fix(graph): an approved merge was reverted by the next registry load - #42
Conversation
`apply_merge` folded the absorbed names into the new canonical but left the entity
that already owned them in place. `save_registry` then wrote a file where two
entities each claim the same alias, and `load_registry` resolves that by
last-writer-wins over `entities` iteration order — which, because save sorts by cid,
is alphabetical cid order rather than the human's decision:
before : {'globex': 'globex-industries', 'globex industries': 'globex-industries'}
in-memory after approve: {'globex': 'globex', 'globex industries': 'globex'}
after save + reload : {'globex': 'globex-industries', 'globex industries': 'globex-industries'}
entities in the file : ['globex', 'globex-industries']
So the human approved a merge into `globex` and the next load silently undid it. The
other sort direction is not better: the merge stands but the curated entity's own
display name is orphaned from its alias, and `build_entities` emits TWO nodes for what
a human declared one entity. ADR 008 wants identity decisions "auditable in one file's
git history"; a file whose meaning flips on cid sort order is neither auditable nor
decided.
apply_merge now absorbs the conflicting ENTITY, not just its names: any other entity
sharing a normalized name with the merge is folded in and deleted. Its old id goes in
too — normalize keeps hyphens, so the slug `globex-industries` is a different key from
the name `globex industries`, and an absorbed entity should keep answering to
everything it used to. Entities sharing no name with the merge are untouched.
`by_alias` is now derived in one place (`_reindex`) used by both load and apply_merge.
Built incrementally it kept entries pointing at ids that no longer exist, and
`merges approve` applies every chosen proposal against one in-memory registry before
saving once — so a later proposal would reason about a phantom entity. Pinned in
memory, not only across a save/load, because that is the live path.
Mutation-tested, all four caught: never absorbing (3 failures), absorbing every other
entity (1), dropping the old id from the fold (1), and skipping the reindex (1 — and
this one only bites once the in-memory assertion exists, which is why it is there).
graph 43 (+3). scorecard 25/25 (graph canonical-nodes metric included), ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guarantee is not "entities get absorbed" — it is that a merge never leaves two entities claiming the same normalized name, because that is the state whose meaning depends on cid sort order at load time. Asserted directly with a conflict detector rather than inferred from a round trip. Also pins the SCOPE, which I checked before claiming it: a merge into a clean file leaves it clean, and a conflict a human hand-authored between entities the merge does not touch is carried FORWARD, not silently resolved. Verified both ways — absorption is one-hop (`claimed` is computed before the loop, so it cannot cascade) and independent of `reg.entities` iteration order. That leaves one thing this fix deliberately does not do: police contradictions a human writes directly into the file, which still resolve by cid order on load. Separate concern, recorded on the backlog rather than smuggled in here. graph 44 (+1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…records
Gate review REFUTED the previous approach, and it was right: absorbing on any
normalized-name overlap made `apply_merge` delete records from a human-owned identity
file, silently, on the strength of one shared string. Two genuinely distinct entities
can carry the same short alias:
before : ['acme-bank', 'acme-foods'] # acme-bank is a person, aliased "Acme"
after (v1) : ['acme-foods']
ABG resolves : acme-foods # a person's alias, now a food company
Its id, display name, type and every other alias were folded into an unrelated entity,
its graph node `entities/person/acme-bank` vanished, and `merges approve` printed output
byte-identical to before. That is worse than the bug it fixed. Three more defects fell
out of the same over-reach: a merge with `absorbs: []` still deleted an entity (the
canonical's own id/name are always in `claimed`); with several proposals the surviving
identity became whichever ran LAST, where main was order-independent; and the canonical
inherited the absorbed entity's OTHER aliases, so a third entity claiming those
recreated the exact cid-sort-order contradiction one hop out — the headline symptom,
reproduced on the fix.
The resolution now depends on WHAT the merge claimed, which is the warrant question:
- the other entity's own NAME or ID is claimed -> the merge is asserting they are the
same entity, so absorb and remove it (as before).
- only one of its ALIASES is claimed -> they are still distinct entities that shared a
spelling. Retarget that alias to the canonical and leave the entity otherwise alone.
Verified across all four: the person survives with its type and ABG intact, the genuine
same-entity case still absorbs and its old id still resolves, a no-op merge deletes
nothing, and the one-hop-out case now survives a reload because the canonical claims
only what the merge named.
And nothing destructive is silent any more. `apply_merge` takes `log`, and `merges
approve` passes `print`, so an absorption or a retarget appears in the operator's
output rather than only in the file's git diff:
note: alias(es) ['Acme'] moved from 'acme-bank' ('Acme Bank') to 'acme-foods';
'acme-bank' is otherwise unchanged
Mutation-tested, all six caught — including the refuted v1 design itself (absorb on any
overlap: 3 failures), and the two the reviewer found surviving: skipping the reindex,
and dropping the canonical's own id/name from `claimed`. The second needed a stronger
assertion than "nothing was deleted" — that the contradiction is actually resolved.
graph 47 (+7). scorecard 25/25, ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gate verdictVERDICT: REFUTED — and the reviewer was right on every count. My fix was worse than the bug it fixed. Redesigned in The core error: I gave
|
| mutant | result |
|---|---|
| never absorb / never retarget | 4 failed |
| absorb on ANY overlap (the refuted v1) | 3 failed |
| drop the old id from the fold | 1 failed |
skip the reindex in apply_merge |
3 failed |
claimed omits the canonical's own id/name |
1 failed |
| retarget but don't strip the alias | 3 failed |
Two corrections to my earlier claims the reviewer forced. My mutation report said skipping the reindex was caught — the realistic form (keep main's incremental update) survived; it's caught now. And claimed omitting the canonical's id/name survived because my test only asserted "nothing was deleted"; it now asserts the contradiction is actually resolved, which is the property that matters.
Confirmed sound, and worth recording
_reindexis behaviour-preserving forload_registry: on a pre-existing shared alias, main's incremental build and_reindexproduce identical maps.- The phantom-entity resurrection across save/load that main had is genuinely fixed.
by_aliasis written in exactly one place now; no sibling writer missed.- Absorption is one-hop and independent of
reg.entitiesiteration order (claimedis computed before the loop).
Recorded, not fixed
- Registries already damaged by main are not repaired. A file written by the old
approvestill holds two entities claiming one alias and still resolves by cid sort order. Only future merges are correct. Amerges doctorthat reports contradictions would be the honest remedy — backlog. - A contradiction a human hand-authors between entities no merge touches still resolves by cid order at load. Separate, pre-existing.
- Two contradictory proposals approved in one run are still last-wins. Deterministic given the human's approval order rather than an arbitrary sort, which is an improvement, but arguably it should refuse.
graph 47 (+7) · clean 256 · corpus 48 · slack 13 · fetch 33 · answer 66 · benchmark 3, bare pytest (CI mode) · ruff clean · scorecard 25/25.
…e store (#44) * fix(facts): a doc re-judged `minimal` left its previous numbers in the store factstore states the contract at the top of the file: "Idempotent per document: a reprocess REPLACEs the document's observations atomically". The `skipped` path enforces it explicitly — "a doc downgraded to noise must not leave stale numbers behind" — but that is one of two ways a pass can produce no facts. A prose document re-judged `minimal` takes the other: the extraction branch declines, `fact_rows` stays None, and neither replace_facts nor delete_facts runs. The previous pass's rows stay in the store carrying a page_path that points at a page which no longer contains those figures — so the answer layer can serve a number whose own citation does not support it, which is precisely what the trust layer exists to prevent. The clear is deliberately conditional on the extractor being CONFIGURED. Producing no rows because the document's content declined is a statement about the document; producing none because CLEAN_FACTS_PROSE is switched off is a statement about the config, and a config change must not silently wipe the store. Those rows go stale instead, which turning the extractor back on repairs — deletion would not. I narrowed it to that before the gate had to: the first version cleared whenever no rows appeared, which would have made turning the extractor off destroy every fact it had ever extracted. That is the same over-reach pattern as PR #42, where absorbing on any name overlap deleted curated records. Both directions pinned. `test_process_one_minimal_representation_clears_stale_facts` mirrors the `skipped` sibling and fails today (the pass-1 rows survive); `test_switching_the_extractor_off_does_not_wipe_the_store` fails against the over-broad version. Mutation-tested: no clear at all, clear-whenever-no-rows, and checking the wrong processor each fail exactly one. clean 258 (+2). scorecard 25/25 with all three facts metrics unchanged, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(facts): the rule is what the PAGE can support, not whether the extractor is on Gate review confirmed the behaviour but demolished the reasoning, and the reasoning was load-bearing — it decided the condition. I claimed `minimal` means "the content no longer carries facts". agents.py says the opposite: `minimal` is "raw data without narrative, or a low-value doc -> pointer (title + type + source link)" — the numbers-heavy class. The SOURCE may still be full of figures; it is the PAGE that stops carrying them. Since every row cites a page via page_path, the actual invariant is that a pointer page cannot support a figure. That reframing fixes the condition. It is not "is the extractor configured" — config has nothing to do with what a page contains — it is `representation == "minimal"`. Which also resolves an incoherence the reviewer found and I had shipped: with CLEAN_FACTS_PROSE off (a reachable config), the `skipped` path I cite as precedent deletes unconditionally, while my carve-out refused to. Either both gate on config or neither does. Neither does: a skipped doc has no page at all, a minimal doc has a pointer page, and neither can support a citation. Same reasoning, one step apart. The full/digest-with-extractor-off case is still deliberately untouched, now for a reason that survives inspection: that page carries its narrative and supports its rows. Deleted a false claim from the shipped comment. I wrote that config-off rows are "recoverable by turning it back on" — verified false: classify_pending keys on the content hash and never on a config change, so flipping the extractor back on re-runs nothing. The comment now says they go stale until the document's bytes change, which is what happens. Also from the review: the deletion was the only destructive facts operation with no audit trail. It now reports `cleared` and main surfaces it on the pass line. And the sheet half of my old condition was unreachable — the extraction guard is byte-identical to it — so it is gone rather than left as dead code with a comment. Two mutants that previously survived now fail: the carve-out applied config-gating (my own earlier version is now a failing mutant), and `if fact_rows:` instead of `is not None`, which would silently stop clearing when an extraction runs and keeps nothing. The latter is pinned by a new sheet test, since the reviewer showed the suite did not cover it. Noted, not claimed: the golden evals cannot exercise this branch at all — the fake backend never emits `representation="minimal"` — so "scorecard unchanged" is true but not evidence. The two unit tests are. clean 259 (+3). scorecard 25/25, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ninth iteration of the autonomous
bughuntsweep. A human-approved identity decision, silently undone by the next load.Bug
apply_mergefolded the absorbed names into the new canonical but left the entity that already owned them in place.save_registrythen wrote a file where two entities each claim the same alias, andload_registryresolves that by last-writer-wins overentitiesiteration order — which, because save sorts by cid, is alphabetical cid order rather than the human's decision:A human approved a merge into
globex; the next load silently undid it.The other sort direction is not better — the merge stands, but the curated entity's own display name is orphaned from its alias and
build_entitiesemits two nodes for what a human declared one entity. ADR 008 wants identity decisions "auditable in one file's git history"; a file whose meaning flips on cid sort order is neither auditable nor decided.Fix
The resolution depends on what the merge claimed, because this file is human-owned and removing a record needs more warrant than one shared string:
Its old id is folded in on absorption.
normalizekeeps hyphens, so the slugglobex-industriesis a different key from the display nameglobex industries— dropping it would stop a reference written as the old id from resolving, and an absorbed entity should keep answering to everything it used to.My first version absorbed on any overlap, and the gate refuted it. Two genuinely distinct entities can share a short alias, and it deleted one of them:
That folded a curated record's id, display name, type and every other alias into an unrelated entity, and vanished its graph node. Three more defects came from the same over-reach: a merge with
absorbs: []still deleted an entity; several proposals made the surviving identity whichever ran last (main was order-independent); and the canonical inherited the absorbed entity's other aliases, so a third entity claiming those recreated this very bug one hop out.Nothing destructive is silent.
apply_mergetakes alogcallable andmerges approvepassesprint, because absorbing or retargeting somebody else's entity is a change the approver did not name:by_aliasis now derived in one place (_reindex), used by bothload_registryandapply_merge. Built incrementally it kept entries pointing at ids that no longer exist — andmerges approveapplies every chosen proposal against one in-memory registry before saving once, so a later proposal would reason about a phantom entity. That is pinned in memory, not only across a save/load, because it is the live path:Test
All three new tests confirmed to fail with the source change stashed.
test_apply_merge_absorbs_an_already_registered_entity— the headline case, asserted both in memory and across a save/load round trip, including that the old id still resolves.test_apply_merge_absorbing_the_other_direction_also_settles— the mirror, where the absorbed id sorts first: the merge "survived" on main but left a contradictory file, so both names must resolve to the approved canonical.test_apply_merge_leaves_unrelated_entities_alone— the guard against over-absorbing: an entity sharing no name with the merge keeps its id, aliases and resolution. Without it, "absorb everything" would pass the other two.Mutation-tested, all six caught — including the refuted v1 design itself (absorb on any overlap: 3 failures), never absorbing (4), dropping the old id (1), skipping the reindex (3),
claimedomitting the canonical's own id/name (1), and retargeting without stripping (3).Two of those initially survived and both were my tests' fault rather than the code's: the realistic reindex mutation (keep main's incremental update, don't refactor) passed because my first tests all went through
save/load, which reindexes anyway; andclaimedomitting the canonical's own id/name passed because I asserted "nothing was deleted" rather than "the contradiction is resolved".graph 47 (+7) · clean 256 · corpus 48 · slack 13 · fetch 33 · answer 66 · benchmark 3 — bare
pytest(CI mode). ruff clean; golden scorecard 25/25, including thegraph: canonical entity nodesmetric.Scope note
The existing
test_apply_merge_and_save_roundtriponly ever merged into an empty registry, which is why this survived: the absorbed-existing-entity case was the untested half of the same function. Its idempotent-re-apply assertion still holds unchanged.Deliberately not fixed
approveis not repaired. It still holds two entities claiming one alias and still resolves by cid sort order; only future merges are correct. Amerges doctorthat reports contradictions would be the honest remedy — backlog.