docs← Back to article

Markdown for LLMs

Numbers and money

The source Markdown for this article. Copy it into your assistant or download it as a text file.

Download this articlePlain text ↗
# Numbers and money

Until now the archive norms answered "yes", "no", and "not established".
But copy fees, overdue fines, and instalments are numbers, and numbers
have their own rules: a page counter does not add up with a share, money
does not divide by a whole number, and rounding is never implied. In the
corpus, comparison stands in 5,518 rules across 293 packages, arithmetic
in 3,944 across 210, money and percent literals in 228 across 28
(measured 05.09.2026).

The tariffs on this page are for teaching: 150 tenge a page, 200 a day
for overdue items, and a 5,000 cap — example numbers, not enacted law.

```law
language "law.core" version "0.2";
package tutorial.archive version "0.13.0";
namespace "urn:law:tutorial:archive";

entity Person;
entity CopyOrder;

relation copy_order(o: CopyOrder, p: Person, pages: Integer) kind empirical { key(o); }
relation researcher(p: Person) kind institutional;
relation days_overdue(p: Person, days: Integer) kind empirical { key(p); }
relation restoration_cost(p: Person, cost: Money) kind empirical { key(p); }
relation reader_contribution(p: Person, paid: Money) kind empirical { key(p); }
relation reader_discount(p: Person, rate: Decimal) kind empirical { key(p); }

relation copy_fee(o: CopyOrder, amount: Money) kind institutional { key(o); }
relation researcher_fee(o: CopyOrder, amount: Money) kind institutional { key(o); }
relation bulk_order(o: CopyOrder) kind institutional;
relation late_fee(p: Person, amount: Money) kind institutional { key(p); }
relation instalment(o: CopyOrder, amount: Money) kind institutional { key(o); }
relation contribution_share(p: Person, share: Decimal) kind institutional { key(p); }
relation discounted_fee(o: CopyOrder, amount: Money) kind institutional { key(o); }

const PAGE_RATE: Money = 150 KZT;
const RESEARCHER_RATE: Decimal = 75 percent;
const BULK_PAGES: Integer = 100;
const DAILY_LATE_FEE: Money = 200 KZT;
const LATE_FEE_CAP: Money = 5000 KZT;
const INSTALMENTS: Decimal = 3.0;
```

## Four kinds of numbers

| Kind | Literal | What it is |
|---|---|---|
| `Integer` | `7`, `100` | a counter: pages, days, items |
| `Decimal` | `0.9`, `3.0`, `75 percent` | an exact decimal fraction; a percent is `Decimal` `0.75` |
| `Rational` | no literal: only `7 / 2` | an exact fraction; produced by dividing two integers |
| `Money` | `150 KZT`, `5000 KZT` | an amount with a currency; the space is mandatory |

The core does not convert kinds implicitly. `Integer` and `Decimal`
differ: `7` does not equal `7.0`, and when a whole `n` is compared with
`100.5`, the compiler refuses. Kinds meet only in multiplication: whole
and decimal numbers act as dimensionless scalars and multiply anything.

## Tariff: a counter times money

```law
rule CopyFee strict {
    for o: CopyOrder;
    for p: Person;
    for n: Integer;
    when copy_order(o, p, n);
    then copy_fee(o, n * PAGE_RATE);
}
```

| Order | `copy_fee` |
|---|---|
| 7 pages | `1050 KZT` |

The test from this page, byte for byte:

```law
test "семь страниц по 150 тенге — 1050" {
    given {
        context {
            decision_time @2026-04-20T09:00:00+05:00;
            knowledge_time @2026-04-20T09:00:00+05:00;
            legal_time @2026-04-20;
            timezone "Asia/Almaty";
        }
        assert copy_order(entity_ref("urn:tutorial:order1"), entity_ref("urn:tutorial:ivanova"), 7) {
            id "assert-order";
            origin case_input;
        }
    }
    evaluate truth(copy_fee(entity_ref("urn:tutorial:order1"), 1050 KZT));
    expect truth_status == TRUE_ONLY;
    expect evaluation_status == COMPUTED;
}
```

The test name reads: "Seven pages at 150 tenge — 1050."

The rule multiplies the page count by the tariff: a whole number scales
money and does not change the kind. A `truth` query asks whether exactly
this value is established: ask `copy_fee(order1, 1000 KZT)` and you get
`NEITHER`. "How much" can also be asked directly, with an expression
query, covered below.

## Rate and rounding

Researchers pay three quarters of the tariff. `75 percent` is the
`Decimal` `0.75`, and money times a decimal fraction is money of the same
currency. The result must be rounded explicitly: `round` takes three
arguments, none implied.

```law
rule ResearcherFee strict {
    for o: CopyOrder;
    for p: Person;
    for n: Integer;
    for a: Money;
    when copy_order(o, p, n) and researcher(p) and copy_fee(o, a);
    then researcher_fee(o, round(a * RESEARCHER_RATE, 2, "HALF_UP"));
}
```

| Order | `researcher_fee` |
|---|---|
| 7 pages, researcher | `787.50 KZT` |

The core never rounds by itself — neither money nor shares. If a norm's
result may come out inexact, it must name the precision and the mode;
otherwise the engine will not answer "about 787" but refuse.

## Threshold and cap

Comparison is a body guard, and both its operands share one kind: a whole
page count against a whole constant. The fine cap is `min` over two sums
of one currency.

```law
rule BulkOrder strict {
    for o: CopyOrder;
    for p: Person;
    for n: Integer;
    when copy_order(o, p, n) and n > BULK_PAGES;
    then bulk_order(o);
}

rule LateFee strict {
    for p: Person;
    for d: Integer;
    when days_overdue(p, d);
    then late_fee(p, min(d * DAILY_LATE_FEE, LATE_FEE_CAP));
}
```

| Facts | Answer |
|---|---|
| 7 pages | `bulk_order`: `NEITHER` |
| 120 pages | `bulk_order`: `TRUE_ONLY` |
| 3 days overdue | `late_fee`: `600 KZT` |
| 40 days overdue | `late_fee`: `5000 KZT` |

Below the threshold the rule stays silent and answers `NEITHER`, not
"false". Comparison does not bind a variable but checks it; candidates
are enumerated by the positive `copy_order` literal, and without it the
compiler refuses.

## Division

Division is arranged most strictly: the specification lists the pairs
that divide, and the list is closed. Money divides by `Decimal`
and only by it; the engine does not silently round an inexact quotient
but rejects it. One term divides under a policy — `div_round`: it rounds
the exact quotient.

```law
rule Instalment strict {
    for o: CopyOrder;
    for a: Money;
    when copy_fee(o, a);
    then instalment(o, div_round(a, INSTALMENTS, 2, "HALF_UP"));
}
```

| Expression | Answer |
|---|---|
| `1050 KZT / 3.0` | `350 KZT` |
| `1000 KZT / 3.0` | issue `INEXACT_DIVISION` |
| `div_round(1000 KZT, 3.0, 2, "HALF_UP")` | `333.33 KZT` |
| `1050 KZT / 3` | issue `TYPE_ERROR` |
| `7 / 2` | an exact `Rational` fraction |
| `250 KZT / 1000 KZT` | `0.25` — a share, not money |

The fourth row catches most often: a money divisor is written `3.0`,
not `3`. A whole number does not act as a scalar here, because the
scalar rule applies only in multiplication. The sixth row divides
two sums of one currency: the quotient is dimensionless and suits the
proportions set by the act itself.

```law
rule ContributionShare strict {
    for p: Person;
    for c: Money;
    for paid: Money;
    when restoration_cost(p, c) and reader_contribution(p, paid);
    then contribution_share(p, paid / c);
}
```

| Facts | `contribution_share` |
|---|---|
| restoration 4,000, reader contribution 1,000 | `0.25` |

There is a catch: statics does not know which kind a `Money / Money`
quotient has, and treats it as `Unknown`, because the kind depends on
the value — a decimal fraction with a 10-smooth denominator, else
`Rational`. `check` will not catch an error around such an
expression; it will arrive at runtime.

## Key: one value per order

`key(o)` on `copy_order` says: the page count is a function of the order.
Two different counts on one order are not two facts but a contradiction:

| Facts | `copy_fee(order1, 1050 KZT)` | Document |
|---|---|---|
| an order for 7 pages and the same order for 9 | `TRUE_ONLY` | issue `KEY_CONFLICT` |

A key deletes and picks nothing: both facts remain, the tariff
is derived from each, and the discrepancy gets a name. Without a key the
predicate would answer one question with two sums and stay silent about
it. That is why computed heads also carry keys — `copy_fee`, `late_fee`,
`instalment`: this is the corpus-wide convention for all
relations whose value a calculation derives.

## A fraction that became a whole

A reader discount is a `Decimal`. Record it in a test as `0.9` and all
agrees; record the full rate as `1` and you get a runtime refusal with
clean `parse`, `check`, and `lower`.

```law
rule DiscountedFee strict {
    for o: CopyOrder;
    for p: Person;
    for n: Integer;
    for a: Money;
    for r: Decimal;
    when copy_order(o, p, n) and copy_fee(o, a) and reader_discount(p, r);
    then discounted_fee(o, round(a * r, 2, "HALF_UP"));
}
```

| Discount fact | `discounted_fee` | Document |
|---|---|---|
| `reader_discount(Ivanova, 0.9)` | `945.00 KZT` | — |
| `reader_discount(Ivanova, 1)` | `NEITHER` | issue `TYPE_ERROR` |

The literal `1` reads as `Integer`, the parameter is declared `Decimal`,
and the core does not mix them. The trap is insidious because the CLIR canon
strips the fractional tail off `Decimal`: a `1.0` value from the
case prints as `1`, and text reproduced from the document verbatim changes
kind. Write `1.0`; with money the currency suffix keeps the kind, and
that trouble does not exist.

## Asking a number directly

An expression's value is asked without a rule — by an expression query
with an `expect value` expectation. Such tests live in
the page file; here — in text, together with the runtime refusals, which
are checked by observing `issue`:

```text
evaluate 7 * 150 KZT;                            → 1050 KZT
evaluate 25 percent;                             → 0.25
evaluate -7 mod 3;                               → 2 (евклидов остаток)
evaluate 1050 KZT + 0.5;                         → issue(TYPE_ERROR)
evaluate 1 + (1 / 2);                            → issue(TYPE_ERROR): счётчик и доля не складываются
evaluate 1000 KZT + 5 USD;                       → issue(CURRENCY_MISMATCH)
```

The parenthetical notes read: "(Euclidean remainder)" and "a counter and a share do not add up".

Runtime refuses where the compiler stays silent: `check` lets
`1000 KZT + 5 USD` through, because currency lives in the value. Guard
such places with a test.

## Compiler refusals

Comparison does not bind a variable. Remove the `copy_order` literal from
`BulkOrder`, leaving one guard:

```text
error LDC-E4101: BulkOrder: head использует несвязанные переменные [o] —
правило не range-restricted (§190: связывание только позитивным
established-конъюнктом конечного источника)
```

The diagnostic reads: "'BulkOrder': head uses unbound variables [o] — the rule is not range-restricted: a variable must be bound by a positive established conjunct of a finite source".

A threshold of the wrong kind — `n > 100.5` with a whole `n`:

```text
error LDC-E2108: «>»: несравнимые виды Integer и Decimal — §57/§48 требуют
совпадения видов, неявных конверсий нет
```

The diagnostic reads: "'`>`': incomparable kinds Integer and Decimal — kinds must match, no implicit conversions".

Rounding without a policy — `round(a * RESEARCHER_RATE)`:

```text
error LDC-E2116: round: объявленная арность стандартного терма — 3;
передано 1. Арность задана §257 и разделом семантики самого терма
(§50/§58/§59/§84/§86); догадки по числу аргументов нет
```

The diagnostic reads: "'round': the declared arity of the standard term is 3; 1 passed. Arity is set by the term's own semantic section; no guessing by argument count".

All three refusals belong to the language: the compiler rejects such
variants. Note where statics stops: the compiler does not trace the kind of the
`PAGE_RATE` constant in an expression, and `PAGE_RATE / n` passes `check`
to fall with `TYPE_ERROR` at runtime. A literal and a variable the
compiler catches earlier; a constant is guarded by a test.

## Next

One number from one fact is half of calculations. The other half is
a sum over many: a reader's orders for a month, the count of unpaid ones,
the average cheque. Those are aggregates, with their own trap: two
identical sums that count as one, and an empty set that does not equal
zero — [the next tutorial](/tutorials/aggregates/).

The exercise for this page is [/tutorials/exercise-numbers/](/tutorials/exercise-numbers/).