Write a rule (.rule)
A .rule file has one fixed shape, read from the top. There is no
forward reference, so reading downwards is reading the dependencies in
order. The keywords are English; the names and the cell values stay in
the language of the business — English in the worked example this page runs on, and
Japanese in the sections that quote a rule transcribed from a Japanese statute, which is the
language it was published in.
Usually an agent writes this
Nothing stops you writing a .rule by hand, and this page is also how. But the shape
this tool is built for is an agent transcribing from a published policy or a
spreadsheet, and a person reading the table it produced and checking it. So what this
page is most worth for is learning to read one — writing follows from the same
material.
And you are not left alone with the file when you read it. rulec doc renders the
document for people, and adds what reading the table cannot tell you: what a single
word in the table actually stands for, which rows are hidden by the rows above them,
and which roundings are placeholders rather than decisions. The same document comes
as one HTML page too (--format html), where typing a case lights up the rows that
matched and shows the result. For a change, how many records move and by how much
comes out before it ships. Both are things to ask the agent for, and so is an
explanation of any row. From a spreadsheet, rulec import xlsx writes the first
draft out of the workbook itself, with every guess marked. Its own procedure is in For agents.
These are all the words that may start a line.
| word | what it declares |
|---|---|
rule |
the first line: the rule's name and version |
description |
one line of prose |
import |
brings in the values of an enum (built-in, a .proto, or a JSON Schema) |
enum |
a closed enumeration |
group |
a named subset of an enum |
inputs |
the rule's arguments |
elements |
the fields of one element of a sequence the rule walks |
outputs |
its results |
derive |
a linear combination of inputs — the only intermediate that can sit in a table column while still being a quantity |
define |
a boolean, or a computed intermediate |
constraint |
a relation between inputs: which combinations cannot happen |
fold |
reduces a column of per-element verdicts to one answer |
count |
how many elements of the sequence meet one test |
sum |
the total of one column over the elements of a sequence |
sequence |
a named list of elements, for an example to walk |
table |
a decision table. The body of the language |
policy |
that table's hit policy (unique or first) |
overrides |
when a table above defines the same output, says that this table's rows take precedence over it. The line after policy; table:label names one row |
clause |
a one-line rule that does not fit a table, written as a sentence: when <column> <cell> and … (when always when there is no condition), then <value>, and overrides when needed |
source |
a document the rule transcribes: a law in a statute database (law [<database>] "<id>" asof <date>) or a file beside the rule (file "<file>" sha256:…). A table, clause, row, derive or define cites it at the end of its line: @source 第20条 |
shape |
the shape of the caller's object, borrowed from the contract it already has (jsonschema "<file>" "<pointer>" or proto "<file>" <Message>). An input then says from <shape>.<field> at the end of its line |
apply |
another rule file, applied with its inputs read as this rule's values: <its input> = <this rule's value>, except <definitions not applied>, <its output> -> <name> |
result |
assembles an output |
machine |
reads a table as one step of a case that goes on: which output comes back as which input on the next call, where a case starts and ends, and what no sequence of calls may do |
examples |
an executable specification |
scenario |
an example that runs for several calls, each starting where the one before ended |
A complete rule
This one is tests/corpus/parcel_rate.rule: it checks clean as it stands, and the
repository's test suite generates it and runs it in every target language on every commit.
The amounts are made up, but nothing else about it is.
rule parcel_rate v1
description "A parcel tariff in pounds and inches, written in English. A sketch, not a transcription: the amounts are made up"
# Nothing else in the corpus is priced in USD by weight, and nothing at all reached oz, lb
# or in — an unexercised unit is an unchecked unit (§15.9).
enum size_class = envelope | small | large
enum zone = domestic | canada | overseas
group north_america = domestic, canada
inputs
weight : mass[lb] range >=1lb <=70lb
girth : length[in] range >=1in <=130in
dest : zone
signature : bool
outputs
fee : money[USD, incl_tax] round up(1USD)
# One table decides the class and the next one prices it: what the first produces is a
# column of the second.
table size_of
policy first
| girth | -> size : size_class |
| <=22in | envelope |
| <=60in | small |
| - | large |
table base_rate
policy unique
| dest | size | weight | -> base : money[USD, incl_tax] |
| north_america | envelope | - | 6USD |
| north_america | small | <=160oz | 12USD |
| north_america | small | >160oz | 18USD |
| north_america | large | <=160oz | 22USD |
| north_america | large | >160oz | 30USD |
| overseas | envelope | - | 16USD |
| overseas | small | - | 38USD |
| overseas | large | - | 60USD |
# A fuel surcharge is a percentage of the base, which is what the rounding on the output is
# there to settle: 12USD at 5% is 12.60USD, and up(1USD) makes that 13USD.
table fuel_rate
policy unique
| dest | -> fuel : rate[step 1%] |
| north_america | 5% |
| overseas | 12% |
table signature_fee
policy unique
| signature | -> extra : money[USD, incl_tax] |
| true | 4USD |
| false | 0USD |
result fee = base + base × fuel + extra
examples
| weight | girth | dest | signature | -> fee |
| 5lb | 10in | domestic | false | 7USD |
| 5lb | 40in | canada | false | 13USD |
| 20lb | 40in | domestic | true | 23USD |
| 5lb | 10in | overseas | false | 18USD |
Names and ASCII aliases
What is in the parentheses is the ASCII alias, and it becomes the public name in the generated code — a kanji cannot be an exported Go identifier.
An alias is required only where a non-ASCII name reaches the public surface — the rule name, the inputs, the outputs — because a kanji has no uppercase and cannot begin an exported Go identifier. A name that is already ASCII needs none: write the whole rule in English and there are no parentheses anywhere — as in the rule above, and in Article 7 of Regulation (EC) No 261/2004, which is transcribed into the corpus in English, in EUR and km.
Elsewhere the alias is optional, and writing one decides what the
generated code calls the value: derive 残余(margin) becomes margin,
group 遠隔地(remote) becomes _remote / isRemote, a table output
column -> サイズ(size) becomes size. Leave it out and the declared
name is the identifier — every target language takes a Japanese one for
something that is not exported.
The alias on a table is the exception: accepted, and currently unused,
because a table is inlined into the one generated function instead of
becoming a function of its own. It is kept for SQL generation.
Types
Ten, and no others.
| type | written | the thing to know |
|---|---|---|
| boolean | bool |
|
| enum | size_class |
a closed finite set. Declared with enum or brought in with import |
| quantity | mass[g] length[cm] area[m2] volume[L] duration[h] |
the unit is part of the type. 2kg is sugar for 2000g; at run time the value is one integer in the declared unit. Mass is mg g kg t oz lb, length mm cm m km in ft yd mi, area mm2 cm2 m2 a ha km2 坪 in2 ft2 yd2 mi2 ac, volume mm3 cm3 m3 mL L kL, duration ms s min h d w. Dimensions do not multiply into one another — an area is its own type, and width * depth is E103 |
| ordered quantity | temperature[℃] sound[dB] |
comparison and range only: they do not add (E048). 41℉ is exactly 5℃ and a literal converts between them, but the difference of two temperatures is not a temperature, and a decibel is a logarithm, so two of them added are not two sounds' worth |
| money | money[JPY, incl_tax] money[USD, excl_tax] |
branded twice, by currency and by tax flag. incl_tax and excl_tax do not add. The currency is 円 or any ISO 4217 code; its hundredth is the code plus c, so money[USD] counts dollars and money[USDc] cents. Two currencies never convert — there is no exchange rate here, and mixing them is E103 |
| rate | rate[step 1%] rate |
an integer throughout, counting steps (10% is 10 with rate[step 1%]). An input declares its step; a computed rate may leave it out, and then it comes from the literals in its column |
| number | number |
a whole number with no unit — a count of things, a number of days, a score. Dividing money by money in the same currency drops the unit and lands here |
| date | date |
comparison and range only. There is no date arithmetic |
| string | string |
cannot be a table column (E110). Use it for an output, or for an input that only passes through. A value that decides a branch belongs in an enum |
| optional | size_class? |
consumed only by the cell none |
Quantities, money, rates and dates are all integers — a date is a day
ordinal, a rate a count of steps. No floating point appears anywhere:
how a fraction is settled is decided by round below, never by the
host language's division.
Enums are closed, and there is no open enum. That is the point: when a value is added, every table that has not accounted for it breaks the completeness check.
default declares "this value needs no row of its own; being caught by
a - row is correct". Without it, a value no row names is reported.
A group is a named subset of an enum and may be used in a cell wherever a value may. Groups are always expanded before checking, so a hole in a table written with groups is still found.
Imports
Two lines start with import, and both bring in the values of an
enum — nothing else crosses a file boundary. A rule stays one file:
what arrives is a set of names, not rows and not amounts.
| line | what it brings | who owns the set |
|---|---|---|
import std/<name> |
a built-in enum | rulec, frozen |
import proto "<file>" <Enum> -> <enum of this rule> |
the values of an enum in a .proto |
that .proto, outside this rule |
import jsonschema "<file>" "<pointer>" -> <enum of this rule> |
the values of an enum in a JSON Schema (OpenAPI included) | that file, outside this rule |
rulec import is a different thing that shares the word
rulec import csv and rulec import xlsx are a command: they
write a first draft of a .rule from a spreadsheet, once, and leave
no line in the file. The two lines above are read again on every
rulec check.
Built-in enums
The first-level divisions of thirteen countries are built in:
import std/us/states brings in the 56 values of us_state, and
std/gb/nations, std/cn/provinces, std/tw/divisions,
std/kr/provinces, std/in/states, std/fr/regions,
std/es/communities, std/it/regions, std/de/states,
std/au/states, std/br/states and std/jp/prefectures the rest. A
value is the English name in ASCII (New_York, Bavaria, Tokyo), and
a cell may also write a division in the local spelling (Bayern) or by
its ISO code (NY): each is the same value. import std/都道府県 is the
same 47 prefectures with the values spelled in Japanese. See
the reference.
When the set belongs to somebody else
An enum like a member tier or a status is usually declared in a
.proto, and whether it gains a value is decided outside this rule. Say
where the set comes from, and the two are held together.
import proto "api/v1/order.proto" MemberTier -> member_tier
enum member_tier = basic | gold | platinum default
The .proto owns which values exist; the .rule owns what they
are called here and how each one is treated. Every rulec check reads
that file and holds the two together.
- A value on one side only is E032. It is usually the proto that gained one, and on the wire that is a compatible change.
- Once the sets agree, a value that no row names and no
defaultmarks is E033. For an enum you wrote yourself that state is a warning (W111); for an imported one it is an error, because the value arrived through a change nobody has read yet.
A table with a - row passes the completeness check when a new value
turns up, and the value quietly takes the default amount. That is what
E033 stops.
From a JSON Schema or an OpenAPI document
The same binding, written with a JSON Pointer, because one document holds hundreds of enums:
import jsonschema "api/openapi.json" "#/components/schemas/MemberTier" -> member_tier
enum member_tier = basic | gold | platinum default
The pointer may land on the schema or on its enum array, and an enum
written inline in a property is reached the same way. The values become
the aliases exactly — nothing taken off the front, no case folded —
because a schema has no naming convention to earn a transformation from.
YAML is not read. A reader for the subset one file happens to use is a reader that goes wrong quietly on the next one. Point at a JSON form of the document; most toolchains can write one.
Inputs and outputs
inputs
weight : mass[lb] range >=1lb <=70lb
girth : length[in] range >=1in <=130in
dest : zone
signature : bool
outputs
fee : money[USD, incl_tax] round up(1USD)
There may be several outputs. They become a NamedTuple in Python, an
interface in TypeScript, a plain object in JavaScript, a Struct in
Ruby, a final class in PHP, a record in Java, a struct in Rust, Swift and Go, one array per
output in NumPy, one column
each in SQL, and the keys of the
answer's observed object from the Wasm module — and rounding applies once per
output.
An output returns the binding of its own name — a define or a table
output column called raw_fee is what the output raw_fee returns. result
is sugar for that, and it reaches the first output only: naming a later
one is E015, and a second result line is E016.
range and round — the two you are not allowed to forget
These are not decoration. They are where this language is aimed.
range, on every numeric input and every derive
One declaration does three jobs.
- Overflow proof. Whether an intermediate fits in int64 is computed from the declared ranges and steps.
- The universe of the completeness check. "Every input matches some row" means every input within these ranges.
- The entry guard of the generated code. Called outside the range, it returns an error instead of silently computing.
A derive whose range does not contain the interval it can actually
reach is an error — if inputs of 0 to 1,000,000 yen can produce
−100,000, then −100,000 has to be in the range.
An input that only ever acts as an entry check takes contract_only:
"this does not appear in any table's conditions, but the contract still
holds it to its range". That silences the unused warning, and nothing
else does.
round, on every numeric output
Without it, the generated code would settle fractions on its own. Four modes, each pinned down for negative values too.
| mode | direction | at grid 1 yen |
|---|---|---|
up |
away from zero | −4.2 → −5 |
down |
toward zero | −4.8 → −4 |
half_up |
an exact half goes away from zero | −4.5 → −5 |
half_down |
an exact half goes toward zero — the payroll deduction rule of the social insurance tables (50 sen and under rounds down) | 4.5 → 4, 4.6 → 5 |
half_even |
an exact half goes to the even neighbour | 2.5 → 2, 3.5 → 4 |
What is in the parentheses is the grid: up(10USDc) rounds to a
multiple of ten cents, so −4.2 cents becomes −10 cents.
The negative direction is pinned because integer division in Python and
Ruby rounds toward −∞ while in Rust, Swift, Go, Java, TypeScript, JavaScript, NumPy,
PHP's intdiv, SQL and the Wasm module it truncates toward zero. Left to the host language, one rule
would answer differently in each. The generated code goes through its own helper, and
that they all agree is checked by unit vectors on every run.
Inputs taken from the caller's object: shape and from
A rule's inputs are flat values, and what the caller holds is usually a nested object — an
API request, a message on a queue. When its shape is already described by a JSON Schema or a
.proto, shape borrows that description, and a from at the end of an input's line says
where in it the value stands.
shape order = jsonschema "contracts/order.schema.json" "#/$defs/Order"
inputs
dest : zone from order.shipping.zone
chilled : bool from any order.lines where chilled = true
lines : number range >=1 <=50 from count order.lines
A .proto is named by its file and message:
shape shipment = proto "contracts/shipment.proto" shop.v1.CreateShipmentRequest.
from comes in four shapes:
| written | what it yields |
|---|---|
from order.shipping.zone |
the value of that field |
from any order.lines where chilled = true |
bool — whether some element passes |
from all order.lines where chilled = true |
bool — whether every element does |
from count order.lines |
number — how many elements there are (with where, how many pass) |
Four things follow.
- The code that reads the inputs out is generated. Beside the rule's own function,
rulec genwrites a second one that takes the whole order object. If the rule's function isorder_shipping, this one isorder_shipping_from(order): it reads the inputs out as thefroms say, calls the rule's function and returns its answer, so the caller hands it the order as it is. It is written for Python, TypeScript, JavaScript, Ruby and PHP. In each of them, parsed JSON is usually used as it comes, as a plain map (adictin Python, aHashin Ruby), so the function takes a plain map too. Go, Swift, Java, Rust, SQL, NumPy and Wasm do not get it. From a.proto, it reads the JSON protojson writes: a field under its lowerCamelCase name or its.protoname, and one left out as its proto default. How to call it, and why the other seven do not get it, are in Generate and call. - The paths are held to the contract. Every
rulec checkreads the contract's file. A path it does not have is E121, which says how far the path got and which fields were there; a type that does not fit is E120; ashapeno input reads from is W122. A field the contract renames stops CI instead of raising aKeyErrorin production. - The contract's validation is held to the inputs' declarations. A value the contract
lets through and an input refuses is E122: without a
maxItemson the contract'slines, an order of 51 lines passes the contract, andlines, declaredrange >=1 <=50, refuses it.fix.textis the annotation or keyword to add to the contract. A row reached only by values the contract never lets through is W123. - The contract's conditions across fields are held to the rule. A CEL expression on the
message, a
oneofand JSON Schema's combinators relate fields to each other. Aconstraintthe contract does not keep is E123, and a row asking for a combination the contract never lets through is W124.
No check of the table changes. What comes out of a projection is a scalar input like any
other, and completeness and overlap are decided as they would be without from.
One collection, and a unary test on a field of an element, is as far as it goes. A join, a nested quantifier and a path inside a cell cannot be written: the cell language is where this tool's boundary is.
Examples has two worked rules with their contracts beside them, one on a JSON
Schema and one on a .proto; the details are in the
grammar.
Tables
table base_rate
policy unique
| dest | size | weight | -> base : money[USD, incl_tax] |
| north_america | envelope | - | 6USD |
| north_america | small | <=160oz | 12USD |
| north_america | small | >160oz | 18USD |
| north_america | large | <=160oz | 22USD |
| north_america | large | >160oz | 30USD |
| overseas | envelope | - | 16USD |
| overseas | small | - | 38USD |
| overseas | large | - | 60USD |
Left of -> are input columns, right of it output columns. A column may
name an input, a derive, a boolean or enum intermediate, or an output
of an earlier table.
That last one is how tables stack, and stacking is how a complicated rule
gets written: in the rule above, table size_of produces size, which is
a column of table base_rate. There is no limit on the depth, and one
table may produce several output columns.
There are exactly two policies.
unique(the default) — any overlap is an error. The order carries no meaning, so reordering the rows cannot change the answer.first— the earliest matching row wins. It exists to accept the way the business actually writes: the exceptions first, the general case last.
DMN's Any, Priority and Collect are not adopted, and completeness cannot be waived — it is always required. A table that tolerates a hole cannot be written.
What a cell may hold
Seven kinds, and no others.
| written | means |
|---|---|
- |
any value. A blank is a syntax error: a blank cannot be told from a forgotten entry |
12USD 160oz true 2026-04-01 |
equality with a literal. A quantity or an amount must carry its unit (a bare 160 is an error) |
domestic, canada |
a set. Each element is a literal or a group name |
not: north_america |
the complement |
<=160oz |
comparison — <=, >=, <, > |
>=10USD <200USD |
an interval (two comparisons side by side mean "and") |
none |
an optional that is absent |
The symbols are ASCII. →, ・ and 、 are still read, and rulec fmt
rewrites them to -> and ,; outside names and cell values, no IME is
needed.
Range notation with .. is a syntax error. "Up to 2000g" does not
say whether the endpoint is included; a comparison operator does. A
boundary stitched together wrongly is caught by the overlap check, with
a witness.
derive, define, result
A table holds the branching and nothing else. The arithmetic lives in three places outside it.
| what it may hold | can it be a column? | |
|---|---|---|
derive |
a linear combination of inputs — +, -, multiplication by a constant |
yes, and it stays a quantity |
define |
a boolean (two shapes), or a computed intermediate value | a boolean or an enum one can |
result |
+ - * /, parentheses, min and max, and the five rounding modes as functions |
— |
A rate can be multiplied in (base_fee × pay_rate); it stays a rate to the end and the
rounding happens once. Everything is an integer — no floating point anywhere.
A derive is a linear combination of inputs only, and can sit in a table column while still being a quantity.
Policies that judge on the amount after a discount are real ("if the post-coupon total is at least 3,980 yen…"). If that could not be a column, the most error-prone subtraction in the whole rule would live as one bare line on the calling side.
A define names a boolean or an intermediate value. A boolean one may sit in a column.
Its condition holds either one value compared with a constant, or a
comparison of two values whose difference cannot be subtracted (two
dates, say). Comparing two numbers directly is refused, with a message
asking you to declare the difference as a derive — that way the
analysis is exact.
A result assembles an output.
The operations are addition and subtraction, multiplication by a
constant, multiplication by a rate, min, max, allocate, and the
five rounding modes. There is no loop and no recursion.
allocate hands an amount out over a run of lines, in the ratio of
their prices. It is the one place a rule divides by something that is not
a constant.
constraint price_upto <= price_total
derive share_upto : money[USD] = allocate(discount_total, price_upto, price_total) range >=0USD <=10000USD
result share = share_upto - share_before
Each line gets the share up to it minus the share up to the line before,
so the odd yen lands on the last line and the parts add up to the
amount exactly — which proofs/ states and proves rather than leaving
to the examples. It asks for three names with declared ranges, none of
them negative, a positive whole, and the constraint above; anything
missing is E117.
Something complicated is written by stacking tables
Because a cell can only see its own column, tables stack as deep as you like. What one table produces is written as a column of the next.
What to look at is the word that appears twice. band leaves the first table and arrives
as a column of the second. Not every table is in the chain: reduction reads the rule's
inputs directly, because Article 7(2) restates the distance conditions rather than referring
back to them.
Depth costs no visibility. When a check fails it names the row that fired in each table.
Four things matter when stacking.
| A table's output is a column of any later table | There is no limit on the depth; only the check's budget stops it, at E109 |
| One table may produce several output columns | one table above produces the fee and the rate that scales it at once |
A derive can be a column |
"Judge on the amount after the discount" becomes one column instead of one bare line of arithmetic |
| Completeness is checked across the stack | The second form of E102 is "the upstream table never emits that value" |
A rule that does this, and runs, is Examples → "Three tables stacked, two outputs returned".
A main rule and its exceptions as two tables
A tariff usually comes with a main rule and an exception that takes precedence over it:
"a contract for more than 100,000 yen is taxed at the reduced rate until 31 March 2027". It
can be written as one table with a reduced column, but when the sources are two — the
appendix table of the Stamp Tax Act and Article 91 of the Special Taxation Measures Act — two
tables read better against them.
table base @stamp_act 別表第一
policy unique
| stated | amount | -> tax : money[JPY] |
r1 | false | - | 200JPY |
r3 | true | >=10_000JPY <=100_000JPY | 200JPY |
r4 | true | >100_000JPY <=500_000JPY | 400JPY |
r5 | true | >500_000JPY <=1_000_000JPY | 1000JPY |
table reduced_rate @measures_act 第91条
policy unique
overrides base
| reduced | stated | amount | -> tax |
| true | true | >100_000JPY <=500_000JPY | 200JPY |
| true | true | >500_000JPY <=1_000_000JPY | 500JPY |
overrides base declares that the rows of this table take precedence over the rows of
table base (an excerpt; the real tables have more rows). r1 at the head of a row is a label,
which is how a single row is named: overrides base:r4.
The checks treat the tables that define one output as one set. Completeness is judged over
both together, and when there is a hole, which table gets the row is a person's decision. Where
rows meet, an overrides line settles it and the overlap passes; without one it is E105. A row
of the main table that the exception covers entirely is E102, and an overrides line whose rows
meet none of the other's is W117.
The generated code tries the later table first and takes the first row that applies. The trace names the table the row was written in and its position there, plus the label when it has one. The page for people says, in one sentence, "table reduced_rate takes precedence over table base. in all 10 pairs that meet, the rows of table reduced_rate lie inside the other's (an exception)".
A rule written as a sentence: clause
A one-line rule whose conditions do not line up as columns — a proviso, typically — is not
forced into a table. It is a clause.
clause regular -> fee # Article 3(1), the main text
when always
then base
clause free -> fee # Article 3(2), the proviso
when total >=3900JPY and member true
then 0JPY
overrides regular
when joins <column> <cell> pairs with and, the cell being any of the seven kinds a table
cell may hold. A rule with no condition writes when always (so that a forgotten line cannot be
mistaken for one, the same reason a blank cell is refused). then holds what an output cell
holds: a literal or a name.
A clause is treated as a table with one row: checked, generated and traced by the same
machinery, firing as {"table":"free","row":1}. It mixes with tables through overrides.
Where it was transcribed from: source and @
A rule transcribed from a published policy or a statute can say where each part came from:
source declares a document, and @source at the end of a table, clause, row, derive or define
line cites it.
source japanpost = file "yupack_tariff.pdf" sha256:9e4edb5b6a1c0f42
source measures_act = law "332AC0000000026" asof 2026-04-01
第91条 sha256:85faf53f6f6e8196
source osha = law ecfr "29 CFR 1910" asof 2026-01-01
"§1910.157" sha256:c2a9ce966c7e2269
define reduced : bool = made <= 2027-03-31 @measures_act 第91条
table fee_table @japanpost
table distance @osha "§1910.157"
There are two kinds of document, cited and copied a little differently.
| Document | Declared as | Cited as | Its copy |
|---|---|---|---|
| A file beside the rule (a policy PDF, a tariff sheet, a company rule in Word) | source japanpost = file "<file>" sha256:<digest> |
@japanpost, or @japanpost table1 to say which table of it was transcribed |
the file itself; rulec source pin writes its digest on the source line, and a cited table is taken out of the document and kept beside it |
| A statute | source stamp_act = law [<database>] "<id>" asof <date>, the date saying which text is meant |
always with the fragment, named the way that database names one | rulec source fetch brings each cited fragment into sources/ beside the rule; rulec source pin writes each copy's digest on the line under source |
Two statute databases, and the word after law says which.
| word | the database | the id | a fragment |
|---|---|---|---|
(none), or egov |
e-Gov, the Japanese government's statute database | 342AC0000000023 |
第91条, 第20条の2第3項, 別表第一, 附則第3条, an amending law's as 附則(令和七年三月三一日法律第一三号)第3条 |
ecfr |
the Electronic Code of Federal Regulations — US federal regulations as in force on a date | a title and a part, 29 CFR 1910 |
a section, §1910.157 |
A fragment the language cannot read as one word is quoted, in the citation and on the pin line
alike: @osha "§1910.157". A paragraph of a CFR section ((d)(2)) is not addressed yet, because
the eCFR serves a section at a time.
From then on every rulec check confirms that the copies are there and that their digests
are what the rule says. When a copy differs — the file was replaced, or the article was
fetched again after an amendment — the check stops and names the tables, clauses and rows
that cite it (E038), which is all there is to reread. check itself never reads the network.
rulec source outdated asks whether the original moved on. For a statute it asks the database
whether an amendment after asof changes the text of a cited fragment — e-Gov by the revision's
enforcement date, the eCFR by the amendment date of that very section, and in both a re-issue
that only moved the markup does not count; for a file with a url "…", it looks where the file came from and says whether a
cited table changed, or only something this rule does not transcribe. check cannot know of
an amendment until the copy is fetched again, so this belongs in a scheduled CI job.
Cite a table and its amounts are held to the copy
@japanpost table1 on a file source makes rulec source fetch take that table out of the document and
write it beside it. A sheet is a table in a workbook (.xlsx), a table is a table in a Word
file (.docx), and Markdown and CSV are what they look like. A PDF or a scan cannot be read
here: hand an extractor (docling and the like) to rulec source fetch --via <cmd>, or cite the
document whole as @japanpost.
What the copy then holds is the amounts the table writes.
$ rulec check rules/uk_minimum_wage.rule
error[E116]: The amount of row 2 is not in the copy it cites
--> rules/uk_minimum_wage.rule:26 table by_age row 2
|
26 | | >=18 <=20 | 1058GBPc |
| ^^^^^^^^ not in the copy: 1058GBPc
|
The copy cited: gov table1
An amount is not rewritten as it is transcribed, so either it was mistyped or it came from
somewhere else. …
A mistyped digit is E116 — here 1058 for 1085, in a table whose rows still tile the whole input
space and overlap nowhere. The other half of the same slip is a figure the copy states that no
row uses, W120 (The copy of table1 states values no row uses), and W120 alone catches a row
that was never transcribed. Only amounts are compared: a threshold is rewritten as it is
transcribed (Not over $11,925 becomes <=1192500USDc) and an amount is not.
The page for people quotes the cited text, or the cited table, from the copies.
Applying another rule: apply
"The provisions of Article 20 apply to part-time staff. In this case, 'years of service' shall
be read as 'period in office'." A statute written this way is saying that the rule of Article
20 is used once more with its inputs replaced. apply writes exactly that.
apply retirement = "retirement_pay.rule" sha256:b58648ea2767ebbd # Article 31
years = tenure
reason = end_reason with term_end -> retirement_age, resignation -> voluntary
base_pay = monthly_pay
except reduction
allowance -> part_time_allowance
The heading names the rule file being applied and the digest of that file. The lines under it
are the substitutions: every input of the applied rule, without exception, and what this rule
passes for it (an input, a derive, a define, an earlier table's output, or a literal). Two enums
are matched value by value with with; a value spelled the same on both sides needs no entry.
except leaves definitions of the applied rule out ("Article 20 (excluding paragraph 2)").
The applied rule's outputs become values of this rule, renamed with ->.
rulec check first checks the applied rule whole, on its own ground, then expands its tables
and clauses into this rule under names like retirement:schedule and checks the result as one
rule — so completeness and overlaps are proved on the rule as applied. Three things more are
checked: that no substitution is missing (E041), that the types agree (E042), and that what
this rule passes stays inside the applied rule's ranges (E043). Declare the period in office
from 0 and it stops at E043, years = 0 being outside the >=1 <=40 range of years in retirement_pay.rule:
the applied rule's completeness was proved over that range and no further, and whether to narrow
the range or to define the excess in a clause of this rule is a business decision.
When the applied rule is amended, the digest no longer matches and the check stops with E040.
rulec diff shows which inputs of this rule move — with past records, how many of them and by how much; once that is accepted,
rulec source pin writes the new digest. Rows of the applied rule's tables that this rule's
ranges never reach are not errors: the page for people lists them as unused by this apply, and
only a table none of whose rows is reached draws W118.
The generated code carries the applied rule expanded, and the trace says
{"table":"retirement:schedule","row":1,"label":"short"}. An apply goes one level, and a rule
that walks a sequence cannot be applied (E044).
Saying which combinations cannot happen
A relation between two inputs, guaranteed by the caller.
It computes nothing. It says which combinations of inputs can happen, and three things follow from the one line.
- The completeness check stops demanding rows for what cannot happen. A table covering everything reachable is complete.
- A witness becomes a case somebody could really send. Every input the checks construct satisfies the constraints.
- The generated code refuses a violating input at the door. The proof assumed the constraint, so the code has to insist on it.
The shape is constraint <input> <comparison> <input>, with one of <=,
<, >=, >, an input on each side, and both of a type that has an
order — money, a quantity, a rate, a number or a date. Several lines
hold at once, and A = B is the two lines A <= B and A >= B. An
example that breaks a constraint is an error (E019), not a case.
Taking a sequence whose length is not fixed
Every rule so far took a fixed number of values and decided once. When the
case carries a sequence instead — the rows of a tariff sheet, the
candidates a filter left — elements declares what one element carries
and fold declares how the walk ends.
elements fee_rows
row_zone : zone
threshold : money[JPY, incl_tax] range >=0JPY <=1_000_000JPY
row_fee : money[JPY, incl_tax] range >=0JPY <=100_000JPY
table row_of
policy unique
| row_zone | threshold | -> verdict : pick |
| kinki | <=1000JPY | take |
| …
fold verdict over fee_rows
skip -> next
halt -> stop with 0JPY
take -> take_unique row_fee
hold -> keep_max row_fee by threshold
empty -> 0JPY
exhausted -> held
What judges an element is an ordinary table. One element is one case,
so completeness, overlap and units are proved over it as they always were,
and the fields are declared exactly like inputs, ranges and units
included. The one difference is that the caller fills them in once per
element.
What fold adds is a single line per verdict: what the walk does next.
| arm | what it does |
|---|---|
next |
leave this element, look at the next |
stop |
end the walk; the answer is what exhausted says |
stop with <value> |
end the walk with this answer |
take_unique <value> |
take this element's value; a second element that also takes is a run-time error |
take_first <value> |
take the first, ignore any later one |
keep_max <value> by <key> |
hold this element's value, replacing what is held when the key is larger |
empty -> <value> |
the answer when there are no elements. Required |
exhausted -> <value> |
the answer when the walk reached the end. Required; held is the value being held |
Every verdict the table can produce needs an arm (E024): the table's own completeness check, applied to the fold. An arm for a verdict nothing can reach is W115.
None of this costs the checks their termination. The table is complete and unique, so every element lands on exactly one verdict, the sequence becomes a string of verdicts, and how the walk reads them is a matter of finitely many states — independent of how many elements arrive at run time.
An example names the sequence, because a cell holds one value.
sequence near
| row_zone | threshold | row_fee |
| kinki | 500JPY | 800JPY |
| kinki | 2000JPY | 1500JPY |
examples
| fee_rows | -> fee |
| near | 800JPY |
A sequence with no rows is the example for a sequence with nothing in
it. What comes out is the same function with one more argument — for
every target but SQL, where one query has no place to carry a value
from row to row and stop partway.
A rule that runs is in Examples, under "A sequence walked into one answer".
Counting instead: count
A fold turns a sequence into one answer. A count turns it into one
number and hands the rule back to the tables.
What where names is a column of one element — a field, or a column a
per-element table produces — whose values are a closed set. For an enum,
= <value> says which one to count; a bool column needs nothing after it
(where chilled).
From there the count is a number, so it can be a column.
| hits | auto_ok | -> action : action_kind |
| 0 | - | register |
| 1 | true | auto |
| 1 | false | review |
| >=2 | - | review |
That is why count exists beside fold: when an ordinary table turns the
number into a decision, the boundaries of that decision are checked — a gap
or an overlap between 0, 1 and >=2 stops the rule as any other would.
The range is required and says two things: the universe the completeness
check quantifies over, and the cap on the sequence. A longer sequence is
refused at the door by the generated code, for the reason a number outside its
range is — the proof was made over what was declared.
A count counts and a sum adds one column up — sum total over lines
of amount. An average does not follow: dividing by a count is dividing by a
variable, so it belongs before the call, as a value. A rule cannot hold both a
fold and a count or sum (E031): two endings for one walk, and a fold may
stop partway.
A rule that runs is in Examples, under "Counting a sequence, and deciding from the count".
A state carried from call to call: machine
Some rules are one step of something that goes on: an order is paid,
shipped and delivered, or cancelled. The state lives with the caller — in
the order's row of a database — and every call is passed the state and
answers the next one. The table is written as always, with the state as a
column and the next state as an output; state in an output cell hands the
state back as it was.
table step
policy unique
| state | event | -> next_state | refund | accepted |
| received | pay | paid | 0JPY | true |
| received | cancel | cancelled | 0JPY | true |
| paid | cancel | cancelled | amount_paid | true |
| …
| cancelled | - | state | 0JPY | false |
machine order over step
carry state -> next_state
held amount_paid
initial received
final delivered, cancelled
never shipped after cancelled
once refund >0JPY
carry is the one line with new meaning: the output a call answers is
passed back as that input on the next call. The generated function does
not change — it keeps nothing, and the caller keeps the state. What the
section adds is claims about every sequence of calls, and check
proves them:
| line | the claim | broken |
|---|---|---|
final |
no call moves a case out of these states | E124 |
| (always) | from every state a case can reach, one of the final states can still be reached |
E125 |
never shipped after cancelled |
no sequence of calls reaches shipped once the case has been cancelled |
E126 |
once refund >0JPY |
in one case, at most one call answers a refund | E127 |
held amount_paid says that one case passes the same paid amount on every call,
so the claims are about the sequences that keep it, and a sequence that
changes it halfway is never offered as a counterexample.
A claim that fails comes back with the shortest sequence of calls that breaks it, each call an input the rule takes. The rest is the table's own checks: a state and an event with no row is E101 as it always was, so what happens when a cancel request arrives after shipment is asked before anybody runs the code.
A scenario is an example that runs for several calls. It has no column
for the carried input: the first call starts in initial, and each later
one where the call before it ended.
scenario late_pay
| event | amount_paid | -> next_state | refund | accepted |
| pay | 3000JPY | paid | 0JPY | true |
| cancel | 3000JPY | cancelled | 3000JPY | true |
| pay | 3000JPY | cancelled | 0JPY | false |
Beside the function, each language gets the initial state and a test for a
final one. The vector suite gets sequences of calls, and rulec test has
every language play them, handing the state it answered to its own next
call. Between two versions, rulec diff gives the shortest sequence of
calls the two answer differently, and the states from which a case in
progress could no longer finish.
The claims stay decidable because the state is a finite enum and nothing else is carried. A total so far — refunds paid, a balance — is kept by the caller and passed in, as it always was.
A rule that runs is in Examples, under "One event in an order that goes on".
Examples
examples
| dest | weight | total | member | -> fee |
| Okinawa | 2500g | 40_000JPY | basic | 0JPY |
| Tokyo | 1999g | 12_000JPY | platinum | 400JPY |
examples is an executable specification. rulec check runs every
row through the reference evaluator and reports a failure with the
rows that fired.
Every output must have a column. Dropping one is an error, and the reason is worth knowing: the agreement of the reference evaluator, the generated Python and the generated Go stays green when all three share the same mistake. That happened — rounding for multiple outputs was missing in all of them at once, and the agreement check was green to the end. The only thing that can break it is an expectation a person wrote.
What cannot be written
- Nested objects (
order.destination.state) — flatten at the boundary and pass the scalar in. - Iteration anywhere you like, and recursion — a sequence is walked once,
by
fold(the section above); every other repetition, a stack of coupons applied in order among them, stays with the caller. - An average over the elements — it divides by how many there are, which is
dividing by a variable. Compute it before the call and pass it in (the
count and the total are written with
countandsum). - A number carried from one call to the next — a balance, how many times
so far. The state a
machinecarries is an enum; a running total is kept by the caller and passed in. - Date arithmetic — comparison and range only.
Allowing these would stop the completeness and overlap checks from terminating. What cannot be written is the price of the checks finishing.
The complete grammar, down to the lexical rules and the reserved words, is in Grammar.