nb-04 — The permit fee
Northbridge course, beginner (nb-01 →
nb-02 → nb-03 →
nb-04). All law is
fictional; every act, rate and discount is synthetic and unofficial.
Engine law 0.1.0, semantics law.core/0.2, std 0.2.0.
Situation
Section titled “Situation”Northbridge charges for permits. The clerks apply a simple desk rule: every month costs 10 EUR, so three months cost 30 EUR — but anyone who commits to twelve months or more gets a 20 percent discount. A short application pays full price; a long one pays less per month.
This article turns that desk rule into three Law DSL constructs: a typed constant for the rate, a pure function for the fee, and a decision table for the discount. You will run the fee, discount and otherwise tests, watch which table row fires, then change one input and watch a different row win.
Prerequisites
Section titled “Prerequisites”nb-01: First permit: facts, a rule and a question:
facts, one rule, one question, and law test as the way to check a
claim. This article adds nothing to support or truth statuses —
everything here computes a value (a sum of money, a discount rate),
not a proposition.
Example
Section titled “Example”Excerpt from
packs/examples/language-demo/calculations/package.law
(lines 8–18, identifiers as written):
pub const MONTHLY_RATE: Money = 10 EUR;pub const LARGE_HOUSEHOLD: Integer = 4;
pub function permit_fee(months: Integer) -> Money = months * MONTHLY_RATE;
pub decision LongTermDiscount(months: Integer) -> Decimal { table hit unique { when months >= 12 => 20 percent; otherwise => 0 percent; }}Three ideas in eleven lines:
- Value types travel with their meaning.
MONTHLY_RATEis not a bare number, it isMoneyin EUR;monthsis anInteger. The types are written next to the names in the declarations, and they stay attached to the values:permit_fee(3)is30 EUR, not30. A fee in a different currency would not add up with it. constnames one value once. The rate appears in exactly one place. If the council ever changes the price, one line changes, and every computation that reads the constant follows.functionis pure.permit_feereturns a data value and nothing else: no assertions, no norms, no reading of ambient state. Given the samemonthsit always returns the sameMoney.
The discount is a decision: a named table mapping inputs to an
output, one row at a time. hit unique means at most one row may match;
otherwise is the row for everything else. The package holds six more
tables over the same input, each with a different hit policy:
Excerpt — TieredRate from
packs/examples/language-demo/calculations/package.law lines 20–26
(nothing cut inside the block; surrounding decisions cut).
pub decision TieredRate(months: Integer) -> Decimal { table hit first { when months >= 24 => 0.5; when months >= 12 => 0.2; otherwise => 0.0; }}hit first takes the first matching row in table order. Look at the
guards: for 30 months both months >= 24 and months >= 12 hold, and
the table answers with the first row’s value, 0.5.
The remaining policies each handle overlap differently. hit any
requires all matching rows to agree. hit multiple keeps every match as
a list. hit collect aggregate count (or sum) folds all matches into
one number. hit priority order 0.5, 0.2, 0.0 ignores row order and
picks the matched value with the highest listed priority:
Excerpt — PriorityRate from
packs/examples/language-demo/calculations/package.law lines 59–65
(nothing cut inside the block; surrounding decisions cut).
pub decision PriorityRate(months: Integer) -> Decimal { table hit priority order 0.5, 0.2, 0.0 { when months >= 12 => 0.2; when months >= 24 => 0.5; otherwise => 0.0; }}Note the trap built into that table on purpose: the 0.2 row is listed
first. A reader in a hurry expects row order to decide. It does not —
the priority list decides, and the tests below prove it.
Command and result
Section titled “Command and result”The calculations package is self-contained (its test world is “own only”), so one command checks everything:
law test packs/examples/language-demo/calculationsObserved result (engine law 0.1.0):
law test demo.northbridge.calculations: мир demo.northbridge.calculations ok [demo.northbridge.calculations] tests/calculations.lawtest / fee for three months ok [demo.northbridge.calculations] tests/calculations.lawtest / discount for twelve months ok [demo.northbridge.calculations] tests/calculations.lawtest / short term — otherwise row ok [demo.northbridge.calculations] tests/calculations.lawtest / first takes the first matching row ok [demo.northbridge.calculations] tests/calculations.lawtest / any lets matching rows agree ok [demo.northbridge.calculations] tests/calculations.lawtest / multiple keeps all matches ok [demo.northbridge.calculations] tests/calculations.lawtest / count of matching rows ok [demo.northbridge.calculations] tests/calculations.lawtest / sum of matching rows ok [demo.northbridge.calculations] tests/calculations.lawtest / priority selects by list, not by rowsитого: 9 проверено, 9 прошли, 0 не прошли, 0 не исполнены; код 0The package itself also passes the static check:
law engine check packs/examples/language-demo/calculations/package.lawcheck OK: packs/examples/language-demo/calculations/package.lawThree of those nine tests carry this article:
| Test | Evaluates | Expects |
|---|---|---|
fee for three months | permit_fee(3) | 30 EUR |
discount for twelve months | LongTermDiscount(12) | 0.2 |
short term — otherwise row | LongTermDiscount(3) | 0.0 |
The fee test is pure arithmetic with the currency attached:
3 * 10 EUR = 30 EUR. The two discount tests show the table’s two
rows: twelve months match the months >= 12 row (20 percent,
i.e. 0.2); three months match nothing, so the otherwise row fires
(0 percent, i.e. 0.0). Which band applied is read off from the
value together with the hit-policy tests below — the table’s observable
behavior is the selected value, not a row label.
The remaining six tests pin the hit policies. TieredRate(30) is 0.5:
both percentage rows match, the first one in table order wins.
AnyMatch(18) is 0.2: two rows match and agree, so the answer stands.
MatchedCount(18) is 2: two rows match, and the count says so
explicitly. SumBonus(30) is 0.7: the collect-sum folds 0.5 + 0.2.
PriorityRate(30) is 0.5 — even though the 0.2 row stands first in
the table, the priority list 0.5, 0.2, 0.0 outranks row order.
All nine tests pass: each computed value matched its expectation. The
summary is in Russian: 9 tests checked, 9 passed, 0 failed, 0 skipped,
exit code 0. The first line names the test world (мир is “world”):
the “own only” world from the package header.
Why this shape
Section titled “Why this shape”The task is to turn “three months cost 30 EUR, twelve months earn a
discount” into something the machine computes the same way every time.
A constant fixes the rate in one place, a pure function fixes the
arithmetic, and a table fixes the discount bands — each with an
exhaustive row (otherwise), so no input falls through silently.
Writing the discount as nested if expressions inside the function
would compute the same numbers but hide which band applied. Writing each
band as a separate rule would need conflict machinery
(nb-03: Exceptions and conflicting rules) for
inputs that match two bands. The table keeps overlap handling inside one
declared policy.
The proof is the nine tests above — each input/output pair plus the
row-selection behavior (first, priority, count, sum) is executed
by law test, not asserted in prose. What they do not prove: that
10 EUR or 20 percent is the right price. The tests prove the machine
computes the declared rates; no test can prove the council chose wisely.
Rates are fictional data, not legal advice.
Changed condition
Section titled “Changed condition”Change one input: months 3 → 12 in the discount question.
LongTermDiscount(3) matches no guarded row, so otherwise fires and
the answer is 0.0. LongTermDiscount(12) satisfies months >= 12,
so the first row fires and the answer is 0.2 — the 20 percent
discount. Same table, same policy, different row wins, and the answer
says which one.
The same input change moves through the other tables too:
TieredRate(12) is 0.2 (only the second row matches), while
TieredRate(30) is 0.5 (both match, first wins). One input, two
outcomes, and the policy — not the reader’s intuition — picks between
them.
Typical mistake
Section titled “Typical mistake”Expecting the table to add up rows that merely overlap. A newcomer
sees TieredRate match two rows for 30 months and guesses the answer
combines them — 0.5 + 0.2 = 0.7. It does not: hit first returns one
row’s value, 0.5.
The 0.7 exists, but only where it is declared: SumBonus uses
hit collect aggregate sum, and that table returns 0.7 for 30
months. Likewise hit multiple does not return a number at all —
AllMatches(18) keeps all matches as a list, which is why its test
expects COMPUTED status rather than a scalar value.
Overlapping rows are normal; what happens with the overlap is the
policy’s job. first picks one, any demands agreement, multiple
keeps all, collect folds them, priority ranks them. If you want a
sum, write sum — never assume the table sums by itself.
Limits
Section titled “Limits”- Pure functions only.
functionanddecisioncompute values; they assert nothing, create no duties, and read no register. Anything with support effects — evidence, closure, positions — lives elsewhere (nb-06: When the register may stay silent through nb-09: From permit to duties and powers) and cannot appear in these bodies. - Money is exact.
3 * 10 EURis exactly30 EUR. There is no silent rounding anywhere in this package — and deliberately so: rounding, when a computation needs it, is an explicit call,round(value, precision, mode)ordiv_round(dividend, divisor, precision, mode), with a named mode such as"HALF_UP". This package needs no rounding, so it calls none; the confirmed rounding example lives one level up (see below). - Tables are total only with
otherwise. A table without anotherwiserow (likeAllMatchesorMatchedCount) simply has no answer for inputs that match nothing. Totality is a property you declare, row by row — the engine does not invent a default.
Three levels
Section titled “Three levels”- Northbridge use (this article): rate constant, fee function, discount and tier tables, verified by the nine tests above.
- Domain template: whenever a fee, tariff or quota depends on bands
of one input, use one decision table with one hit policy and an
otherwiserow; expose “which row fired” in the answer; fold (count/sum) only where the domain asks for a fold. - Confirmed example elsewhere: explicit rounding in
demo.northbridge.math02—half_rounded(x) = round(x, 0, "HALF_UP")andratio_seventh() = div_round(7.0, 2.0, 1, "HALF_UP")— verified bylaw test packs/examples/language-demo/math02(42 checked, 42 passed, 0 failed). Rounding exists in the suite, it just does not live in the fee package — exactly as the limits say. Confirmed external formalization: high-36 average retired-pay base (10 U.S.C. section 1407(c)(1)) — packageus.code.military_retirement,corpus/laws/us/military-retirement/01-retired-pay.law:271-281, constructdiv_round(Money, Decimal, scale, policy)single-rounding share (BaseHigh36:div_round(t, 36.0, 2, "HALF_UP")). Money divided by a Decimal divisor with one rounding over the exact quotient under an explicit policy — the explicit, named-mode rounding the Limits section requires.
Evidence and scope of the external example
Documented in
docs/research/constructs/21-expressions-quantities/corpus-forms.en.md,
section 3 (rated exemplary there). Verification confirms the named
construct at the cited lines only, by direct source read; it claims
nothing about deployment, runtime behavior, or legal correctness.
Exercise
Section titled “Exercise”Without running the engine, predict, then check with law test:
TieredRate(12)— value and which row fires?PriorityRate(12)— value and which row fires?PriorityRate(30)— value? What would the answer be if selection followed row order instead of the priority list?MatchedCount(18)— value, and what does it tell you about how many rows matched?
Write down each prediction first; run the suite; explain any miss in one sentence. Checkable solution: the nb-04 solution.
- Source:
packs/examples/language-demo/calculations/package.law - Tests:
packs/examples/language-demo/calculations/tests/calculations.lawtest - Suite tour:
packs/examples/language-demo/README.md - Language reference:
docs/language/02-facts-and-questions.law.md(constants, pure functions, Money),docs/language/08-cheat-sheet.law.md(values),docs/language/09-advanced-cheat-sheet.law.md(decision, hit policies,round/div_round/sumsignatures) - Rounding source:
packs/examples/language-demo/math02/package.law(half_rounded,ratio_seventh) - Prerequisite: nb-01: First permit: facts, a rule and a question; next: nb-05: Who counts as a suitable applicant
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.