Markdown for LLMs
nb-04 — The permit fee
The source Markdown for this article. Copy it into your assistant or download it as a text file.
# nb-04 — The permit fee
*Northbridge course, beginner ([nb-01](/tutorials/northbridge/nb-01-first-permit/) →
[nb-02](/tutorials/northbridge/nb-02-missing-fact/) → [nb-03](/tutorials/northbridge/nb-03-exceptions/) →
[nb-04](/tutorials/northbridge/nb-04-permit-fee/)). 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
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
[nb-01: First permit: facts, a rule and a question](/tutorials/northbridge/nb-01-first-permit/):
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
Excerpt from
`packs/examples/language-demo/calculations/package.law`
(lines 8–18, identifiers as written):
```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).
```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).
```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.
## Command and result
The calculations package is self-contained (its test world is "own
only"), so one command checks everything:
```sh
law test packs/examples/language-demo/calculations
```
Observed result (engine `law 0.1.0`):
```text
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:
```sh
law engine check packs/examples/language-demo/calculations/package.law
```
```text
check OK: packs/examples/language-demo/calculations/package.law
```
Three 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
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](/tutorials/northbridge/nb-03-exceptions/)) 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
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
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
- **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](/tutorials/northbridge/nb-06-register-silence/)
through [nb-09: From permit to duties and powers](/tutorials/northbridge/nb-09-duties-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.
## Three levels
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.
<details>
<summary>Evidence and scope of the external example</summary>
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.
</details>
## Exercise
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](/tutorials/northbridge/solutions/nb-04-solutions/).
## Links
- 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](/tutorials/northbridge/nb-01-first-permit/);
next: [nb-05: Who counts as a suitable applicant](/tutorials/northbridge/nb-05-suitable-applicant/)