fix(retain): delete the removed chunks when a re-retain only removes content - #3676
Conversation
…content Delta retain classifies a replacement body against the stored chunks as unchanged / changed / new / removed. Dropping the tail of a document and leaving the rest byte-identical yields `changed=0 new=0 removed=N`: nothing to extract, but something to delete. `_try_delta_retain` short-circuited to the metadata-only path as soon as the EXTRACTION list came back empty, which threw the non-empty `removed` set away. The document row was advanced to the shrunken body and nothing was deleted, so the removed sections' chunks and facts stayed recallable as part of a document that no longer contained them — and the state was stable, because the stored content_hash now matched the new body, so re-submitting it was a no-op that never revisited the leftovers. The same deletion combined with any other edit was always correct (the write path deletes `removed` alongside the changed chunks it rewrites), which is what kept this invisible: it takes a pure deletion to hit. Bail out to metadata-only only when there is nothing to WRITE, not merely nothing to extract.
|
Reviewed — correct and complete, and the localisation is the part worth stating. Guard audit. There are four bailouts to
So the asymmetry was isolated to one guard, and this fixes it without disturbing 3340 — which is the one an over-eager "make them consistent" pass would have broken, since it deliberately drops a non-empty removed set. Two things I checked and found clean:
On the test. Asserting through One optional addition: a line where Worth noting for the record: I was in this function earlier fixing a stale comment at the top of it and read straight past this. A paired store-vs-Postgres sweep would not have found it either — it fails identically on both backends, so it is invisible to a differential method. It takes a pure deletion to hit, because any other edit masks it. |
The bug
Delta retain classifies a replacement body against the stored chunks as
unchanged / changed / new / removed. Delete the tail of a document and leave the restbyte-identical, and the diff is
changed=0 new=0 removed=N: nothing to extract, butsomething to delete.
_try_delta_retainbailed out to the metadata-only path as soon as the extraction listcame back empty:
So the document row advanced to the shrunken body and nothing was deleted. The removed
sections' chunks and facts stayed recallable as part of a document that no longer contained
them — and the state was stable: the stored
content_hashnow matched the new body, sore-submitting the same document was a no-op that never revisited the leftovers.
Measured on a 10-section document re-retained with the last 4 sections dropped:
retain_batch_tokensretain_batch_tokens(sliced transport)The same deletion combined with any other edit was always correct — the write path deletes
removed_indicesalongside the changed chunks it rewrites — which is what kept thisinvisible. It takes a pure deletion to hit. That also makes deleting a section from the
middle correct already: the removal shifts every later chunk, so delta has changed chunks
to extract and takes the write path.
The fix
One condition. Bail out to metadata-only only when there is nothing to write, not merely
nothing to extract:
The write path already deletes
removed_indiceswhether or not it also has facts to insert,and both write paths (Postgres and the store-owned Protocol-B one) handle an empty fact set:
extract_facts_from_contents([])returns before any LLM call,insert_facts_batchinsertsnothing, and
_store_document_bodiesre-puts the full new chunk set. A removal-onlyre-retain therefore costs zero extraction — asserted below.
Tests
tests/test_delta_removed_chunks.py:test_pure_deletion_drops_the_removed_chunks[within_budget]— fails onmaintest_pure_deletion_drops_the_removed_chunks[oversized]— fails onmaintest_pure_deletion_only_deletes— the surviving chunks are byte-identical, so aremoval-only delta must not send anything back through fact extraction (guards the other
direction: letting the write path run must not turn this into a full re-ingest)
test_unchanged_re_retain_still_takes_the_metadata_only_path— a no-op re-retain stillextracts nothing and keeps every fact
test_middle_deletion_still_drops_the_removed_section— control for the case that wasalready correct
Verification
main(the two repro cases).pytest tests/ -k "retain or delta or chunk or document or append or observation or recovery or oversized":979 passed. The 2 failures / 29 errors are pre-existing and environmental
(
HINDSIGHT_API_LLM_API_KEY— real-LLM tests); each was confirmed failing identically onclean
main.ruff check/ruff format --check/ty check hindsight_api/clean;check-unused.shreports nothing new.
Relationship to #3666
#3666 targets the same area but was branched before #3660, which had already fixed the
symptoms it describes (stale-fact accumulation and full re-extraction on an oversized edit
— 4 of its 5 tests pass unmodified on current
main). Its remaining test is the shrinkingtail, which is this bug.
#3666's approach — dropping
removed_indicesfor every sliced retain — should not bemerged: on its own head it deletes content that is still in the document. Re-retaining a
10-section oversized document with section 0 edited and section 3 deleted:
Suggest closing #3666 in favour of this.