docs← Back to article

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.

Download this articlePlain text ↗
# 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/)