Skip to content

fix(build): excuse an exact duplicate dedup fold from the shrink guard - #4953

Closed
ayushcodes10 wants to merge 2 commits into
Graphify-Labs:v8from
ayushcodes10:fix-shrink-guard-dedup-excuse
Closed

ayushcodes10 wants to merge 2 commits into
Graphify-Labs:v8from
ayushcodes10:fix-shrink-guard-dedup-excuse

Conversation

@ayushcodes10

Copy link
Copy Markdown
Contributor

Fixes #4940.

Bug

During an incremental semantic --update, build_merge (via build) can replace a re-extracted file's nodes and then run deduplicate_entities's exact-duplicate pass, which collapses a same-label, same-file near-duplicate pair onto one survivor id. When the saved node's id is the one folded away (not the survivor), to_json's #479 shrink guard — specifically _lost_saved_semantic_nodes — has no way to know the concept actually survived under a different id. It reports the saved id as missing and refuses the write outright, even though nothing was lost. Retrying re-extracts the same unchanged documents and refuses identically.

The accounting for this already half-exists: build() computes _dedup_collapsed (the total node-count drop from dedup) and stashes it on G.graph, and to_json calls take_shrink_accounting(G) to pop it — but the call site in export.py discards the return value entirely (confirmed: take_shrink_accounting has exactly one other caller in the whole codebase, cli.py, and neither ever consumes the dict needed to exclude a specific id). The count was never wired into the _lost_saved_semantic_nodes check that actually produces the refusal.

No existing issue or PR covers this; found via the issue tracker (#4940, well-documented with an exact repro shape). Checked PR #3623 (open, "excuse dedup-explained node drops from the #479 shrink guard") — it addresses a different mechanism (build_from_json's #1145 ghost-merge count, _ghost_dedup_count), not deduplicate_entities's exact-duplicate remap. No overlap.

Fix

  • dedup.py: deduplicate_entities gains an optional node_remap: dict[str, str] | None out-param, filled in place with every merged id's survivor id — mirroring the existing hyperedges out-param precedent exactly ("Optional and mutating rather than returned so existing two-tuple callers are unaffected").
  • build.py: build() passes a fresh dict as node_remap= and stashes it on G.graph["_dedup_node_remap"] next to the existing _dedup_collapsed. take_shrink_accounting now pops and returns it too (3-tuple; its one other caller, cli.py, is updated to match). _lost_saved_semantic_nodes gains a dedup_remap parameter: a saved id whose remap target is present in the new graph is excused (and recorded into folded, so downstream node-count math stays consistent), checked before the existing AST-twin/file-twin fold checks.
  • export.py: to_json now captures the remap from take_shrink_accounting and passes it through to _lost_saved_semantic_nodes.
  • cli.py: the extract CLI's own early take_shrink_accounting(G) call pops this map before to_json runs; when to_json's own internal guard will still execute (force=False, the partial-extraction path), the map is put back onto G.graph first so to_json can retrieve it again.

Checked watch.py's _rebuild_code/_check_shrink (the --update incremental path) separately — that path writes the raw merged extraction directly, never through build_from_json/deduplicate_entities, so this specific gap doesn't apply there.

Tests

Added to tests/test_lost_saved_semantic_nodes.py:

  • test_dedup_remap_target_present_is_not_lost / test_dedup_remap_target_absent_is_still_lost — the _lost_saved_semantic_nodes unit-level behavior.
  • test_no_dedup_remap_given_is_unaffected — control confirming the existing no-remap call shape (every other caller) is unchanged.
  • test_to_json_writes_after_exact_duplicate_dedup — end-to-end with build() + to_json(), reproducing the issue's exact shape (a saved concept re-extracted alongside a same-label, same-file near-duplicate under a shorter id, collapsed by the exact-duplicate pass).

Verification

  • Reproduced the bug with the issue's exact message shape (Deduplicated 1 node(s) (1 exact). / Refusing to overwrite — saved semantic node(s) are missing... / graph.json written: False), then confirmed the fix resolves it (graph.json written: True).
  • Confirmed all 4 new tests fail against the unmodified code (either a genuine assertion failure or the expected TypeError from the not-yet-existing dedup_remap parameter) and pass against the fix; restored byte-identical afterward.
  • Ran tests/test_dedup.py (153), tests/test_merge_sweeps_reextract_orphans.py (the only other take_shrink_accounting caller, uses index-based unpacking so the 3-tuple doesn't break it), and the CLI-level shrink-guard tests (test_incomplete_build_guard.py, test_hollow_chunks_arm_shrink_guard.py, test_dedup_shrink_refuses_force_write.py) — all pass.
  • Full suite: 3 failed, 9314 passed, 83 skipped, 1 xfailed — the 3 failures are the pre-existing flakes in test_erlang_extractor.py, test_r_extractor.py, and the skill backtick test, unrelated to this change. (An earlier run under host memory pressure showed spurious unrelated failures — a clean rerun after the pressure cleared confirmed this baseline.)
  • python3 -m tools.skillgen --check: check OK: 134 artifact(s) match committed output and expected/.

build merges a saved semantic node under a different id than it was
saved under whenever this run's extraction re emits a same label,
same file near duplicate that the exact duplicate dedup pass then
collapses onto a shorter or richer survivor id. The saved id still
names a real concept, just one that now lives under a different id,
but the shrink guard's lost saved node check had no way to tell that
apart from a node genuinely dropped, since dedup never exposed which
id it folded into which. A healthy merge was refused outright, and
retrying only re extracted the same unchanged documents and refused
again.

Added an optional node remap parameter to the entity deduplication
pass, filled in place the same way the existing hyperedge parameter
already is so every two tuple caller stays unaffected. build stashes
the filled map on the graph next to the existing dedup collapsed
count, and the shrink guard's lost saved node check now accepts it
and excuses a saved id whose remap target survived in the new graph,
recording the fold the same way an AST twin fold already is so a
caller's own node count math stays consistent either way. The CLI's
own early read of this accounting puts the map back before its own
call into the guard, since that path pops the accounting before the
guard would otherwise see it.
Covers the lost saved node check directly, both with a remap whose
target survived and one whose target did not, plus a control
confirming the existing no remap call shape is unaffected. Also
covers the full build and write path end to end with the exact
duplicate dedup scenario reported: a saved concept re emitted under
a second, shorter id this run, collapsed onto that shorter id by the
exact duplicate pass, writing successfully instead of being refused.
@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

Thanks for the pull request, @ayushcodes10. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 6 changed function(s) (2 sampled, not proven, 3 not verified).

Formal verification. 1 change(s) alter behavior, breaking input(s) attached. PR-changed functions: 3/6 verified (0 proven, 2 may-equivalent, 1 distinguished) · 3 not verified (3 vacuous).

Behavior changes: take\_shrink\_accounting changes behavior, here is the input that shows it.

The verifier found a concrete input on which take\_shrink\_accounting behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"''"\}, the old code produced \(0, 0\) but the new code produces \(0, 0, \{\}\) (outputs first differ at character 6). Paste that input straight into a regression test.

Not verified on this run: build (vacuous: never exercised), dispatch\_command (vacuous: never exercised), deduplicate\_entities (vacuous: never exercised).


Graphify review — findings

Fixes the false "lost saved nodes" refusal in to_json when exact-duplicate dedup folds a saved node into a same-label, same-file survivor under a different id. deduplicate_entities now fills a caller-supplied node_remap, build stashes it on the graph, and _lost_saved_semantic_nodes excuses a saved id only when its mapped survivor is actually present in the new graph, so a stale remap still reports a genuine loss. take_shrink_accounting now returns a third value, the remap, which changes the contract for every caller that unpacked two. The lookup is a single hop, so a survivor that was itself refolded later still counts as lost.

Worth a look

  • take_shrink_accounting return arity changed from 2 to 3 — graphify/build.py:2209 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2343 functions depend on the 374 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: build_from_json() — 300 callers, 23 callees
  • worse: to_json() — 90 callers, 11 callees
  • worse: build() — 87 callers, 6 callees
  • worse: _lost_saved_semantic_nodes() — 20 callers, 6 callees

Verification — 2343 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2046 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

166 of 489 test file(s) selected (34%) via static blast radius.

  • tests/test_4874_jvm_resolution.py — impact
  • tests/test_affected_cli.py — impact
  • tests/test_affected_path_file_node.py — impact
  • tests/test_affected_seed_reference_forms.py — impact
  • tests/test_agents_platform.py — impact
  • tests/test_alias_folding.py — impact
  • tests/test_analyze.py — impact
  • tests/test_atomic_canvas_export.py — impact
  • tests/test_atomic_writes.py — impact
  • tests/test_backend_opt_in.py — impact
  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_benchmark_real_query.py — impact
  • tests/test_build.py — impact
  • tests/test_build_located_semantic_identity.py — impact
  • tests/test_build_merge_dedup_scope.py — impact
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.py — impact
  • tests/test_c_include_resolution.py — impact
  • tests/test_carried_hyperedge_remap.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_claude_cli_backend.py — impact
  • tests/test_cli_export.py — impact
  • tests/test_cloud_cta.py — impact
  • tests/test_cluster.py — impact
  • tests/test_codebuddy.py — impact
  • tests/test_community_labels_skill.py — impact
  • tests/test_compact_graph_json.py — impact
  • tests/test_confidence.py — impact
  • tests/test_confidence_score_output.py — impact
  • tests/test_corrupt_graph_json.py — impact
  • tests/test_cpp_objc_cross_file_calls.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_cross_repo_external_call_guards.py — impact
  • tests/test_csharp_method_owner_labels.py — impact
  • tests/test_csharp_object_creation.py — impact
  • tests/test_dedup.py — impact
  • tests/test_dedup_remaps_hyperedges.py — impact
  • tests/test_dedup_shrink_refuses_force_write.py — impact
  • tests/test_dedup_survivor_richness.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_devin.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_edge_also_seen.py — impact
  • tests/test_evidence_binding.py — impact
  • tests/test_explain_cli.py — impact
  • tests/test_explain_same_file_tie.py — impact
  • tests/test_export.py — impact
  • tests/test_export_control_characters.py — impact
  • … and 116 more

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Behavior changes: take\_shrink\_accounting changes behavior, here is the input that shows it.

The verifier found a concrete input on which take\_shrink\_accounting behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"''"\}, the old code produced \(0, 0\) but the new code produces \(0, 0, \{\}\) (outputs first differ at character 6). Paste that input straight into a regression test.

No difference found (not proven): No behavior difference found in \_lost\_saved\_semantic\_nodes (not a proof).

The verifier ran both versions of \_lost\_saved\_semantic\_nodes on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify build.

The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 40 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify deduplicate\_entities.

The verifier did not have enough to check deduplicate\_entities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 11 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

No difference found (not proven): No behavior difference found in to\_json (not a proof).

The verifier ran both versions of to\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

· 4 more finding(s) on lines outside this diff (see the check run).

@safishamsi

Copy link
Copy Markdown
Member

Closing in favour of #4952, which shipped in 0.9.101. Same core idea (thread the dedup merge map into the write guard). #4952 also fixes the net-count check: when N saved ids fold into one new (non-saved) survivor, the shrink is the expected N-1, which this PR still refused. Thanks for the fix on #4940.

@safishamsi safishamsi closed this Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shrink-guard refuses a valid merge when exact-duplicate dedup drops nodes the saved cache references

2 participants