Skip to content

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.

A permit is issued to a resident with a registered vehicle; an applicant with outstanding fines is refused, whatever else holds.

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 false

A 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
}).eligible
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.

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)

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.

1. Write the package — parking.law, the four law blocks above without the tests.

2. Check it:

Terminal window
law engine check parking.law
check OK: parking.law

3. 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:

Terminal window
law engine test tests/vehicle-unknown.lawtest --program parking.law
test PASS: resident; the vehicle is unknown

4. Keep the scenarios — tests/catala.lawtest, the four test blocks with the three header lines:

Terminal window
law engine test tests/catala.lawtest --program parking.law
test PASS: resident with a vehicle, no fines
test PASS: resident with a vehicle and outstanding fines
test PASS: resident; the vehicle is unknown
test PASS: two records disagree about residence

5. 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).

Terminal window
law engine lower parking.law > parking.lawir.json
import { 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' ] ]

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.