Coming from Catala
If you have written Catala, most of Arxo will look familiar: the text of the law sits next to the code, a rule has a base case and exceptions, and a scenario states the expected answer. This page takes one small problem — the Northbridge parking permit — writes it in both languages, runs the same four inputs through Arxo, and maps the vocabulary. The Arxo package on this page is compiled and its scenarios are run by the checks that publish the site; the Catala text is written in the forms of the Catala tutorial and has not been run here.
Open this package in the playground → — edit the rules and the scenarios of this page and run them in your browser.
The problem
Section titled “The problem”A permit is issued to a resident with a registered vehicle; an applicant with outstanding fines is refused, whatever else holds.
In Catala
Section titled “In Catala”declaration scope PermitEligibility: input resident content boolean input vehicle_registered content boolean input outstanding_fines content boolean output eligible content boolean
scope PermitEligibility: label base definition eligible equals resident and vehicle_registered
exception base definition eligible under condition outstanding_fines consequence equals falseA scope declares its inputs and outputs; the base definition gives the general rule, and an exception labelled against it takes precedence when its condition holds. A test in Catala is another scope that calls this one with fixed inputs:
declaration scope TestAnn: output result content boolean
scope TestAnn: definition result equals (output of PermitEligibility with { -- resident: true -- vehicle_registered: true -- outstanding_fines: false }).eligibleIn Arxo
Section titled “In Arxo”language "law.core" version "0.2";package demo.parking version "0.1.0";namespace "urn:law:demo:parking";
entity Applicant;
relation resident(a: Applicant) kind institutional;relation vehicle_registered(a: Applicant) kind institutional;relation outstanding_fines(a: Applicant) kind institutional;relation permit_eligible(a: Applicant) kind institutional;
rule PermitEligibility defeasible { for a: Applicant; when resident(a) and vehicle_registered(a); then permit_eligible(a);}
rule FinesRefusal defeasible { for a: Applicant; when outstanding_fines(a); then not permit_eligible(a);}
priority RefusalOverEligibility { prefer FinesRefusal over PermitEligibility; reason lex_specialis;}The general rule and the refusal are two rules of equal standing; the
priority declaration is what makes the refusal win, and it names its
ground. The inputs are not booleans on a scope but facts about a named
applicant, supplied by the case.
Vocabulary
Section titled “Vocabulary”| Catala | Arxo | Note |
|---|---|---|
declaration scope S with input / output |
package with relation declarations |
a relation is both an input (when asserted) and an output (when asked) |
definition x equals … under a condition |
rule … { when …; then …; } |
a rule derives support for a statement |
label base definition + exception base |
rule … defeasible + a refusing rule and priority, or an unless clause |
an exception that only withdraws the conclusion is unless; one that asserts the opposite is a rule with a negated head and a priority |
output of S with { … } in a test scope |
a test block in a .lawtest file: given, evaluate, expect |
the scenario names facts by id and origin |
| a boolean value | a four-valued answer: TRUE_ONLY, FALSE_ONLY, NEITHER, BOTH |
absence of a fact is NEITHER, not false |
| the law text in the same literate file | source / edition / fragment with content_hash, and @source(…) on the rule |
the quoted passage is checked against the pinned document (see Sources and legal time) |
Four inputs, side by side
Section titled “Four inputs, side by side”The same four cases, with the answer the Catala scope above gives and the answer Arxo gives (the Arxo answers are the scenarios below, run on this page).
| Input | Catala eligible |
Arxo permit_eligible |
|---|---|---|
| resident, vehicle registered, no fines | true |
TRUE_ONLY |
| resident, vehicle registered, outstanding fines | false |
FALSE_ONLY |
| resident; nothing known about the vehicle | the caller must pass vehicle_registered: false, and the scope returns false |
NEITHER — not established, not refused |
| resident, vehicle registered, and two records disagreeing about residence | the scope takes one value per input, so this case is not posed to it | BOTH — the contradiction is kept in the answer |
test "resident with a vehicle, no fines" { given { context { legal_time @2026-03-01; decision_time @2026-03-01T09:00:00Z; knowledge_time @2026-03-01T09:00:00Z; timezone "UTC"; } assert resident(entity_ref("urn:demo:parking:ann")) { id "ann-resident"; origin case_input; } assert vehicle_registered(entity_ref("urn:demo:parking:ann")) { id "ann-vehicle"; origin case_input; } } evaluate truth(permit_eligible(entity_ref("urn:demo:parking:ann"))); expect truth_status == TRUE_ONLY;}
test "resident with a vehicle and outstanding fines" { given { context { legal_time @2026-03-01; decision_time @2026-03-01T09:00:00Z; knowledge_time @2026-03-01T09:00:00Z; timezone "UTC"; } assert resident(entity_ref("urn:demo:parking:ann")) { id "ann-resident"; origin case_input; } assert vehicle_registered(entity_ref("urn:demo:parking:ann")) { id "ann-vehicle"; origin case_input; } assert outstanding_fines(entity_ref("urn:demo:parking:ann")) { id "ann-fines"; origin case_input; } } evaluate truth(permit_eligible(entity_ref("urn:demo:parking:ann"))); expect truth_status == FALSE_ONLY;}
test "resident; the vehicle is unknown" { given { context { legal_time @2026-03-01; decision_time @2026-03-01T09:00:00Z; knowledge_time @2026-03-01T09:00:00Z; timezone "UTC"; } assert resident(entity_ref("urn:demo:parking:ann")) { id "ann-resident"; origin case_input; } } evaluate truth(permit_eligible(entity_ref("urn:demo:parking:ann"))); expect truth_status == NEITHER; expect not applied(PermitEligibility);}
test "two records disagree about residence" { given { context { legal_time @2026-03-01; decision_time @2026-03-01T09:00:00Z; knowledge_time @2026-03-01T09:00:00Z; timezone "UTC"; } assert resident(entity_ref("urn:demo:parking:ann")) { id "ann-resident"; origin case_input; } assert not resident(entity_ref("urn:demo:parking:ann")) { id "ann-not-resident"; origin case_input; } assert vehicle_registered(entity_ref("urn:demo:parking:ann")) { id "ann-vehicle"; origin case_input; } } evaluate truth(resident(entity_ref("urn:demo:parking:ann"))); expect truth_status == BOTH;}Three behaviours differ, and each is a design choice rather than a
missing feature on either side. On a missing fact Arxo answers NEITHER
and can say which premise is missing; Catala’s caller decides what value
to pass. On competing grounds Arxo requires a declared priority and
otherwise keeps BOTH; Catala orders exceptions by their labels and
checks the tree statically. On contradictory input Arxo keeps both
supports in the answer.
The full cycle
Section titled “The full cycle”1. Write the package — parking.law, the four law blocks above
without the tests.
2. Check it:
law engine check parking.lawcheck OK: parking.law3. Ask a question. A question is a scenario: the third test block
above, in its own file, asks whether Ann is eligible when only her
residence is known, and expects NEITHER. The runner evaluates it:
law engine test tests/vehicle-unknown.lawtest --program parking.lawtest PASS: resident; the vehicle is unknown4. Keep the scenarios — tests/catala.lawtest, the four test
blocks with the three header lines:
law engine test tests/catala.lawtest --program parking.lawtest PASS: resident with a vehicle, no finestest PASS: resident with a vehicle and outstanding finestest PASS: resident; the vehicle is unknowntest PASS: two records disagree about residence5. Call it from an application. Lower the package once to its
executable form, then open it with @arxo/law and ask the same
questions; the answer carries the status, the proof graph and the hashes
of the program and the result. whyNot names the rule that did not
fire; for a strict rule it also lists the premise that stopped it (see
Missing and conflicting facts).
law engine lower parking.law > parking.lawir.jsonimport { readFileSync } from 'node:fs';import { open } from '@arxo/law';
const ir = JSON.parse(readFileSync('parking.lawir.json', 'utf8'));const parking = await open('demo.parking@0.1.0', { offline: true, local: { packages: { 'demo.parking@0.1.0': { ir } } },});
const ann = { legalTime: '2026-03-01', timezone: 'UTC', answers: [{ predicate: 'resident', args: ['ann'] }] };
const r = await parking.truth('permit_eligible', ['ann'], ann);console.log(r.evaluationStatus, r.truthStatus);
const why = await parking.whyNot('permit_eligible', ['ann'], ann);console.log(why.value.value.blockers.map((b) => [b.rule, b.trigger]));COMPUTED NEITHER[ [ 'urn:law:demo:parking#PermitEligibility', 'UNDETERMINED' ] ]Reproducible comparison
Section titled “Reproducible comparison”The numbers you may have seen — 65 of 65 cases and 240 of 240 random inputs agreeing between a Catala model and an Arxo package — come from one experiment on one act, a Kazakh regulation on vehicle damage assessment, with both models written independently from the same source text. The code, the cases and the comparison script are at github.com/arxohq/arxo-catala-parity; the write-up is Law as function, law as data.
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.