diff --git a/scripts/build_series_catalog.py b/scripts/build_series_catalog.py index 1adeaca..0387708 100644 --- a/scripts/build_series_catalog.py +++ b/scripts/build_series_catalog.py @@ -1249,12 +1249,9 @@ def _succeeds_problem( reserved = _reserved_segment_problem(succeeds.get("concept")) if reserved: return f"line {lineno}: succeeds {reserved}" - if self.entry_key(succeeds) == key: - return ( - f"line {lineno}: {key} succeeds ITSELF — self-handover " - "would forge consumption and unlock a ceremony-free " - "reclaim of a fresh UUID" - ) + # Dimension types must be valid BEFORE any key derivation: a + # string geography would otherwise crash _geo_key instead of + # producing a schema finding. for what, allowed in ( ("geography", ("level", "id", "vintage")), ("entity", ("name", "role")), @@ -1264,6 +1261,12 @@ def _succeeds_problem( ) if domain: return f"line {lineno}: {domain}" + if self.entry_key(succeeds) == key: + return ( + f"line {lineno}: {key} succeeds ITSELF — self-handover " + "would forge consumption and unlock a ceremony-free " + "reclaim of a fresh UUID" + ) predecessor_key = self.entry_key(succeeds) predecessor = self.latest.get(predecessor_key) if predecessor is None: @@ -1611,6 +1614,71 @@ def claim_uuid(row_uuid: str, key: tuple) -> str: used_uuids[parsed] = key return row_uuid + def _plan_enrichment( + prior: dict, + canon_key: tuple[str, str, str], + geography: dict | None, + entity: dict | None, + row_uuid: str, + ) -> bool: + """Plan a retire + succeeds pair when ``prior`` is a genuine + placeholder whose live binding owns ``row_uuid``; True if planned. + """ + prior_own_key = UuidRegistry.entry_key(prior) + owner_key = registry.uuid_owner.get(uuid_module.UUID(row_uuid).int) + prior_geo = prior.get("geography") or None + geo = geography or {} + enrichment_shaped = ( + prior.get("status") == "docket-only" + and prior.get("entity") is None + and ( + prior_geo is None + or ( + (prior_geo.get("level"), prior_geo.get("id")) + == (geo.get("level"), geo.get("id")) + and prior_geo.get("vintage") + in (None, geo.get("vintage")) + ) + ) + ) + if not ( + enrichment_shaped + and owner_key == prior_own_key + and registry.is_live(prior_own_key) + ): + return False + # Docket-placeholder enrichment: the binding MOVES to the observed + # identity via an explicit retire + succeeds pair. UUID continuity + # is preserved, so no ceremony flag is needed — this holds for the + # first enrichment and for a re-enrichment after a withdraw/reclaim + # cycle (the grammar accepts succeeds on a retired successor key). + plan["enrich_retires"].append( + dict( + _registry_event( + prior["concept"], + prior.get("geography"), + prior.get("entity"), + row_uuid, + ), + retired=True, + note=( + "docket placeholder enriched by first observed " + "identity" + ), + ) + ) + plan["mints"].append( + dict( + _registry_event(canon_key[0], geography, entity, row_uuid), + succeeds={ + "concept": prior["concept"], + "geography": _identity_geography(prior.get("geography")), + "entity": _identity_entity(prior.get("entity")), + }, + ) + ) + return True + def resolve_uuid( canon_key: tuple[str, str, str], prior: dict | None, @@ -1620,6 +1688,17 @@ def resolve_uuid( binding = registry.binding(canon_key) prior_uuid = prior["uuid"] if prior else None if prior_uuid and binding and prior_uuid != binding: + # A RETIRED successor returning on the CURRENT placeholder + # lineage is a re-enrichment, not a remint: plan the retire + + # succeeds pair instead of dead-ending on UUID ownership. + if ( + prior is not None + and not registry.is_live(canon_key) + and _plan_enrichment( + prior, canon_key, geography, entity, prior_uuid + ) + ): + return prior_uuid # The catalog row disagrees with the registry: an explicit, # gated remint (the curator edited the row's uuid on purpose). # The replacement must be new to the registry outright. @@ -1686,62 +1765,9 @@ def resolve_uuid( row_uuid = prior_uuid if prior_uuid else str(uuid_module.uuid4()) owner_key = registry.uuid_owner.get(uuid_module.UUID(row_uuid).int) if owner_key is not None: - prior_own_key = ( - UuidRegistry.entry_key(prior) if prior is not None else None - ) - prior_geo = (prior or {}).get("geography") or None - geo = geography or {} - enrichment_shaped = ( - prior is not None - and prior.get("status") == "docket-only" - and prior.get("entity") is None - and ( - prior_geo is None - or ( - (prior_geo.get("level"), prior_geo.get("id")) - == (geo.get("level"), geo.get("id")) - and prior_geo.get("vintage") - in (None, geo.get("vintage")) - ) - ) - ) - if ( - enrichment_shaped - and owner_key == prior_own_key - and registry.is_live(owner_key) + if prior is not None and _plan_enrichment( + prior, canon_key, geography, entity, row_uuid ): - # Docket-placeholder enrichment: the binding MOVES to the - # observed identity via an explicit retire + succeeds pair. - # UUID continuity is preserved, so no ceremony flag needed. - plan["enrich_retires"].append( - dict( - _registry_event( - prior["concept"], - prior.get("geography"), - prior.get("entity"), - row_uuid, - ), - retired=True, - note=( - "docket placeholder enriched by first observed " - "identity" - ), - ) - ) - plan["mints"].append( - dict( - _registry_event( - canon_key[0], geography, entity, row_uuid - ), - succeeds={ - "concept": prior["concept"], - "geography": _identity_geography( - prior.get("geography") - ), - "entity": _identity_entity(prior.get("entity")), - }, - ) - ) return row_uuid raise SystemExit( f"identity {canon_key} would mint uuid {row_uuid}, which " diff --git a/tests/test_build_series_catalog.py b/tests/test_build_series_catalog.py index 5f81621..3ce80fc 100644 --- a/tests/test_build_series_catalog.py +++ b/tests/test_build_series_catalog.py @@ -2221,3 +2221,85 @@ def test_check_compares_bytes_not_text(tmp_path: pathlib.Path) -> None: crlf = catalog_path.read_bytes().replace(b"\n", b"\r\n") catalog_path.write_bytes(crlf) assert bsc.main(argv + ["--check"]) == 1 # byte drift is drift + + +def test_builder_plans_reenrichment_of_retired_successor( + tmp_path: pathlib.Path, +) -> None: + # Fourteenth-review follow-up: placeholder A/U1 -> enriched B/U1 -> + # B retired (observation withdrawn) + A reclaimed as U2 -> B returns. + # The builder must plan retire(A/U2) + succeeds(B/U2) instead of + # aborting on UUID ownership before enrichment planning. + fresh = "dddddddd-4444-4444-8444-444444444444" + a_key = {"concept": "census.m3.new_orders", "geography": None, + "entity": None} + b_geo = {"level": "country", "id": "0100000US", "vintage": "current"} + b_ent = {"name": "economy", "role": "aggregate"} + entries = [ + _mint("census.m3.new_orders", U1), + dict(_mint("census.m3.new_orders", U1), retired=True, + note="placeholder enriched"), + dict(_mint("census.m3.new_orders", U1, geography=b_geo, + entity=b_ent), succeeds=a_key), + dict(_mint("census.m3.new_orders", U1, geography=b_geo, + entity=b_ent), retired=True, + note="observation withdrawn upstream"), + dict(_mint("census.m3.new_orders", fresh), reclaimed=True, + note="placeholder re-established after handover"), + ] + existing = { + "series": [ + { + "uuid": fresh, + "concept": "census.m3.new_orders", + "geography": None, + "entity": None, + "aliases": [], + "status": "docket-only", + } + ] + } + catalog, plan = _build( + tmp_path, + [_row("census.m3.new_orders", geography=dict(b_geo), entity=b_ent)], + existing=existing, + registry_entries=entries, + ) + row = catalog["series"][0] + assert row["uuid"] == fresh and row["status"] == "observed" + retire = next(e for e in plan["enrich_retires"] if e.get("retired")) + succeed = next(e for e in plan["mints"] if e.get("succeeds")) + assert retire["uuid"] == succeed["uuid"] == fresh + # The staged whole must reload: succeeds lands on B's RETIRED key. + registry = _registry(tmp_path, entries) + staged = registry.stage( + plan["enrich_retires"] + plan["mints"] + plan["revives"] + + plan["supersedes"] + plan["retire_pending"] + ) + b_full_key = ("census.m3.new_orders", bsc._geo_key(b_geo), + bsc._entity_key(b_ent)) + assert staged.binding(b_full_key) == fresh + assert staged.is_live(b_full_key) + + +def test_malformed_succeeds_geography_is_a_schema_finding( + tmp_path: pathlib.Path, +) -> None: + # Fourteenth-review follow-up: a non-object succeeds.geography raised + # a raw AttributeError inside key derivation. + path = tmp_path / "registry.jsonl" + base = [ + _mint("a.one", U1), + dict(_mint("a.one", U1), retired=True, note="placeholder done"), + ] + forged = dict( + _mint("a.one", U1, entity={"name": "economy", "role": "aggregate"}), + succeeds={"concept": "a.one", "geography": "not-an-object", + "entity": None}, + ) + path.write_text( + "".join(json.dumps(e) + "\n" for e in base + [forged]), + encoding="utf-8", + ) + with pytest.raises(SystemExit, match="must be an object or null"): + bsc.UuidRegistry.load(path)