Repository navigation
Fix chunk-tracking reseed: distinguish absent row from present-but-empty row (#3609) - #3660
Conversation
…pty row (HKUDS#3609) aedit_entity/aedit_relation decided whether a chunk-tracking row existed using the truthiness of its chunk_ids list, collapsing 'row absent' (never migrated) and 'row present but empty' (authoritative: tracks no chunks) into one branch that then reseeds existing_full_chunk_ids from the graph node's source_id. Since source_id may still name chunks a previous purge pruned, an edit could write stale attribution back into a curated empty row. Test row presence (isinstance(dict) and 'chunk_ids' in row) instead of list truthiness, and reseed from source_id only when no row exists. Applied to both the entity and relation blocks. Adds a regression test that persists {'chunk_ids': [], 'count': 0} next to a stale graph source_id and asserts a description-only edit does not repopulate it.
|
Thanks for picking this up. I reviewed the patch and verified it empirically against What I verified as correct
Blocking: the third call site is untouched, and it re-arms the reseed you just fixedThis was point 2 of my comment on #3609 and it needs to be folded into this PR. await relation_chunks_storage.delete([old_storage_key])
if relation_chunk_ids: # <-- line 710: a curated empty row is silently not migrated
await relation_chunks_storage.upsert({new_storage_key: {...}})The presence test above it ( I probed this on your branch, not on
Stale attribution resurrected into the authoritative store — the same outcome the PR is titled after. This migration path is independently reachable through the HTTP API's entity-rename endpoint. The two sites you fixed can also receive Also needs addressing
Finally, per the second acceptance item on the issue: if you conclude any remaining truthiness check is safe as-is, annotate it with why, so the next reader does not have to redo this analysis. One process note#3609 was claimed by @Solaris-star and I gave an explicit go-ahead there before this PR was opened. That is not a reason to close this one — but please coordinate on the issue thread so the two of you are not duplicating the remaining work. Generated by Claude Code |
…HKUDS#3609) Addresses the blocking review on HKUDS#3660: the entity-rename relation-key migration in utils_graph.py still re-armed the reseed the edit paths now guard against. - utils_graph.py: when migrating a relation chunk-tracking row to its new key during rename, migrate a present-but-empty row ({"chunk_ids": [], "count": 0}) instead of dropping it. The old `if relation_chunk_ids:` guard deleted the row under the old key and skipped the upsert for an empty row, making it absent — so the next relation edit reseeded it from a possibly-stale source_id. Now the write is skipped only when the row was genuinely absent AND source_id yields nothing. This is the one path reachable via the HTTP API (entity rename). - Tests (tests/pipeline/test_doc_status_chunk_preservation.py): * rename migrates a curated empty relation row (fails without the fix above) * absent-row pin: an absent tracking row is still seeded from source_id on edit * a row of {} (present, no chunk_ids key) is treated as absent/unknown - lightrag.py: annotate the two _purge_kg_contributions sites (entity + relation) that intentionally keep truthiness semantics, pointing at HKUDS#3609 — applying the presence check there is a deletion-behavior change that needs its own issue.
|
Thanks for the thorough review and for verifying it empirically — the behavioral fix-proof is exactly the bar I wanted to hit. Pushed Blocking: third call site (relation-key migration on rename). Folded in. In Tests. Added the three you asked for, all in
PR description.
Point 4 ( Remaining truthiness checks. The one truthiness check I kept in the migration is annotated with why it's safe (a curated empty row is a non-empty dict → truthy → treated as present; Suite. Coordination. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f8dd3b07d4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…iness Per @codex on HKUDS#3660: the rename relation-key migration classified the old row with `bool(old_stored_data) and isinstance(old_stored_data, dict)`, so a legacy/partial shape like `{"count": 0}` (no `chunk_ids` key) was treated as *present* and persisted as an authoritative empty row at the new key — the next edit would then preserve that empty list and the relation would lose its live chunk attribution. Use the same presence check as the entity/relation edit paths (`isinstance(row, dict) and "chunk_ids" in row`), so only a curated `{"chunk_ids": [], "count": 0}` counts as present (and is migrated as-is), while `{}` / `{"count": 0}` / absent rows are reseeded from the edge's `source_id`. Adds a regression test with a `{"count": 0}` relation row and an edge whose `source_id` names a live chunk: after rename the new-key row must be reseeded from `source_id` (not persisted empty). It fails on the previous truthiness check and passes now.
|
@danielaskdd @codex good catch — fixed in df4833b. The rename migration now classifies the old relation row by the same presence check the entity/relation edit paths use —
Added a regression test ( |
|
To use Codex here, create an environment for this repo. |
|
@codex review |
|
Codex Review: Didn't find any major issues. Breezy! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Thanks for the update. I re-reviewed the current head
|
…n branch Per @danielaskdd's re-review of HKUDS#3660: 1. (blocking) The presence predicates classified `{"chunk_ids": null}` as a present/authoritative row, then iterated `None` -> `TypeError` (a 500 after partial mutation) whenever the tracking branch was entered (source_id edits, rename migration). Test the *schema* at all three sites — `isinstance(row, dict) and isinstance(row.get("chunk_ids"), list)` — so null and other malformed non-list shapes follow the unknown/reseed path. 2. Fold in the same fix for the relation branch of `_merge_entities_impl`: a partial row like `{"count": 0}` is now reseeded from the edge's live `source_id` on merge instead of being persisted as an authoritative empty row at the merged key. Adds regression tests: renaming an entity with a `{"chunk_ids": null}` row (crashed before, now reseeds) and merging a relation whose row is `{"count": 0}` (now reseeds from source_id). Full chunk-preservation suite: 34 passed.
|
Thanks for the careful re-review — both addressed in d11ba1b. 1 (blocking) — validate the value, not just the key. All three predicates (entity edit 2 — folded in. Aligned the Full |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d11ba1b900
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Treat malformed relation tracking rows with non-list chunk_ids as absent so source_id edits reseed them instead of failing after graph and vector mutations. Add a regression test for null chunk_ids on relation edits. Refs HKUDS#3609
|
@codex review |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…n/merge, crash-safe key migrations Follow-up to the HKUDS#3609 fix (PR HKUDS#3660), closing the remaining paths where a present-but-empty chunk-tracking row could be destroyed or reseeded from a possibly-stale graph source_id: - Extract the row-presence predicate into utils.has_chunk_tracking_row() and adopt it at every call site (entity/relation edit, rename migration, relation-key migration on rename, entity merge, ingestion merges), so the absent-vs-empty distinction is defined once. - operate.py ingestion merges (_merge_nodes_then_upsert, _merge_edges_then_upsert, and the edge-endpoint node update): fall back to the graph source_id only when NO tracking row exists, never when the row is present but empty. Previously an ordinary document insert could resurrect purged chunk ids into the authoritative store — the most reachable variant of the original defect. - _merge_entities_impl step 9: decide row presence by schema instead of list truthiness, and upsert the target row whenever any source had one. Merging entities whose rows were all curated-empty used to leave the target with an ABSENT row, re-arming the reseed on the next edit via the HTTP-reachable merge endpoint. Also upsert the target row before deleting the source rows. - Rename key migrations (_edit_entity_impl, entity row and relation rows): upsert the new key BEFORE deleting the old one. On RPC-backed KV storages each call commits independently, so the old order opened a crash window in which the row existed under neither key — turning a curated row absent. An orphaned old-key row is dead bookkeeping; a lost row re-arms the stale reseed. New regression tests (all fail without the fixes): - tests/utils/test_has_chunk_tracking_row.py — predicate truth table - tests/extraction/test_chunk_tracking_presence_ingestion.py — the three operate.py sites, with absent-row direction pins - tests/extraction/test_merge_entities_chunk_tracking.py — curated-empty preservation through merge + upsert-before-delete ordering - tests/extraction/test_rename_tracking_migration_ordering.py — rename migration ordering for entity and relation rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z1LA6qjSSkjXmyzxdSnTx
… crash-safe relation-row migration Review follow-up on the previous commit's _merge_entities_impl step 9 fix, which had a mixed-state defect: it collected chunk ids only from inputs that HAD a tracking row, then wrote the target row whenever ANY row was present. With input A holding an authoritative empty row and input B holding no row at all, the merge wrote an authoritative empty target row — promoting "partially known" to "all empty" and discarding B's attribution with no recovery path (the empty row is authoritative, so later edits never reseed it from the merged node's source_id). Step 9 now decides presence PER INPUT ENTITY: - a usable row (has_chunk_tracking_row) is used as-is, including an authoritative empty list; - an absent/malformed row falls back to THAT entity's own pre-merge graph source_id (captured in steps 1-2, before step 5 overwrites the target node with the merged payload — the merged source_id unions every input's chunks and must not be attributed to a single input); - the ordered union is written only when at least one usable row exists (target upsert still precedes source-row deletes); - with no row present at all the target row stays absent (unknown stays unknown — a fabricated row would promote UNKNOWN to authoritative). Also closes the adjacent crash window in the same function's relation-row migration: it deleted every old relation key BEFORE upserting the migrated rows, so a crash between the two calls lost them all on RPC-backed KV storages. The upsert now commits first, and only keys outside the new key set are deleted — a key can be both old and new (relations already attached to an existing target keep their key), and deleting it after the upsert would drop the just-written union. New regression tests (all fail on the previous implementation; the three prior guarantees — curated-empty preservation, unknown-stays-unknown, target-before-source ordering — still pass on both): - mixed authoritative-empty + absent row: target gets only the absent input's graph chunks; - mixed valid + malformed row: target gets tracking union graph fallback; - existing target without a row: its pre-merge source_id contributes; - relation rows: upsert precedes delete, and a shared old/new key is never deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z1LA6qjSSkjXmyzxdSnTx
|
Summary of the two review follow-up commits @danielaskdd merged into this branch (ecffba1, f86ef93), which extend the fix beyond the edit/rename paths this PR originally covered. What changedShared predicate — Ingestion merge paths in
Previously, merging entities whose rows were all curated-empty left the target with an absent row, re-arming the reseed on the next edit through the HTTP-reachable merge endpoint. Crash-safe key migrations (ecffba1, f86ef93) VerificationNew regression tests: PR description is now partially stale
Generated by Claude Code |
|
@codex review |
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
What
Fixes #3609.
aedit_entityandaedit_relationdecided whether a chunk-tracking row existed using the truthiness of itschunk_idslist:That collapses two states that mean different things — row absent (never migrated / unknown) and row present but empty (
{"chunk_ids": [], "count": 0}— authoritative: this object tracks no chunks) — into the same branch, which then reseedsexisting_full_chunk_idsfrom the graph node'ssource_id. Sincesource_idmay still name chunks a previous purge pruned, an ordinary edit can write stale attribution back into a curated empty row.This is the call-site version of the same class of bug that #3591 hardened inside
compute_incremental_chunk_ids, and it mirrors the "test row presence, not list truthiness" ruleAGENTS.mdalready states for recovery anchors (the defect behind #3400).Change
chunk_idslist — instead of list truthiness, and reseed fromsource_idonly when no usable row exists at all. Legacy/partial shapes ({},{"count": 0}) and malformed ones ({"chunk_ids": null}) count as absent/unknown. The predicate lives in one shared helper,utils.has_chunk_tracking_row()(with the authority-model docstring), adopted at every call site: the entity and relation edit blocks, both rename migrations, the entity-merge block, and the ingestion merges.utils_graph.py, the rename branch): a present-but-empty relation row is now migrated to the new key instead of being dropped. Previously theif relation_chunk_ids:guard deleted the row under the old key and skipped the upsert for an empty row, making it absent — which re-armed the exact reseed this PR fixes, on the next relation edit. The upsert is now skipped only when the row was genuinely absent andsource_idyielded nothing.operate.py:_merge_nodes_then_upsert,_merge_edges_then_upsert, and the edge-endpoint node update inside it): fall back to the graphsource_idonly when no tracking row exists, never when the row is present but empty. Before this, an ordinary document insert could resurrect purged chunk ids into the authoritative store — the most reachable variant of the defect._merge_entities_implstep 9 (originally listed as out of scope below; folded in after review): row presence is decided per input entity — a usable row is authoritative as-is, including an empty list; an input with an absent/malformed row falls back to its own pre-merge graphsource_id(captured before step 5 overwrites the target node with the merged payload, whosesource_idunions every input); the ordered union is written only when at least one usable row exists; with none present the target row stays absent. One entity's curated-empty row proves only that that entity owns nothing, so "partially known" is never promoted to "all empty" — and merging all-curated-empty rows now leaves the target a present empty row instead of an absent one.Reachability
To be precise about scope (per the issue, filed "for verification rather than as a confirmed bug"):
aedit_entity/aedit_relationwithsource_idinupdated_data) are Python-API / partial-write reachable — the HTTP layer does not exposesource_id. The regression test for them reproduces the reseed from directly seeded state; it protects an invariant, it is not a confirmed user-visible incident.amerge_entities) and the document-insert ingestion merges are likewise reachable (HTTP merge endpoint / ordinary inserts); both are covered by the review follow-up commits (ecffba1, f86ef93).Deliberate behavior note
When a present-but-empty row coexists with a
source_idchange, genuine additions (new - old) are now appended to the empty baseline rather than the row being reseeded fromsource_idfirst. This is intentional and matches the authority model (a curated empty row is the truth; only real new chunks are added).Out of scope (noted, not changed here)
_purge_kg_contributions(lightrag.py, entity + relation classification) does the same absent-vs-empty conflation, but applying the presence check there is a deletion-behavior change (a present-empty row would stop falling back to the graphsource_idand instead route the object intoentities_to_delete). That needs its own issue + tests, so both sites are annotated with aNOTE(#3609)comment rather than changed.(An earlier revision also listed the
_merge_entities_implentity block here; that item has since been implemented — see the Change section above.)Testing
Initial submission:
./scripts/test.sh tests/pipeline tests/utils→ 827 passed, plus 2 failures that are pre-existing and unrelated to this change (test_chunk_options_regex_scrub::test_audit_line_bounds_the_untrusted_patternandtest_strict_capability_gate::test_gaps_are_warned_...— both aValueError: too many values to unpackfrom a version skew in my local env; they reproduce identically with this PR's changes reverted).Regression tests in
tests/pipeline/test_doc_status_chunk_preservation.py:test_edit_does_not_reseed_present_but_empty_chunk_tracking— present-but-empty entity row is not reseeded from a stalesource_id(fails onmain).test_rename_migrates_present_but_empty_relation_tracking_row— a curated empty relation row survives the key migration on rename (fails without the migration fix; verified by reverting onlyutils_graph.py).test_edit_seeds_chunk_tracking_from_source_id_when_row_absent— absent-row pin: an absent row is still seeded fromsource_idon edit (pins the direction the guard was narrowed).test_edit_treats_row_without_chunk_ids_key_as_absent— a{}row (present, nochunk_idskey) is treated as absent/unknown.Later review rounds added further cases to that file (legacy
{"count": 0}shapes, malformed{"chunk_ids": null}rows), plus four new test files (commits ecffba1, f86ef93):tests/utils/test_has_chunk_tracking_row.py— predicate truth table.tests/extraction/test_chunk_tracking_presence_ingestion.py— the threeoperate.pyingestion sites, with absent-row direction pins.tests/extraction/test_merge_entities_chunk_tracking.py— per-input-entity presence (mixed empty/absent/malformed states, existing-target merge) and upsert-before-delete ordering for entity and relation rows.tests/extraction/test_rename_tracking_migration_ordering.py— rename migration ordering for the entity row and migrated relation rows.Each behavioral test fails on the code as it stood before its commit; the absent-row pins pass on both sides, confirming the guards were narrowed, not widened. Follow-up verification:
tests/extraction+tests/utils+tests/api/routes+tests/pipeline→ 1584 passed (remaining failures are pre-existing sandbox-environment issues, identical on the unmodified head).ruff check/ruff formatclean./cc @Solaris-star — #3609 was assigned to you with @danielaskdd's go-ahead; happy to coordinate on the issue thread so we don't duplicate the remaining follow-up (the purge item above).
Description updated by Claude Code at the maintainer's request, to fold in the review follow-up commits (ecffba1, f86ef93).