Skip to content
docs
Arxo ↗

nb-04 — The permit fee

For LLMs11 sections
← Course mapChapter 04 / 25 · Beginner

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.

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.

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.

Excerpt from packs/examples/language-demo/calculations/package.law (lines 8–18, identifiers as written):

Arxo Law
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_RATE is not a bare number, it is Money in EUR; months is an Integer. The types are written next to the names in the declarations, and they stay attached to the values: permit_fee(3) is 30 EUR, not 30. A fee in a different currency would not add up with it.
  • const names 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.
  • function is pure. permit_fee returns a data value and nothing else: no assertions, no norms, no reading of ambient state. Given the same months it always returns the same Money.

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).

Arxo Law
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).

Arxo Law
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.

The calculations package is self-contained (its test world is “own only”), so one command checks everything:

Terminal
law test packs/examples/language-demo/calculations

Observed result (engine law 0.1.0):

Output
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 не исполнены; код 0

The package itself also passes the static check:

Terminal
law engine check packs/examples/language-demo/calculations/package.law
Output
check OK: packs/examples/language-demo/calculations/package.law

Three of those nine tests carry this article:

TestEvaluatesExpects
fee for three monthspermit_fee(3)30 EUR
discount for twelve monthsLongTermDiscount(12)0.2
short term — otherwise rowLongTermDiscount(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.

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.

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.

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.

  • Pure functions only. function and decision compute 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 EUR is exactly 30 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) or div_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 an otherwise row (like AllMatches or MatchedCount) 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.
  1. Northbridge use (this article): rate constant, fee function, discount and tier tables, verified by the nine tests above.
  2. Domain template: whenever a fee, tariff or quota depends on bands of one input, use one decision table with one hit policy and an otherwise row; expose “which row fired” in the answer; fold (count/sum) only where the domain asks for a fold.
  3. Confirmed example elsewhere: explicit rounding in demo.northbridge.math02 — half_rounded(x) = round(x, 0, "HALF_UP") and ratio_seventh() = div_round(7.0, 2.0, 1, "HALF_UP") — verified by law 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)) — package us.code.military_retirement, corpus/laws/us/military-retirement/01-retired-pay.law:271-281, construct div_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.

Without running the engine, predict, then check with law test:

  1. TieredRate(12) — value and which row fires?
  2. PriorityRate(12) — value and which row fires?
  3. PriorityRate(30) — value? What would the answer be if selection followed row order instead of the priority list?
  4. 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/sum signatures)
  • 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.