From 7a409f8faae078fa197291ce20dda7aacaf40b50 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 29 Sep 2026 16:28:02 -0400 Subject: [PATCH 1/3] Pin the three September claims-week spellings The 598f394 first-print append spelled week_2026-08-29, week_2026-09-05 and week_2026-09-12 for the first time, and the stripped-segment pin in test_committed_catalog_is_current_and_valid did not list them, so Arch checks failed on the branch head. Each is the row's own week_ending period (us.dol.initial_claims.sa and dol.eta.continued_claims.sa), so the strips stand and the pin is extended. Co-Authored-By: Claude Opus 5.5 --- tests/test_build_series_catalog.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_build_series_catalog.py b/tests/test_build_series_catalog.py index 584cb707..44fc3c0b 100644 --- a/tests/test_build_series_catalog.py +++ b/tests/test_build_series_catalog.py @@ -782,6 +782,10 @@ def test_committed_catalog_is_current_and_valid() -> None: # Occurrences as of 55bbf3d: both August keys map to # dol.eta.continued_claims.sa (joined week_2026-08-15 at d77afe2) and # us.dol.initial_claims.sa. + # Extended 2026-09-29 to the catalog at 598f394, whose eight-row + # first-print append first spelled week_2026-08-29 and week_2026-09-05 + # (both claims series) and week_2026-09-12 (us.dol.initial_claims.sa + # only). Each is the row's own week_ending period, so the strips stand. # EVERY stripped spelling is auditable, mapped to the canonical # concepts it touched — a statute or edition label colliding with a # period spelling can only be caught here. @@ -794,7 +798,8 @@ def test_committed_catalog_is_current_and_valid() -> None: "week_2026-06-20", "week_2026-06-27", "week_2026-07-04", "week_2026-07-11", "week_2026-07-13", "week_2026-07-18", "week_2026-07-25", "week_2026-08-01", "week_2026-08-08", - "week_2026-08-15", "week_2026-08-22", "week_2026_06_13", + "week_2026-08-15", "week_2026-08-22", "week_2026-08-29", + "week_2026-09-05", "week_2026-09-12", "week_2026_06_13", "week_ending_2026_06_06", ] assert committed["stripped_segments"]["after_mpc_june_2026"] == [ From ab986a4dde60d4a6ea43611458ae38cbb6c1225a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 29 Sep 2026 16:41:11 -0400 Subject: [PATCH 2/3] Resolve catalog supersede links against every row's effective id build_catalog collected assertion version ids only from rows that carry assertionVersion.id, so a correction superseding a pre-versioning row (every row of the 128-line immutable prefix) was refused as superseding an "unknown version". receipt 0.6.2's append gate addresses such a row by its recomputed av2 content address and accepts the correction, and the Thesis resolver runs this generator on every append, so one gate-accepted correction would have failed every later append. Reproduced 2026-09-29 with six synthetic corrections of the June 2026 Table A-19 prefix rows. check_assertion_versions now gives every row the gate's effective id (the explicit id, else expected_assertion_version_id(row)), reserves all of them for the duplicate check, and resolves links against them. The self-link and cycle refusals are unchanged. Two identical unversioned rows, and a versioned row reissuing an unversioned row's address, are now duplicates here as they already are at the gate. Tests: the live ledger gets the gate's ids (catalog bytes unchanged: sha256 226174876fce...425a before and after); the A-19 correction case passes the gate, the preconditions and a full build; duplicate, unknown, self and two- and three-row cycle refusals match the gate's; Hypothesis properties check gate-accepted journals pass, and on single-defect journals the generator refuses for a reason iff the gate's refusal classifies as that reason. hypothesis is added to the dev extras and group; uv.lock gains only its entry (a full relock would have moved pandas and policyengine-us a major version). Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 + scripts/build_series_catalog.py | 158 ++++++++---- tests/test_build_series_catalog.py | 395 +++++++++++++++++++++++++++++ uv.lock | 96 ++++++- 4 files changed, 595 insertions(+), 56 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d96fd965..332d75ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "hypothesis>=6.100,<7", "pytest>=8.0.0,<9", "ruff>=0.5.0", ] @@ -56,5 +57,6 @@ target-version = "py311" [dependency-groups] dev = [ + "hypothesis>=6.100,<7", "pytest>=8.0.0,<9", ] diff --git a/scripts/build_series_catalog.py b/scripts/build_series_catalog.py index de6f4380..6fc66ccf 100644 --- a/scripts/build_series_catalog.py +++ b/scripts/build_series_catalog.py @@ -1436,61 +1436,78 @@ def _registry_event( } -def build_catalog( - observations_path: pathlib.Path, - docket_path: pathlib.Path | None, - existing: ExistingCatalog, - registry: UuidRegistry, -) -> tuple[dict, dict]: - """Build the catalog and the identity plan. +def _append_gate_helpers(): + """Import the append gate's supersede-aware view and content address.""" + try: + from check_thesis_facts_append import ( + effective_current_rows, + expected_assertion_version_id, + ) + except ImportError as exc: # pragma: no cover - environment guard + raise SystemExit( + "cannot import the supersede-aware current view from " + f"check_thesis_facts_append (receipt package required): {exc}" + ) + return effective_current_rows, expected_assertion_version_id - The plan records every registry-affecting outcome: ``mints`` (new - identity bindings to append), ``supersedes`` (identities whose UUID - changes — these require ``--allow-remint``), and ``dropped`` (existing - rows whose UUID would vanish from the catalog — also gated). + +def effective_assertion_version_id(row: dict, index: int) -> str: + """Return the assertion version id the append gate assigns ``row``. + + Mirrors receipt's ``_effective_assertion_id``: a versioned row is + addressed by its explicit ``assertionVersion.id``, and a row with no + ``assertionVersion`` (every row of the immutable prefix predates + versioning) by its recomputed av2 content address. A correction of a + prefix row names that address in ``supersedes``. ``index`` only + labels refusals. """ - raw = observations_path.read_bytes() - observation_lines = raw.decode().split("\n") - if observation_lines and observation_lines[-1] == "": - observation_lines.pop() - rows = [ - json.loads( - line, - object_pairs_hook=_reject_duplicate_keys, - parse_constant=_reject_json_constants, + version = row.get("assertionVersion") + if version is None: + _, content_address = _append_gate_helpers() + try: + return content_address(row) + except (AttributeError, TypeError, ValueError) as exc: + raise SystemExit( + f"observation row {index}: cannot compute the content " + f"address of a row without assertionVersion: {exc}" + ) from None + if not isinstance(version, dict) or not isinstance( + version.get("id"), str + ) or not version["id"]: + raise SystemExit( + f"observation row {index}: assertionVersion must carry a " + "nonempty string id" ) - for line in observation_lines - if line.strip() - ] - # The journal is append-only: a correction appends a row whose - # assertionVersion.supersedes names the version it replaces. Series - # identity must follow the supersede-aware CURRENT view (the same one - # the ledger's aggregate-fact validation uses), or superseded - # assertions would keep stale identities alive forever. The imported - # helper assumes append-gate-validated input; standalone runs get the - # same preconditions enforced here (unique ids, resolvable links, no - # cycles). - seen_ids: dict[str, int] = {} + return version["id"] + + +def check_assertion_versions(rows: list[dict]) -> list[str]: + """Enforce the append gate's supersede preconditions; return every id. + + The gate (receipt ``check_rows``) reserves the effective id of EVERY + row, versioned or not, and resolves corrections against those ids. + This check does the same, so a gate-accepted correction of a prefix + row is accepted here too. It refuses what the current view cannot + represent: two rows with one effective id (identical unversioned rows, + or a correction restoring an earlier value), a link to no row's id, a + self-link and a cycle. The gate refuses each of these as well. Its + remaining link rules (the target is the active version of the same + record, on an earlier line) are the gate's alone. + """ + ids: list[str] = [] + owner: dict[str, int] = {} links: dict[str, str] = {} for index, row in enumerate(rows): - version = row.get("assertionVersion") - if version is None: - continue - if not isinstance(version, dict) or not isinstance( - version.get("id"), str - ) or not version["id"]: - raise SystemExit( - f"observation row {index}: assertionVersion must carry a " - "nonempty string id" - ) - vid = version["id"] - if vid in seen_ids: + vid = effective_assertion_version_id(row, index) + if vid in owner: raise SystemExit( - f"observation row {index}: assertionVersion id {vid!r} " - f"duplicates row {seen_ids[vid]}" + f"observation row {index}: assertion version {vid!r} " + f"duplicates row {owner[vid]}" ) - seen_ids[vid] = index - supersedes = version.get("supersedes") + owner[vid] = index + ids.append(vid) + version = row.get("assertionVersion") + supersedes = version.get("supersedes") if version is not None else None if supersedes is not None: if not isinstance(supersedes, str) or not supersedes: raise SystemExit( @@ -1504,7 +1521,7 @@ def build_catalog( ) links[vid] = supersedes for vid, target in links.items(): - if target not in seen_ids: + if target not in owner: raise SystemExit( f"assertionVersion {vid!r} supersedes unknown version " f"{target!r}" @@ -1519,13 +1536,44 @@ def build_catalog( ) seen_chain.add(cursor) cursor = links.get(cursor) - try: - from check_thesis_facts_append import effective_current_rows - except ImportError as exc: # pragma: no cover - environment guard - raise SystemExit( - "cannot import the supersede-aware current view from " - f"check_thesis_facts_append (receipt package required): {exc}" + return ids + + +def build_catalog( + observations_path: pathlib.Path, + docket_path: pathlib.Path | None, + existing: ExistingCatalog, + registry: UuidRegistry, +) -> tuple[dict, dict]: + """Build the catalog and the identity plan. + + The plan records every registry-affecting outcome: ``mints`` (new + identity bindings to append), ``supersedes`` (identities whose UUID + changes — these require ``--allow-remint``), and ``dropped`` (existing + rows whose UUID would vanish from the catalog — also gated). + """ + raw = observations_path.read_bytes() + observation_lines = raw.decode().split("\n") + if observation_lines and observation_lines[-1] == "": + observation_lines.pop() + rows = [ + json.loads( + line, + object_pairs_hook=_reject_duplicate_keys, + parse_constant=_reject_json_constants, ) + for line in observation_lines + if line.strip() + ] + # The journal is append-only: a correction appends a row whose + # assertionVersion.supersedes names the version it replaces. Series + # identity must follow the supersede-aware CURRENT view (the same one + # the ledger's aggregate-fact validation uses), or superseded + # assertions would keep stale identities alive forever. The imported + # helper assumes append-gate-validated input; standalone runs get the + # same preconditions enforced by check_assertion_versions. + check_assertion_versions(rows) + effective_current_rows, _ = _append_gate_helpers() current_rows = effective_current_rows(rows) identities = build_identities(current_rows) diff --git a/tests/test_build_series_catalog.py b/tests/test_build_series_catalog.py index 44fc3c0b..f4086de9 100644 --- a/tests/test_build_series_catalog.py +++ b/tests/test_build_series_catalog.py @@ -10,17 +10,27 @@ from __future__ import annotations +import copy import doctest +import hashlib import json import pathlib +import re import sys import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +# The gate's own effective-id rule, private to receipt: the differential +# oracle for the generator's copy of it. +from receipt.append_gate import _effective_assertion_id ROOT = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) import build_series_catalog as bsc # noqa: E402 +import check_thesis_facts_append as gate # noqa: E402 PERIOD_SEGMENTS = [ "fy2026", @@ -2193,6 +2203,391 @@ def test_assertion_version_preconditions(tmp_path: pathlib.Path) -> None: _build(tmp_path, [f]) +# The append gate (receipt check_rows) gives EVERY journal row one effective +# assertion version id: its explicit assertionVersion.id, or, for a row +# without one (the whole immutable prefix), its recomputed av2 content +# address. It reserves every such id and resolves corrections against them. +# The generator's preconditions must accept whatever the gate accepts, and +# refuse a duplicate id, a self-link, an unresolvable link and a cycle +# exactly when the gate refuses for that reason. 2026-09-29 repro: six +# corrections of the June 2026 Table A-19 prefix rows passed the gate and +# were refused here as superseding an "unknown version", which would have +# failed every later resolver append. + +PREFIX_LINE_COUNT = json.loads( + (ROOT / "ledger" / "immutable_prefix.json").read_text(encoding="utf-8") +)["prefixLineCount"] + + +def _address(row: dict) -> str: + return _effective_assertion_id(row, gate.APPEND_GATE_SPEC) + + +def _journal_row( + record: str, + value: float, + supersedes: str | None = None, + *, + versioned: bool = True, +) -> dict: + """A row check_rows accepts; ``versioned=False`` is a prefix-style row.""" + row = _row("agency.rate", rid=record) + row["value"] = value + row.update( + retrievedAt="2026-06-01T00:00:00Z", + sourceVintage="first_print", + ledgerRepoSha="0" * 40, + responseArchive={"sha256": "0" * 64}, + ) + if versioned: + # The content address excludes the link, so relinking keeps the id. + row["assertionVersion"] = { + "id": gate.expected_assertion_version_id(row), + "supersedes": supersedes, + } + return row + + +def _gate_refusal(rows: list[dict], prefix_count: int) -> str | None: + try: + gate.check_rows([json.dumps(row) for row in rows], prefix_count) + except gate.AppendError as exc: + return str(exc) + return None + + +GENERATOR_REASONS = { + "duplicates row": "duplicate", + "supersedes itself": "self", + "supersedes unknown version": "unknown", + "supersede cycle": "cycle", +} + + +def _generator_refusal(rows: list[dict]) -> str | None: + """The generator's refusal reason, or its raw message if unclassified.""" + try: + bsc.check_assertion_versions(rows) + except SystemExit as exc: + message = str(exc) + return next( + (reason for text, reason in GENERATOR_REASONS.items() + if text in message), + message, + ) + return None + + +GATE_LINK_REFUSAL = re.compile(r"line (\d+) supersedes (\S+) but ") + + +def _gate_reason(rows: list[dict], refusal: str | None) -> str | None: + """Classify the gate's refusal by the generator's reasons. + + The gate names only the first line it refuses. A restated id is a + duplicate. A refused link is classified by where its target lies among + the gate's own effective ids: this row (self), no row (unknown), or a + row whose chain leads back here (cycle). Every other refusal (a link to + a non-active, other-record or later version outside a cycle, a missing + link, an id that is not the content address) is a rule the gate alone + enforces, which the generator must accept: None. + """ + if refusal is None: + return None + if " restates assertion version " in refusal: + return "duplicate" + match = GATE_LINK_REFUSAL.match(refusal) + if match is None: + return None + ids = [_address(row) for row in rows] + own, target = ids[int(match[1]) - 1], match[2] + if target == own: + return "self" + if target not in ids: + return "unknown" + links = { + ids[index]: (row.get("assertionVersion") or {}).get("supersedes") + for index, row in enumerate(rows) + } + cursor, seen = target, set() + while cursor is not None and cursor not in seen: + if cursor == own: + return "cycle" + seen.add(cursor) + cursor = links.get(cursor) + return None + + +def _relink(row: dict, target: str | None) -> None: + row["assertionVersion"]["supersedes"] = target + + +def test_live_ledger_reserves_the_gates_ids() -> None: + # Regression on the live ledger: the gate accepts it, and every row, + # the prefix rows without assertionVersion included, gets the gate's + # effective id. test_committed_catalog_is_current_and_valid pins the + # catalog bytes, which this change leaves identical. + lines = bsc.OBSERVATIONS.read_text(encoding="utf-8").splitlines() + rows = [json.loads(line) for line in lines] + assert all( + "assertionVersion" not in row for row in rows[:PREFIX_LINE_COUNT] + ) + gate.check_rows(lines, PREFIX_LINE_COUNT) + assert bsc.check_assertion_versions(rows) == [_address(r) for r in rows] + + +def test_correction_of_a_prefix_row_is_accepted_like_the_gate( + tmp_path: pathlib.Path, +) -> None: + # The 2026-09-29 repro on the live ledger. SYNTHETIC corrections: the + # restatement in millions is the repro's, not a chosen remedy (that is + # decision d397), and each archive digest is synthetic so no real + # correction can share its content address. Each supersedes the active + # version of its June record, which is the prefix row's av2 address + # until a real correction lands. + lines = bsc.OBSERVATIONS.read_text(encoding="utf-8").splitlines() + rows = [json.loads(line) for line in lines] + june = [ + row for row in rows[:PREFIX_LINE_COUNT] + if row["source_record_id"].startswith( + "bls.cps.employed_people_by_occupation." + ) + and row["source_record_id"].endswith(".june_2026.first_print") + ] + assert len(june) == 6 + assert all("assertionVersion" not in row for row in june) + corrections = [] + for original in june: + record = original["source_record_id"] + active = [r for r in rows if r["source_record_id"] == record][-1] + correction = copy.deepcopy(original) + correction["value"] = round(original["value"] / 1000, 3) + correction["measure"]["unit"] = "millions" + correction["responseArchive"]["sha256"] = hashlib.sha256( + f"synthetic correction of {record}".encode() + ).hexdigest() + correction["assertionVersion"] = { + "id": gate.expected_assertion_version_id(correction), + "supersedes": _address(active), + } + corrections.append(correction) + journal = rows + corrections + + assert _gate_refusal(journal, PREFIX_LINE_COUNT) is None + ids = bsc.check_assertion_versions(journal) + assert ids == [_address(row) for row in journal] + + observations = tmp_path / "obs.jsonl" + observations.write_text( + "".join(json.dumps(row) + "\n" for row in journal), encoding="utf-8" + ) + catalog, plan = bsc.build_catalog( + observations, + bsc.DOCKET_SEED, + bsc.ExistingCatalog(bsc.CATALOG), + bsc.UuidRegistry.load(bsc.UUID_REGISTRY), + ) + assert not plan["mints"] and not plan["supersedes"] and not plan["dropped"] + occupations = [ + series for series in catalog["series"] + if series["concept"].startswith( + "bls.cps.employed_people_by_occupation." + ) + ] + assert len(occupations) == 6 + assert all(series["unit"] == "millions" for series in occupations) + assert catalog["observation_rows"] == len(journal) + assert catalog["current_assertion_rows"] == len( + gate.effective_current_rows(rows) + ) + + +def test_every_rows_effective_id_is_reserved() -> None: + # A row without assertionVersion reserves its content address exactly + # as the gate does, so two identical such rows, a versioned re-append + # of one, and an A -> B -> A restore of one all collide. So does an + # A -> B -> A restore among versioned rows. + legacy = _journal_row("rec.a", 1.0, versioned=False) + reissued = copy.deepcopy(legacy) + reissued["assertionVersion"] = {"id": _address(legacy), "supersedes": None} + b = _journal_row("rec.a", 2.0, _address(legacy)) + restore = copy.deepcopy(legacy) + restore["assertionVersion"] = { + "id": _address(legacy), "supersedes": _address(b) + } + a2 = _journal_row("rec.b", 1.0) + b2 = _journal_row("rec.b", 2.0, _address(a2)) + back = _journal_row("rec.b", 1.0, _address(b2)) + for journal, prefix, match in ( + ([legacy, copy.deepcopy(legacy)], 2, "row 1: .* duplicates row 0"), + ([legacy, reissued], 1, "row 1: .* duplicates row 0"), + ([legacy, b, restore], 1, "row 2: .* duplicates row 0"), + ([a2, b2, back], 0, "row 2: .* duplicates row 0"), + ): + assert _gate_reason(journal, _gate_refusal(journal, prefix)) == ( + "duplicate" + ) + with pytest.raises(SystemExit, match=match): + bsc.check_assertion_versions(journal) + + +def test_link_refusals_fire_where_the_gate_refuses() -> None: + legacy = _journal_row("rec.a", 1.0, versioned=False) + unknown = _journal_row("rec.a", 2.0, "av2:" + "f" * 64) + # A near miss: the address the prefix row would have at another value. + near = _journal_row("rec.a", 2.0, _address(dict(legacy, value=3.0))) + itself = _journal_row("rec.a", 2.0) + _relink(itself, _address(itself)) + x, y = (_journal_row(f"rec.{n}", 1.0) for n in "xy") + _relink(x, _address(y)) + _relink(y, _address(x)) + two_cycle = [legacy, x, y] + p, q, r = (_journal_row(f"rec.{n}", 1.0) for n in "pqr") + _relink(p, _address(q)) + _relink(q, _address(r)) + _relink(r, _address(p)) + three_cycle = [legacy, p, q, r] + for journal, match, reason in ( + ([legacy, unknown], "supersedes unknown version", "unknown"), + ([legacy, near], "supersedes unknown version", "unknown"), + ([legacy, itself], "supersedes itself", "self"), + (two_cycle, "supersede cycle", "cycle"), + (three_cycle, "supersede cycle", "cycle"), + ): + assert _gate_reason(journal, _gate_refusal(journal, 1)) == reason + with pytest.raises(SystemExit, match=match): + bsc.check_assertion_versions(journal) + + +@st.composite +def _gate_valid_journals(draw) -> tuple[list[dict], int]: + """A journal check_rows accepts: prefix-style rows without + assertionVersion, then versioned appends, each a new record or a + correction of a record's active version (a prefix row's by its + content address).""" + prefix = draw(st.integers(0, 4)) + steps = draw(st.lists(st.one_of(st.none(), st.integers(0, 7)), max_size=8)) + rows: list[dict] = [] + active: dict[str, str] = {} + for _ in range(prefix): + row = _journal_row(f"rec.{len(active)}", float(len(rows)), + versioned=False) + rows.append(row) + active[row["source_record_id"]] = _address(row) + for step in steps: + if step is None or not active: + record, supersedes = f"rec.{len(active)}", None + else: + record = sorted(active)[step % len(active)] + supersedes = active[record] + row = _journal_row(record, float(len(rows)), supersedes) + rows.append(row) + active[record] = _address(row) + return rows, prefix + + +MUTATIONS = ( + "unknown", "self", "cycle", "relink", "unlink", "duplicate", "restore" +) + + +def _mutate(draw, kind: str, rows: list[dict], prefix: int): + """Apply one defect. Only ``supersedes`` changes, which leaves every id + alone, or a row is added: a copy of any row, or an A -> B -> A + restore.""" + ids = [_address(row) for row in rows] + versioned = [i for i, row in enumerate(rows) if "assertionVersion" in row] + if kind == "unknown" and versioned: + target = "av2:" + hashlib.sha256( + draw(st.binary(min_size=1, max_size=8)) + ).hexdigest() + _relink(rows[draw(st.sampled_from(versioned))], target) + elif kind == "self" and versioned: + i = draw(st.sampled_from(versioned)) + _relink(rows[i], ids[i]) + elif kind == "cycle" and len(versioned) >= 2: + ring = sorted(draw(st.lists(st.sampled_from(versioned), min_size=2, + max_size=3, unique=True))) + for a, b in zip(ring, ring[1:] + ring[:1]): + _relink(rows[a], ids[b]) + elif kind == "relink" and versioned and len(rows) >= 2: + # Any other row, earlier or later: the valid target, a stale or + # other-record version, or a forward link that may close a cycle. + i = draw(st.sampled_from(versioned)) + j = draw(st.sampled_from([k for k in range(len(rows)) if k != i])) + _relink(rows[i], ids[j]) + elif kind == "unlink" and versioned: + _relink(rows[draw(st.sampled_from(versioned))], None) + elif kind == "duplicate" and rows: + j = draw(st.integers(0, len(rows) - 1)) + at = draw(st.integers(j + 1, len(rows))) + rows.insert(at, copy.deepcopy(rows[j])) + prefix += at < prefix + elif kind == "restore": + versions: dict[str, list[int]] = {} + for i, row in enumerate(rows): + versions.setdefault(row["source_record_id"], []).append(i) + chains = [v for _, v in sorted(versions.items()) if len(v) >= 2] + if chains: + chain = draw(st.sampled_from(chains)) + first = copy.deepcopy(rows[chain[0]]) + first["assertionVersion"] = { + "id": ids[chain[0]], "supersedes": ids[chain[-1]] + } + rows.append(first) + return rows, prefix + + +@st.composite +def _mutated_journals(draw, max_mutations: int): + rows, prefix = draw(_gate_valid_journals()) + rows = copy.deepcopy(rows) + kinds = draw(st.lists(st.sampled_from(MUTATIONS), max_size=max_mutations)) + for kind in kinds: + rows, prefix = _mutate(draw, kind, rows, prefix) + return rows, prefix + + +PROPERTY_SETTINGS = settings( + max_examples=400, deadline=None, derandomize=True, database=None +) + + +@PROPERTY_SETTINGS +@given(_gate_valid_journals()) +def test_gate_accepted_journals_pass_the_preconditions(journal) -> None: + # Invariant: the gate accepts => the generator accepts, and both assign + # every row the same effective id. + rows, prefix = journal + assert _gate_refusal(rows, prefix) is None + assert bsc.check_assertion_versions(rows) == [_address(r) for r in rows] + + +@PROPERTY_SETTINGS +@given(_mutated_journals(max_mutations=1)) +def test_generator_refuses_iff_the_gate_does_for_that_reason(journal) -> None: + # Invariant, on journals with at most one defect: the generator refuses + # for reason R exactly when the gate's refusal classifies as R, and + # accepts every journal the gate accepts or refuses for its own rules. + rows, prefix = journal + refusal = _gate_refusal(rows, prefix) + assert _generator_refusal(rows) == _gate_reason(rows, refusal), refusal + + +@PROPERTY_SETTINGS +@given(_mutated_journals(max_mutations=3)) +def test_generator_refusals_are_gate_refusals(journal) -> None: + # Invariant, with several defects: every generator refusal is a gate + # refusal, and a gate refusal for a generator reason is refused here. + rows, prefix = journal + refusal = _gate_refusal(rows, prefix) + generator = _generator_refusal(rows) + if generator is not None: + assert refusal is not None + if _gate_reason(rows, refusal) is not None: + assert generator is not None + + def test_cross_dimension_ambiguous_alias_never_steals( tmp_path: pathlib.Path, ) -> None: diff --git a/uv.lock b/uv.lock index 3a5807ed..1d609318 100644 --- a/uv.lock +++ b/uv.lock @@ -635,6 +635,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ab/443208c2988a24eaafbfbd6791b897ad9a8a1dd7f870d5b455d7d15a28ad/hypothesis-6.168.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9029775dc2e25e3e8e596c4306926f0079158353631c7c52c285bbc5c8073c2b", size = 792239, upload-time = "2026-09-28T05:19:59.144Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7b/619c1a76c52de2d3cfd68a2f9876c09464d63097db5bf86a28b62dc37bb1/hypothesis-6.168.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e1d91530cefdb9e0b6467c203eb509c61a11d70480a37db59cb53a2f9913102", size = 788113, upload-time = "2026-09-28T05:19:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/33841e8833105f850214a486d83740ba52041892ca94e687a05829c0ce83/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0df00cbe8133aa220308d11fa28e3add996cf5102a39b279c1d711015fd9114b", size = 1124241, upload-time = "2026-09-28T05:18:37.589Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/c84a7900feced6a420e824ab61d316e4f59a6887d991bc143ecc294a09ed/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7f75a2e23288ee82ee965a952473d9c5447c2cbc1afc94be09d2402201774e", size = 1170406, upload-time = "2026-09-28T05:19:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/47/5d/6e2a32b9c3020af7a06b00fe446a2c5155ae76ac468c0b68d7188e524002/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc2b1c37631f2231dd0d077225aa5908fc2bd34a4c3faf475053b0bc2eb24c95", size = 1300339, upload-time = "2026-09-28T05:20:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f9/78/19f3c9eb9a5c215b9fbde57be18ef7c1c78afa509b460d4e8f86026d741a/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73960a58f6efc8cbc57d8ad0647955f7c5b25f3d562e3eaa57afa1c69894f7aa", size = 1336484, upload-time = "2026-09-28T05:19:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/88/bb/d46c4519e989e022667705d8a56e83a09ad4ea50eae07ca4bb0f4708dd23/hypothesis-6.168.3-cp311-cp311-win_amd64.whl", hash = "sha256:3f1122223759acc0fe5301c1ae505d762ee61af91db7b191a791ccdbcd965462", size = 684646, upload-time = "2026-09-28T05:20:00.842Z" }, + { url = "https://files.pythonhosted.org/packages/7a/56/44a17266bcf1add5fba7c3df5c3920396a5e924d917fb0534a6f9e2b9b9a/hypothesis-6.168.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bff12e036b67abc5ded682f724f4d7afaff5223190270a0763695f218c079068", size = 793313, upload-time = "2026-09-28T05:20:22.045Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/1a25b11e9133cb54c02ee6cf99a61296ad461aabc7aae3399e1d0ff1f095/hypothesis-6.168.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73c6188056e6dc110260e39d141c69c3d02f23e3ba1d8610c7a8b6f2510ec96", size = 784855, upload-time = "2026-09-28T05:19:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/8b/da/728d3210f0b0089337f37206a100d31ef546d1f99839e709f0a9ca443f8e/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb1063cb794765097b8c0add598bd34a904f44dc15db81275dd1f0ed1ac567ed", size = 1123043, upload-time = "2026-09-28T05:19:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/53/86/bfb9ec344a8b3ef2fa6f59bb3d30c5b3a7d86d594107670539c34ee53d1b/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b2d47f5d9060b049039bef0b267e88480f6468f95ee394e514e84a81ccdc5f5", size = 1169121, upload-time = "2026-09-28T05:20:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e0/5a76dbafbcaf8fff075f09377cddefa17289ef05dbf51878dde88385b966/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8367f623a98cbf90f33fd2a43b100671b152b5975bc343a578290945f52f7018", size = 1298749, upload-time = "2026-09-28T05:19:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/28/15/0e65af96b353f2c4bdc283c53ed0980e5ac5009afe97ef3b23c77b364664/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd81241f4cd76fb18d481e87b9688b30a0cb5c30841730513323ba1c7aba1837", size = 1335271, upload-time = "2026-09-28T05:20:54.005Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c5/37eab2155664aa544df321f6d3940b89c6702d79a72fdc98d7b5a2286f8c/hypothesis-6.168.3-cp312-cp312-win_amd64.whl", hash = "sha256:377438de53afb94347d9845b7d90db5905c6d2a8d612108e9938e0f80503bb90", size = 682211, upload-time = "2026-09-28T05:18:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/27960db1d45a94497e1681fb9d7b9140b44da4e655ba20f0d82f95d10230/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:101531b4ccf7fa12965a6b295d10b64c2f8a8fab61e1f9b18c55335d8fb02575", size = 793135, upload-time = "2026-09-28T05:19:14.056Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cb/be08379286c1925fcd651b92ed835f470a83b859b5c484b3431da9d851f5/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:41e0120814de5c3a6b58d8cb27cb11b8873ab33fba130cd09855a27f5a12a1ca", size = 788995, upload-time = "2026-09-28T05:20:34.164Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/6d885d139b2ed76e2f4908cf10eae56acc73bf05cbef286357c4559c9a04/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d120009d145697909f2f0c532f2d568558ddd6990707f8cba6576bfb9770696", size = 1124974, upload-time = "2026-09-28T05:19:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/05/cc/b302ae809b48254830cae57801d0af0b66783df515b5dd56b9f21c8dfa86/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:818b3d09bba60ef90463e47a4bc87275dba1e8aeb184d3102888440ca7c7837d", size = 1171967, upload-time = "2026-09-28T05:20:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ab/51031080a6600f5b07adf5f9dfa117345c2adda07a5c24bda523e70bd1e9/hypothesis-6.168.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9e3c36eba80e089ecfa28ec08049723223cb41d3cfe4d9988169cf98440e402b", size = 685641, upload-time = "2026-09-28T05:19:44.609Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1661,6 +1749,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "hypothesis" }, { name = "pytest" }, { name = "ruff" }, ] @@ -1670,6 +1759,7 @@ policyengine = [ [package.dev-dependencies] dev = [ + { name = "hypothesis" }, { name = "pytest" }, ] @@ -1677,6 +1767,7 @@ dev = [ requires-dist = [ { name = "cryptography", specifier = ">=46.0.3" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100,<7" }, { name = "microplex", specifier = ">=0.1.0" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "odfpy", specifier = ">=1.4.1" }, @@ -1697,7 +1788,10 @@ requires-dist = [ provides-extras = ["dev", "policyengine"] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0.0,<9" }] +dev = [ + { name = "hypothesis", specifier = ">=6.100,<7" }, + { name = "pytest", specifier = ">=8.0.0,<9" }, +] [[package]] name = "policyengine-core" From ee04eac8c7e4c7b14d168f911a18a4d78e6a964f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 29 Sep 2026 17:18:19 -0400 Subject: [PATCH 3/3] Scope the precondition and differential claims to what holds Review of #306 found wording that overstated the change: the gate-only list in _gate_reason included an explicit id that is not the content address, but the generator trusts explicit ids and can refuse such a journal for a duplicate or orphaned link. The single-defect "iff" is now stated for journals whose explicit ids are their content addresses, and the containment property also rewrites ids ("reid") to hold the rest to "every generator refusal is a gate refusal". check_assertion_versions now says it enforces the part of the gate's rules the current view relies on, names the gate-only rules (content address, active version, earlier line, no version superseded twice), and says a duplicate needs an exact restatement of addressed content, not just a restored value. The receipt import comment says check_rows reserves the same id inline, and the import-failure message names the content address too. Co-Authored-By: Claude Opus 5.5 --- scripts/build_series_catalog.py | 23 ++++++++------- tests/test_build_series_catalog.py | 47 +++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/scripts/build_series_catalog.py b/scripts/build_series_catalog.py index 6fc66ccf..80186a78 100644 --- a/scripts/build_series_catalog.py +++ b/scripts/build_series_catalog.py @@ -1445,7 +1445,8 @@ def _append_gate_helpers(): ) except ImportError as exc: # pragma: no cover - environment guard raise SystemExit( - "cannot import the supersede-aware current view from " + "cannot import the append gate's content address and " + "supersede-aware current view from " f"check_thesis_facts_append (receipt package required): {exc}" ) return effective_current_rows, expected_assertion_version_id @@ -1485,14 +1486,16 @@ def check_assertion_versions(rows: list[dict]) -> list[str]: """Enforce the append gate's supersede preconditions; return every id. The gate (receipt ``check_rows``) reserves the effective id of EVERY - row, versioned or not, and resolves corrections against those ids. - This check does the same, so a gate-accepted correction of a prefix - row is accepted here too. It refuses what the current view cannot - represent: two rows with one effective id (identical unversioned rows, - or a correction restoring an earlier value), a link to no row's id, a - self-link and a cycle. The gate refuses each of these as well. Its - remaining link rules (the target is the active version of the same - record, on an earlier line) are the gate's alone. + row, versioned or not, and a correction names the effective id of the + version it replaces. This check reserves the same ids, so a + gate-accepted correction of a prefix row is accepted here too. It + refuses the part of the gate's rules the current view relies on: two + rows with one effective id (identical unversioned rows, or a row + restating an earlier row's exact addressed content), a link to no + row's id, a self-link and a cycle. The gate refuses each of these as + well. Its other rules (an explicit id equals the content address; a + link names the active version of the same record, on an earlier line, + so no version is superseded twice) are the gate's alone. """ ids: list[str] = [] owner: dict[str, int] = {} @@ -1571,7 +1574,7 @@ def build_catalog( # the ledger's aggregate-fact validation uses), or superseded # assertions would keep stale identities alive forever. The imported # helper assumes append-gate-validated input; standalone runs get the - # same preconditions enforced by check_assertion_versions. + # preconditions it relies on from check_assertion_versions. check_assertion_versions(rows) effective_current_rows, _ = _append_gate_helpers() current_rows = effective_current_rows(rows) diff --git a/tests/test_build_series_catalog.py b/tests/test_build_series_catalog.py index f4086de9..fc886040 100644 --- a/tests/test_build_series_catalog.py +++ b/tests/test_build_series_catalog.py @@ -22,8 +22,9 @@ from hypothesis import given, settings from hypothesis import strategies as st -# The gate's own effective-id rule, private to receipt: the differential -# oracle for the generator's copy of it. +# receipt's effective-id rule (private; effective_current_rows uses it, and +# check_rows reserves the same id inline after requiring an explicit id to +# equal the content address): the differential oracle for the generator's. from receipt.append_gate import _effective_assertion_id ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -2206,10 +2207,12 @@ def test_assertion_version_preconditions(tmp_path: pathlib.Path) -> None: # The append gate (receipt check_rows) gives EVERY journal row one effective # assertion version id: its explicit assertionVersion.id, or, for a row # without one (the whole immutable prefix), its recomputed av2 content -# address. It reserves every such id and resolves corrections against them. -# The generator's preconditions must accept whatever the gate accepts, and -# refuse a duplicate id, a self-link, an unresolvable link and a cycle -# exactly when the gate refuses for that reason. 2026-09-29 repro: six +# address. It reserves every such id and a correction names one of them. +# The generator's preconditions must accept whatever the gate accepts; on +# journals whose explicit ids are their content addresses (the gate's first +# row rule), refuse a duplicate id, a self-link, an unresolvable link and a +# cycle exactly when the gate refuses for that reason; and with any defects +# at all, refuse only what the gate refuses. 2026-09-29 repro: six # corrections of the June 2026 Table A-19 prefix rows passed the gate and # were refused here as superseding an "unknown version", which would have # failed every later resolver append. @@ -2289,8 +2292,11 @@ def _gate_reason(rows: list[dict], refusal: str | None) -> str | None: the gate's own effective ids: this row (self), no row (unknown), or a row whose chain leads back here (cycle). Every other refusal (a link to a non-active, other-record or later version outside a cycle, a missing - link, an id that is not the content address) is a rule the gate alone - enforces, which the generator must accept: None. + link) is a rule the gate alone enforces, which the generator must + accept: None. So is an explicit id that is not the content address, + but the generator trusts explicit ids, so such a journal can still be + refused there for a duplicate or orphaned link; journals with one are + held only to containment. """ if refusal is None: return None @@ -2397,6 +2403,8 @@ def test_correction_of_a_prefix_row_is_accepted_like_the_gate( assert len(occupations) == 6 assert all(series["unit"] == "millions" for series in occupations) assert catalog["observation_rows"] == len(journal) + # Each correction replaces its target, so the current count is the + # live ledger's. assert catalog["current_assertion_rows"] == len( gate.effective_current_rows(rows) ) @@ -2489,12 +2497,16 @@ def _gate_valid_journals(draw) -> tuple[list[dict], int]: MUTATIONS = ( "unknown", "self", "cycle", "relink", "unlink", "duplicate", "restore" ) +# An explicit id that is not the row's content address: the gate refuses +# it outright, and only containment is claimed for it. +ANY_MUTATION = (*MUTATIONS, "reid") def _mutate(draw, kind: str, rows: list[dict], prefix: int): """Apply one defect. Only ``supersedes`` changes, which leaves every id alone, or a row is added: a copy of any row, or an A -> B -> A - restore.""" + restore. The exception is "reid", which rewrites one explicit id to + another row's id or to an address no row has.""" ids = [_address(row) for row in rows] versioned = [i for i, row in enumerate(rows) if "assertionVersion" in row] if kind == "unknown" and versioned: @@ -2535,14 +2547,20 @@ def _mutate(draw, kind: str, rows: list[dict], prefix: int): "id": ids[chain[0]], "supersedes": ids[chain[-1]] } rows.append(first) + elif kind == "reid" and versioned: + i = draw(st.sampled_from(versioned)) + stray = "av2:" + hashlib.sha256( + draw(st.binary(min_size=1, max_size=8)) + ).hexdigest() + rows[i]["assertionVersion"]["id"] = draw(st.sampled_from([*ids, stray])) return rows, prefix @st.composite -def _mutated_journals(draw, max_mutations: int): +def _mutated_journals(draw, max_mutations: int, kinds=MUTATIONS): rows, prefix = draw(_gate_valid_journals()) rows = copy.deepcopy(rows) - kinds = draw(st.lists(st.sampled_from(MUTATIONS), max_size=max_mutations)) + kinds = draw(st.lists(st.sampled_from(kinds), max_size=max_mutations)) for kind in kinds: rows, prefix = _mutate(draw, kind, rows, prefix) return rows, prefix @@ -2575,10 +2593,11 @@ def test_generator_refuses_iff_the_gate_does_for_that_reason(journal) -> None: @PROPERTY_SETTINGS -@given(_mutated_journals(max_mutations=3)) +@given(_mutated_journals(max_mutations=3, kinds=ANY_MUTATION)) def test_generator_refusals_are_gate_refusals(journal) -> None: - # Invariant, with several defects: every generator refusal is a gate - # refusal, and a gate refusal for a generator reason is refused here. + # Invariant, with several defects, ids rewritten away from their content + # addresses included: every generator refusal is a gate refusal, and a + # gate refusal for a generator reason is refused here. rows, prefix = journal refusal = _gate_refusal(rows, prefix) generator = _generator_refusal(rows)