Skip to main content

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_regulationslex_articleslex_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:

OutcomeAt the auditAfter the #484 waves
Resolved to a real article41 (65%)53 (84%)
Instrument-level citation (valid)3
Non-statutory basis (valid)2
Article missing / instrument not ingested131
Grammar could not parse94

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:

StatusMeaningOwner
resolvedgrounded to a real lex_articles row
instrument_onlya valid whole-Act citation (Belgian AML Law)
non_statutorya methodology basis (Sector risk assessment) — never rendered as law
not_in_corpuswell-formed, but the law is not ingestedcorpus waves (#484)
unparseablethe citation itself cannot be understoodrule 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:

  1. get_article() orders by sort_order, id — the lookup is deterministic, returning the first occurrence in document order rather than an arbitrary row.
  2. 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 citationResolves?Why it was wrong
AMLR Art. 28 for CDDyesArt. 28 is the RTS-delegation article, not the duty
AMLR Art. 35-37 for PEPyesThose are third-country countermeasures; PEP is Art. 42-44
Czech AML Act s. 10 for PEPyess. 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.

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 v1v2, 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.

See also