Skip to main content

ADR-0162: Corpus completeness is a named determination, not a coverage threshold

Date: 2026-08-01 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus (analysis + implementation) Issue: #911 (blocks #909) Extends: ADR-0118 (corpus integrity gate) — the gate stays; its completeness specification is replaced where the source permits. Touches: ADR-0119 (structured citation binding), ADR-0121 (semantic citation verification), ADR-0067 (fail-closed / never report clear when no check ran).

Context

The instance

CZ-CNB-Decree — Czech National Bank Decree 67/2018 Coll., the decree setting the internal AML/CFT control requirements for supervised institutions — shipped in the live Lex corpus holding 21 of its 25 sections. §§ 14, 17a, 17b and 23 were absent. They are in force, not repealed. Verified against the live database on 2026-08-01:

stored: 1,2,3,4,5,6,7,8,9,10,11,12,13,15,16,17,18,19,20,21,22 (21 articles, 30,859 chars)

EsbirkaFetcher grouped e-Sbírka's leaf fragments into sections using the fragment's citation label (§ 5 odst. 3 písm. b)), and made that label mandatory in the SPARQL join. The label is optional in the source. A section's rubric, its opening chapeau, and — for a section with no numbered paragraphs — its entire body live in child fragments that carry no label. For those four sections the only labelled fragment was the section's own heading; the label-strip then emptied the body, and an empty body was silently continued.

The same defect ran quieter through fourteen further sections, which shipped incomplete. Stored § 1 began mid-sentence at "a) postupů…", because its rubric ("Předmět úpravy"), its chapeau ("Tato vyhláška upravuje požadavky na zavedení a uplatňování") and its closing clause are all unlabelled fragments. A semantic citation judge (ADR-0121) reading that provision was reading a truncated one.

The class — and it is the more important half

backend/app/services/lex/integrity.py C4 required MIN_SEQUENCE_COVERAGE = 0.85. CZ-CNB-Decree sat above it. The gate behaved exactly as specified; the specification permits up to 15% of a law to be absent while the corpus reports itself as ingested.

That is a coverage percentage standing in for a completeness finding — the same shape as a partial screen reading as clean, and the same shape as ADR-0158's empty ownership graph reading as a proven-clean one. It is feedback_claim_vs_check applied to the control built to kill that very class (#484): nothing anywhere distinguished "we hold this law" from "we hold 91% of this law", and no artefact recorded which 9% was gone.

The failure direction is not a false clear — a rule citing CNB Decree § 14 resolved not_in_corpus, which is honest. The defect is that the corpus overstated its own completeness, and every downstream honesty claim (ADR-0119 resolution states, ADR-0121 semantic verdicts) rests on it.

Why the parser could not have caught this

ParsedRegulation.markers_seen_in_source scans the HTML the parser receives. For every fetcher that synthesises HTML — esbirka, gii_xml, bwb_xml, legi_parquet — that HTML is the fetcher's own output. A section dropped at fetch time is therefore invisible to every parser-side check by construction: the scan can only ever see markers the fetcher chose to emit.

Measured on 2026-08-01 against the live source (an earlier draft of this ADR said the scan "returns 1"; that was wrong and is corrected here — the structural argument stands, the number did not). On the repaired HTML the scan returns 23, the highest section label present. On the shipped HTML — the same fetch with §§ 14/17a/17b/23 dropped — it returns 22, which is also the highest article the parse captured. C4's tail check therefore computes a shortfall of exactly 0: the ceiling moved down in lockstep with the loss, so the truncation is not merely undetected but undetectable from the parser's side. The article inventory has to be declared by the stage that sees the real source.

Decision

1. Where the source can be enumerated, C6 replaces the percentage

A fetcher that can list its source's own article inventory declares it as FetchedRegulation.declared_article_numbers. The gate then runs C6:

every declared article must be present in the parse; each absentee is named; the bar is 100%.

MIN_SEQUENCE_COVERAGE is not consulted when C6 applies. This is not merely a stronger check, it is a different kind of check, and it is better in both directions:

  • It catches what a threshold cannot: one missing section out of 25 (the shipped defect sat at ~95% by integer-span, far above the line).
  • It does not fire where a threshold wrongly would: an instrument numbered 1-5 and 50 is 12% "complete" by span and entirely whole in fact. A gate that fires wrongly is a gate that gets switched off.

MIN_SEQUENCE_COVERAGE stays at 0.85, unchanged, and a test pins that value. Relaxing it was the tempting fix and it is the wrong one: it converts a detection into a permission. It remains the fallback for every corpus whose source cannot be enumerated — but a corpus guarded only by it is never labelled complete.

2. The gap is recorded, never silently accepted

  • Sequence gaps are computed unconditionally. They were previously computed only inside the violation branch, so a corpus passing at 91% carried no record of what it lacked — the gate's silence and a genuinely whole corpus were indistinguishable.

  • Every report carries a coverage_state, persisted on the corpus row (Alembic 104):

    statemeaning
    completesource inventory enumerated and every declared article captured. The only state that supports "we hold this law".
    partialinventory enumerated, articles are missing.
    unverifiedthe source cannot be enumerated; completeness is not determinable.

    unverified is the default and the honest state for the 45 corpora ingested before any inventory existed. It is not a downgrade — their completeness was never determined. A passing integrity gate means nothing detectable is wrong, which is not the same claim.

  • coverage_detail (JSONB) carries the named gap: declared-but-absent articles, unused integer numbers, sequence coverage, and the gate's notes. So a citation to a missing section can be answered "known gap in our copy" rather than the ambiguous ADR-0119 not_in_corpus.

2b. A refusal records its own evidence (added in review, PR #958)

The gate raises before the upsert — deliberately, so nothing bad is stored. The consequence had not been followed through: the structured determination (state="partial", the named declared_missing) was computed, formatted into an exception message and then discarded.

It was not the case that a refusal left no record — measured on the pre-fix code, the existing except Exception handler already wrote a lex_ingestion_log row whose error_message reads C6 the source declares 7 articles but 4 were not captured: 14, 17a, 17b, 23. What was lost was the machine-readable determination, which is the thing this ADR introduced a contract for.

The fix records the attempt, not the corpus. lex_regulations.coverage_state describes the articles that are stored; a refused run replaces none of them, so stamping partial there would mislabel a corpus that may be perfectly whole, and on a first ingestion it would have to create a zero-article regulation row — the SK-AML shape the gate exists to prevent. So:

  • CorpusIntegrityError carries its IntegrityReport.
  • lex_ingestion_log gains coverage_detail (same Alembic 104) and a status="rejected" row is written before the exception is re-raised.
  • Every attempt records its determination, accepted or refused, so an absent coverage_detail always means not written — never written, and nothing to report.
  • A refusal for a different reason still records the honest coverage finding: a C1 body failure does not license inventing a coverage gap. Pinned by test.

3. The fetcher stops deciding that a section does not exist

EsbirkaFetcher resolves a fragment's section by hierarchy ancestry: the nearest ancestor (by path prefix) carrying a § N label, or itself.

Ancestry is chosen deliberately over "nearest preceding labelled fragment in document order". The latter is precisely the #484 failure shape — an unlabelled block silently merging into its predecessor. Under ancestry, the unlabelled chapter heading at /2/6/ is a sibling of the sections, has no § ancestor, and is attached to nothing; under document order it would be swallowed by § 1. The fix cannot re-introduce the defect the gate exists to catch. Pinned by test.

A section the source declares but for which no fragment carries text is still declared (and logged loudly). The fetcher no longer drops it; C6 refuses the whole ingestion and names it.

4. The SPARQL query is under test, not around it (added in review, PR #958)

The first version of the end-to-end test drove the real fetcher through httpx.MockTransport(lambda request: canned_response) — a transport that never read the request. Restoring the pre-#911 citation-mandatory join to the production _QUERY left that suite 17/17 green (measured), so the recovery of §§ 14/17a/17b/23 was attributed to a component nothing had exercised. A correct guard fed a synthetic input is inert while the behavioural test still passes.

The HTTP boundary is still the only thing stubbed — that is the prescribed pattern here, and the approval is now recorded in the test file per the methodology's mock-approval rule. What changed is that the stub interprets the query it is given, on the one semantic axis the defect lived on: a triple pattern outside an OPTIONAL block is an inner join, so a fragment lacking that property is not in the result set at all. It also honours the SELECT projection and the subject IRI, and answers 400 to anything it cannot parse rather than answering generously. It is not a SPARQL engine, and says so.

Three consequences, each a standing test rather than a one-off manual mutation:

  • restoring the pre-#911 query makes the four sections vanish again, and C6 refuses the result by name;
  • the same seam with the current query has no C6 complaint (the control);
  • a marker-gated live contract test (REAL_ESBIRKA=1) asserts the stub's own premise — that e-Sbírka still publishes unlabelled text fragments. If that stops being true, the fixture has expired and this is what says so.

5. Three ways the new control could still certify a lie (round 2, PR #958)

Each of these is the same shape as the defect this ADR exists to fix, found inside the fix.

(a) The migration was never exercised. test_lex_rejected_coverage_record_911 built its schema with Base.metadata.create_all, so coverage_state and coverage_detail existed because the ORM models declare them — which says nothing about whether Alembic 104 creates them, or is reachable from head at all. The fixture even hand-created the migration-only uq_lex_reg_celex_lang index, routing around the mismatch it should have surfaced. Measured: with 104 mutated to omit coverage_state, that module was 6/6 green. The fixture now runs alembic upgrade head against a throwaway pgvector container (the migrated-schema oracle used by test_migration_092_backfill_sla_due_at); under the same mutation it fails 4 tests, including the production INSERT … coverage_state with column "coverage_state" ... does not exist — the deployment failure, caught in CI.

head rather than the revision id on purpose: half the question is whether 104 is on the production chain, and targeting it by name would pass even if it were orphaned.

(b) A multi-letter section suffix collapsed two provisions into one. _SECTION_RE captured \d+[a-z]?, one letter. Czech insertion numbering does not stop there — verified live on 2026-08-01, Act 235/2004 Coll. (VAT) at 2024-01-01 publishes §§ 110zz, 110zza, 110zzb … 110zzj, eleven distinct in-force sections that all captured as 110z. The inventory and the emitted HTML are built from the same regex, so C6 would have compared a collapsed inventory against a collapsed parse, found perfect agreement, and stamped complete while ten sections were absent — #911 reproduced inside the control built to detect it. Widened to \d+[a-z]*, matching the parser's _ARTICLE_NUM, which had already been widened for this collision ("third instance of this class", says its comment). Pinned by tests over the real live labels, plus a REAL_ESBIRKA=1 contract test asserting the source still numbers this way.

(c) An empty inventory certified complete. An enumerating fetcher returning () — how an upstream vocabulary change presents — satisfied "every declared article was captured" vacuously: nothing declared, nothing missing, complete. And because C6 is meant to have decided exactly, C4 stood down too. The one input meaning "the fetcher's view of the source collapsed" switched off both halves of the gate and returned the only state that supports the claim "we hold this law". New C6a treats a present-but-empty inventory as absent-and-broken: unverified, a named violation, and C4 re-armed as the fallback it is. EsbirkaFetcher raises before it can return this, so today the branch is unreachable in production — the gate does not get to assume the next enumerating fetcher will be as careful.

Consequences

Proven live. CZ-CNB-Decree re-ingested: 21 → 25 articles, 30,859 → 32,724 chars, coverage_state = complete, declared 25 / missing []. All four sections carry their provision; § 1 carries its chapeau again.

The gate protects the corpus, verified on the live path. A mutated fetcher that drops § 14 while still declaring it was refused — C6 the source declares 25 articles but 1 were not captured: 14 — and the stored 25-article corpus survived intact. The pre-change gate would have accepted that same 24/25 corpus.

#909 unblocked. Act 523/1992 Coll. (Czech Tax Advisers Act), latest consolidation 2023-08-01: 23 declared / 23 captured, coverage_state = complete, no violations. The three sections the issue named as missing (§ 2, § 16, § 17) are present with real text.

Article content_hash moved for CZ-CNB-Decree (the text genuinely changed — it was incomplete). No article-level citation targets that instrument; the single citation (cz_bank_high_capital_turnover → "CNB Decree 67/2018 Sb.") is instrument-level, resolves instrument_only, and did not move. ADR-0121's deterministic gate is green.

Only one corpus uses this fetcher. esbirka is configured for CZ-CNB-Decree alone, so the re-check of "every other corpus ingested through esbirka" is complete by enumeration.

Honest residuals

  • 45 corpora remain unverified. This ADR does not make them complete; it stops them claiming to be. Extending declared inventories to the other fetchers (gii_xml, bwb_xml, slovlex, legi_parquet, …) is per-fetcher work and is not done here. The mechanism is in place; each fetcher is a separate change.
  • No consumer reads coverage_state yet. It is persisted and queryable. Wiring it into citation_resolver so not_in_corpus can be qualified as "known gap in our copy", and into a monitoring surface, is deliberately a follow-up — the record must exist before anything can read it, and a half-wired consumer would be its own claim-vs-check.
  • markers_seen_in_source stays blind on synthesised HTML. Left as-is: C6 supersedes it where an inventory exists, and where none exists it is still the only tail-truncation signal available. Recorded, not silently relied upon.
  • The declared inventory is only as good as the source. If e-Sbírka itself omits a section from its fragment set, C6 cannot see it. That is a narrower and more legible exposure than a percentage, but it is not zero, and complete means "matches what the publisher declares", not "matches the printed statute".
  • lex_ingestion_log has a tenant policy but no admin_bypass (pre-existing, the #561 shape). A rejection row is therefore readable by a tenant-scoped session for that tenant — the normal path — but an admin session reads zero rows regardless of content. Recorded here rather than fixed, because widening RLS is not this PR's subject and there is no reader yet. Anyone adding one must not read an empty admin result as "no rejections".
  • The query-honouring stub is not a SPARQL engine. It models mandatory-vs-OPTIONAL binding, the SELECT projection and the subject IRI. A regression expressed some other way (a FILTER, a changed property path) would still pass it — which is why the live contract test exists and why the live re-ingestion, not the suite, is what proves a corpus.