Skip to content

Fix node loss during incremental deduplication - #3503

Closed
hopstreax wants to merge 5 commits into
Graphify-Labs:v8from
hopstreax:investigate/3477-build-merge-node-loss
Closed

Fix node loss during incremental deduplication#3503
hopstreax wants to merge 5 commits into
Graphify-Labs:v8from
hopstreax:investigate/3477-build-merge-node-loss

Conversation

@hopstreax

Copy link
Copy Markdown
Contributor

Summary

Fixes #3477, where build_merge() could silently drop nodes from files outside the scope of an incremental update.

The root cause was global entity deduplication collapsing duplicate nodes belonging exclusively to untouched files.

Changes

  • Add protected node IDs to incremental build_merge() deduplication.
  • Prevent untouched nodes from being merged with other untouched nodes.
  • Preserve untouched nodes as canonical survivors when incoming nodes deduplicate against them.
  • Prevent incoming nodes from transitively bridging multiple untouched nodes.
  • Apply the protection consistently across exact, fuzzy, and LLM-assisted deduplication.
  • Preserve existing full-build deduplication behavior.
  • Add regression tests for untouched duplicates, incoming/untouched merges, bridge cases, and normal incoming deduplication.

The existing shrink-guard and manifest behavior are intentionally unchanged and remain out of scope.

Tests

  • 237 passed, 1 skipped
  • git diff --check clean

@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 3 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Protects untouched existing nodes from being collapsed with each other during incremental merge (#3477): build_merge now passes the IDs of all existing graph nodes as protected_ids through build into deduplicate_entities. Across every dedup pass — exact file-group, cross-file, fuzzy MinHash/LSH, LLM tiebreak, and final survivor selection — nodes tracked via _get_prot/_union_with_prot will never union two distinct protected components, and mixed protected/incoming groups fold incoming nodes into at most one protected survivor while pure-protected groups are left entirely intact. When protected_ids is unset the original global dedup behaviour is unchanged.

Worth a look

  • build_merge now passes raw root instead of derived _eff_root to buildgraphify/build.py:2049 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Falsy existing node IDs are omitted from protected_idsgraphify/build.py:2034 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • build_merge no longer passes inferred effective root into buildgraphify/build.py:2046 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1379 functions depend on the 161 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _rebuild_code() — 115 callers, 51 callees
  • new: build_from_json() — 204 callers, 19 callees
  • new: deduplicate_entities() — 76 callers, 24 callees
  • new: build_merge() — 76 callers, 14 callees
  • new: to_obsidian() — 38 callers, 14 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: build() — 52 callers, 6 callees
  • new: _call_claude_cli() — 33 callers, 9 callees
  • …and 40 more — each is listed as a finding

Verification — 1379 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: 973 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

88 of 269 test file(s) selected (33%) via static blast radius.

  • tests/test_analyze.py — impact
  • tests/test_atomic_canvas_export.py — impact
  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_build.py — impact
  • tests/test_build_merge_dedup_scope.py — impact, changed-test
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.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_cluster.py — impact
  • tests/test_confidence.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_dedup.py — impact
  • tests/test_dedup_remaps_hyperedges.py — impact
  • tests/test_dedup_survivor_richness.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_evidence_binding.py — impact
  • tests/test_export.py — impact
  • tests/test_export_control_characters.py — impact
  • tests/test_export_path_length.py — impact
  • tests/test_extract.py — impact
  • tests/test_falkordb_integration.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_global_add_tag_inference.py — impact
  • tests/test_global_graph.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_god_nodes_exclude_hubs.py — impact
  • tests/test_hyperedge_member_shapes.py — impact
  • tests/test_hyperedge_roundtrip.py — impact
  • tests/test_hypergraph.py — impact
  • tests/test_image_vision.py — impact
  • tests/test_import_self_loops.py — impact
  • tests/test_issue_3472_source_file_collision.py — impact
  • tests/test_java_type_resolution.py — impact
  • tests/test_languages.py — impact
  • tests/test_llm_backends.py — impact
  • tests/test_llm_parser.py — impact
  • tests/test_llm_parser_reasoning.py — impact
  • tests/test_loose_sibling_import_resolution.py — impact
  • tests/test_manifest_ingest.py — impact
  • tests/test_merge_chunks_validation.py — impact
  • tests/test_merge_graphs_cli.py — impact
  • tests/test_multigraph_diagnostics.py — impact
  • … and 38 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

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: 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 build\_merge.

The verifier did not have enough to check build\_merge, 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: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

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: not verifiable: all 9 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)

Could not verify: Could not verify \_llm\_tiebreak.

The verifier did not have enough to check \_llm\_tiebreak, 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: not verifiable: all 200 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)

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

@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.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds a protected_ids set that flows from build_merge through build into deduplicate_entities, so during incremental merge untouched existing nodes are never collapsed with one another. Incoming nodes still fold into at most one protected survivor per exact/fuzzy/LLM pass, while protected-only groups are preserved verbatim and cross-protected-component bridging is blocked, tracked via a per-root prot_by_root map alongside union-find. When protected_ids is None, dedup behaves exactly as before with global merging.

Worth a look

  • Falsy existing node IDs are not protected during merge dedupgraphify/build.py:2034 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1384 functions depend on the 173 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _rebuild_code() — 115 callers, 51 callees
  • new: build_from_json() — 204 callers, 19 callees
  • new: deduplicate_entities() — 76 callers, 24 callees
  • new: build_merge() — 76 callers, 14 callees
  • new: to_obsidian() — 38 callers, 14 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: build() — 52 callers, 6 callees
  • new: _call_claude_cli() — 33 callers, 9 callees
  • …and 40 more — each is listed as a finding

Verification — 1384 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: 978 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

88 of 269 test file(s) selected (33%) via static blast radius.

  • tests/test_analyze.py — impact
  • tests/test_atomic_canvas_export.py — impact
  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_build.py — impact
  • tests/test_build_merge_dedup_scope.py — impact, changed-test
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.py — impact
  • tests/test_carried_hyperedge_remap.py — impact, changed-test
  • 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_cluster.py — impact
  • tests/test_confidence.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_dedup.py — impact
  • tests/test_dedup_remaps_hyperedges.py — impact
  • tests/test_dedup_survivor_richness.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_evidence_binding.py — impact
  • tests/test_export.py — impact
  • tests/test_export_control_characters.py — impact
  • tests/test_export_path_length.py — impact
  • tests/test_extract.py — impact
  • tests/test_falkordb_integration.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_global_add_tag_inference.py — impact
  • tests/test_global_graph.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_god_nodes_exclude_hubs.py — impact
  • tests/test_hyperedge_member_shapes.py — impact
  • tests/test_hyperedge_roundtrip.py — impact
  • tests/test_hypergraph.py — impact
  • tests/test_image_vision.py — impact
  • tests/test_import_self_loops.py — impact
  • tests/test_issue_3472_source_file_collision.py — impact
  • tests/test_java_type_resolution.py — impact
  • tests/test_languages.py — impact
  • tests/test_llm_backends.py — impact
  • tests/test_llm_parser.py — impact
  • tests/test_llm_parser_reasoning.py — impact
  • tests/test_loose_sibling_import_resolution.py — impact
  • tests/test_manifest_ingest.py — impact
  • tests/test_merge_chunks_validation.py — impact
  • tests/test_merge_graphs_cli.py — impact
  • tests/test_multigraph_diagnostics.py — impact
  • … and 38 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

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: 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 build\_merge.

The verifier did not have enough to check build\_merge, 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: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

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: not verifiable: all 9 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)

Could not verify: Could not verify \_llm\_tiebreak.

The verifier did not have enough to check \_llm\_tiebreak, 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: not verifiable: all 200 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)

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

safishamsi pushed a commit that referenced this pull request Sep 12, 2026
Under #3503 (#3477) existing nodes from untouched files are protected from
dedup collapse, so the carried-hyperedge remap test must deliver the duplicate
via an incoming re-extracted chunk to fold into the protected survivor.
@safishamsi

Copy link
Copy Markdown
Collaborator

Shipped in v0.9.59 — landed on v8 via cherry-pick with your authorship preserved. Thanks @hopstreax — incremental dedup no longer drops cross-file concept nodes from files it did not touch. Release: https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.59

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants