Citation Resolution
Lex has two halves. The corpus is data-driven and structured: instruments
declared in corpus_config.py, ingested through fetch → parse → chunk → embed,
landing in lex_regulations → lex_articles → lex_chunks with identifiers,
provenance and content hashes. The rules are authored, and their legal basis
is a string:
regulatory_basis="GwG s. 43 (Meldepflicht — SAR obligation to FIU)"
When a rule fires, that string is copied onto the finding and travels into the case record, the decision memorandum, the regulator case-pack and the officer UI.
The seam between the halves is where this page lives. There is no foreign key
from a rule or a finding to lex_articles. The linkage is derived by parsing the
string — and until ADR-0119 it was derived three times independently, by
RegPill.tsx, LexQueryService.get_article() and citation_verifier.py.
The architecture at a glance
Three authored surfaces emit a citation string; one resolver interprets it against the ingested corpus; three gates keep the binding honest — one free on every PR, one an LLM judge run when a human decides, one scheduled for drift.
The rest of this page walks the resolver (the 5 states), the referential gate, and the semantic layer (judge → unanimity → drift) in turn.
The problem this solved
The citation was a convention, not a constraint. Rules write GwG; the
corpus stores short_name='DE-GwG'. That mapping existed only implicitly,
spread across three parsers. Nothing at build time, load time or ingest time
asserted that a cited article existed.
Measured on 2026-07-22 across the 63 distinct citations in the registry:
| Outcome | At the audit | After the #484 waves |
|---|---|---|
| Resolved to a real article | 41 (65%) | 53 (84%) |
| Instrument-level citation (valid) | — | 3 |
| Non-statutory basis (valid) | — | 2 |
| Article missing / instrument not ingested | 13 | 1 |
| Grammar could not parse | 9 | 4 |
All 63 rendered identically before the resolver existed. That is how 15 mis-citations survived to a hand
audit, how French CMF citations pointed at a law never ingested, and how
GwG s. 43 — the SAR-reporting duty — resolved to a bare heading.
That first pass covered the rule registry only. The audit has since been
extended to the two other surfaces that reach a regulator — the document
builders and the finding code (the latter parsed via the AST, so multiline,
concatenated, f-string, dict and variable-assigned bases are all seen). Across
all three, as of 2026-07-23: 140 citations judge-verified, 25 declared (≈19
references to instruments outside the AML corpus — CRR, EBA guidelines, Basel,
national sectoral acts — and ~6 finding citations naming the closest AMLR article
for matters AMLR does not precisely govern, each disclosed with the judge's
reasoning). Extending the finding surface alone surfaced 30 findings citing
AMLR Art. 28 — the RTS-delegation article, the canonical wrong example below —
as if it were the CDD duty.
The resolver
app/services/lex/citation_resolver.py is the single interpreter of the
grammar and of the instrument-prefix mapping. Writing that mapping down is the
substance of the fix.
It returns five states, never a boolean, because the failure kinds have different owners:
| Status | Meaning | Owner |
|---|---|---|
resolved | grounded to a real lex_articles row | — |
instrument_only | a valid whole-Act citation (Belgian AML Law) | — |
non_statutory | a methodology basis (Sector risk assessment) — never rendered as law | — |
not_in_corpus | well-formed, but the law is not ingested | corpus waves (#484) |
unparseable | the citation itself cannot be understood | rule authoring |
Collapsing not_in_corpus and unparseable into one falsy value would merge
"we have not ingested this law" with "this citation is malformed" — different
facts, different owners, different fixes.
Grammar notes
Two details are load-bearing and non-obvious:
- French article numbers contain hyphens (
L561-10-2) while everywhere else a hyphen is a range (Art. 42-44). One greedy pattern cannot tell them apart; getting it wrong produced the article number"42-44", which matches no row, so a perfectly correct citation reported as missing from the corpus. - Section qualifiers (
§4(b),(2)(a)) are captured separately. Resolution keys on the article; the qualifier is carried for display and future paragraph-level linking.
et seq. and ranges anchor on the named article and record the span rather than
failing — the span is real legal information and discarding it loses the
citation.
The standing gate
backend/tests/test_lex_citation_contract.py converts what used to be a manual
audit into an invariant, in two tiers:
- Grammar tier — no database, runs on every PR. Catches the common regression: a rule authored with an instrument prefix nobody mapped.
- Corpus tier — integration-marked. Article existence against the live corpus.
Every citation must classify, or be declared in one of two registers with a reason and an owning issue:
KNOWN_UNGRAMMATICAL— the citation cannot be understood (owner: authoring)KNOWN_CORPUS_GAPS— well-formed but the law is not ingested (owner: #484)
The registers are checked in both directions. An undeclared gap fails, and a declared entry that has since started resolving also fails. A hand-maintained safe-list that is never re-checked becomes a place where fixed problems go to look permanent; this is what prevents that.
The same file pins the Regulatory Radar in both directions — every ingested
instrument must be in RADAR_TO_LEX_NAME and every radar target must exist in
the corpus. A one-way check would let a dangling target look healthy while an
ingested instrument silently dropped out of coverage.
Fail-closed rendering
RegPill takes a verifiable prop. When it is false the pill renders in the
amber "not assessed" band with an unverified marker, is not interactive,
and its tooltip and accessible name read:
legal basis stated; article text not available for verification
Checked before any interactive affordance — offering "ask the assistant to explain this" for an article we do not hold sends the officer to a dead end and implies a grounding that does not exist. Undefined is treated as grounded, so the behaviour is opt-in rather than a silent downgrade of every existing pill.
Note the wording: the basis is stated. What is missing is the grounding. The pill must never read as "no legal basis".
Known limitation: duplicate article numbers
There is no uniqueness constraint on (regulation_id, article_number). The
backlog stood at 64 duplicate groups (NO-AML 54, RO-AML 6, CSRD 3, eIDAS 2 1)
and is now 4 — the PDF-parser rebuild re-ingested the national corpora
cleanly.
The 4 that remain are CSRD (3) and eIDAS 2 (1), both amending instruments whose Article 1 quotes the amended act inline — CSRD Art. 1 is 73,937 characters of Directive 2013/34/EU — so the parser splits inside the quotation. Teaching the EU CSS path to recognise quoted blocks would risk 30 working corpora to correct 4 rows in instruments no AML rule cites, so it is deliberately deferred.
They cannot simply be deduplicated: the groups hold distinct text, so collapsing them would delete law, and keeping "an arbitrary one" is the very bug being fixed, moved from read time to write time.
Two things hold the line meanwhile:
get_article()orders bysort_order, id— the lookup is deterministic, returning the first occurrence in document order rather than an arbitrary row.- The ADR-0118 ingest gate's C3 check rejects duplicate article numbers, so no new corpus can introduce them. The set is closed, not growing, and a ratchet test asserts it never exceeds 4.
The database-level unique index lands with the quoted-block fix above — it cannot be created while any duplicate remains.
Does the article say what the rule claims?
Everything above makes citations referentially sound: a rule can no longer name an instrument nobody mapped, an article that does not exist, or render as grounded when it is not. It does not make them correct, and the proof is direct — every one of the 15 mis-citations corrected on 2026-07-21 passes the gate above cleanly, because they all name real articles:
| Wrong citation | Resolves? | Why it was wrong |
|---|---|---|
AMLR Art. 28 for CDD | yes | Art. 28 is the RTS-delegation article, not the duty |
AMLR Art. 35-37 for PEP | yes | Those are third-country countermeasures; PEP is Art. 42-44 |
Czech AML Act s. 10 for PEP | yes | s. 10 is "Identification by an Intermediary" |
Existence and correctness are different properties, and the second is the one an
officer, an auditor and a regulator rely on. So a second layer retrieves the
cited article's actual text and judges it against the rule's stated purpose,
returning SUPPORTS / CONTRADICTS / UNSUPPORTED / INDETERMINATE.
INDETERMINATE blocks: we could not check must never read as checked and
fine.
The judge is asymmetric by design — a verdict that removes scrutiny
(SUPPORTS, taking a citation off the reviewed backlog) must survive a unanimous
re-vote; every other verdict adds scrutiny and is trusted on the first call.
temperature=0 is not determinism (measured: one citation returned SUPPORTS
then UNSUPPORTED on a byte-identical article), so a single accepting vote is a
sample, not a fact.
The accepted verdict records the article's content_hash, so re-ingesting the
corpus automatically re-opens every citation resting on changed text — the
scheduled drift sweep is that re-check.
Legal basis, not verbatim overlap
The first prompt was purely adversarial — "refute the citation, default to
UNSUPPORTED when unsure". Run against all 92 real citations it returned 7
supports and 62 unsupported, rejecting AMLR Art. 34 for an enhanced-due-
diligence rule because Art. 34 does not enumerate that specific risk factor —
when Art. 34 is the EDD duty.
That is the false-alarm rate that gets a control switched off. The error was
conflating two questions: "is this the right legal basis for this obligation?"
with "does this article state this fact verbatim?". A compliance rule applies a
general duty to a specific risk factor — the rule supplies the risk
assessment, the article supplies the duty — so citing the article that creates
the duty is correct even when the rule's trigger is not named in it. v2 asks the
first question and keeps hunting the real defect: an article on a different
subject. The prompt id moved v1 → v2, so no stored verdict can misattribute
which prompt produced it.
The register is a lockfile
A free, deterministic test asserts every registry citation appears in
citation_baseline.json with the same text; the LLM runs only when a human is
deciding something. A control that needs an API key and real spend on every
commit is one that gets disabled.
Current state: 69 accepted, 23 declared, 92 total. The 28 genuine findings
from the first run were re-authored against each article's real text — every
replacement carries a verbatim quote as evidence — leaving one: the German
sanctions rule, where the EU/OFAC freeze duty flows from Regulations 2580/2001
and 269/2014, which are not ingested. AWG s. 5a is self-executing but UN-only,
so the shortfall is declared rather than overstated. The remaining 22 do not
resolve at all and were already known from ADR-0119. Each is recorded in a
ratcheted exceptions block with the judge's reasoning. Blocking all work until every flagged citation is
re-authored would get the control switched off; hiding the rejections would make
the audit a lie. A declared backlog that may shrink but never grow is the honest
middle.
The scheduled sweep
A citation can rot without anyone touching it — the corpus is re-ingested, a law
is amended and a paragraph renumbered under a citation that still resolves, the
judge's prompt is revised. None of that appears in a diff, so none of it can fail
the per-PR gate. The system citation-drift-sweep Temporal Schedule
(dark-launched, CITATION_DRIFT_SWEEP_ENABLED) is that tier.
Two properties are load-bearing:
It never writes the baseline. A scheduled job that refreshed its own reviewed
record would auto-bless the exact drift it exists to catch. It reports; a human
runs scripts/audit_citations.py --all and commits.
Staleness is decided before any LLM runs. A recorded verdict is stale iff
the article's content_hash moved or the recorded prompt_id differs from
the current one. Only that subset reaches the judge — currently zero, which
is what makes a nightly control affordable rather than theoretical.
That is a cost decision, not a correctness one, and the difference was
measured. temperature=0 is not determinism: cz_bank_young_company was
judged SUPPORTS and recorded, then returned UNSUPPORTED against a
byte-identical article under the same prompt id. Greedy sampling still rides a
serving stack that is not bit-reproducible.
The response is asymmetric, because the two error directions are not equivalent
under the never-suppress doctrine. A spurious rejection puts a citation in front
of a reviewer and suppresses nothing, so it stands on one vote. A spurious
SUPPORTS marks a wrong legal basis as verified and takes it off the
backlog, so it must survive 2 of 3 votes; a failed majority returns the dissent
labelled UNSTABLE — n/3, because "the judge disagreed with itself" is a
different finding from "this article is off subject". The confirmation caught two
previously-accepted citations on its first run.
A re-judged citation that still returns SUPPORTS is still reported: the stored
verdict refers to text that no longer exists, so the record is stale even though
the citation is right.
This is the failure only this tier can catch: nl_psp_pep_match cites Wwft
Art. 8(5)(a)-(c) for PEP obligations, and that paragraph now governs
crypto-asset services. The law was amended under a citation that still
resolves perfectly.
GET /monitoring/citation-drift (super_admin) reports the flag, the schedule's
actual presence, the baseline's real contents and how many verdicts sit under an
older prompt. A disabled flag, a missing schedule or an unreadable baseline is
healthy: false with a status_note — never silent-green.