Skip to content

fix: excuse dedup-explained node drops from the #479 shrink guard - #3623

Open
seonghobae wants to merge 1 commit into
Graphify-Labs:v8from
seonghobae:fix/1847-ghost-dedup-shrink-guard
Open

seonghobae wants to merge 1 commit into
Graphify-Labs:v8from
seonghobae:fix/1847-ghost-dedup-shrink-guard

Conversation

@seonghobae

Copy link
Copy Markdown

Summary

Reproduction (evidence from downstream)

Reported downstream at ContextualWisdomLab/fast-mlsirm#1847: a persisted graph.json had 23,934 nodes; build_from_json(raw) on the identical extraction reloaded 23,933 NetworkX nodes (crate:mlsirm-core merged into its AST twin pkg_mlsirm_core, zero incident edges dropped). cluster-only . --no-viz --no-label on this unchanged graph refused the write:

[graphify] WARNING: new graph has 23933 nodes but existing graph.json has 23934 ... Refusing to overwrite.

No data was actually lost — this was the intended (source_file, label) canonicalization merge, not a shrink.

Test plan

  • Added test_ghost_merge_records_dedup_count (build.py) asserting the merge count is recorded.
  • Added test_to_json_allows_dedup_explained_shrink and test_to_json_still_refuses_shrink_beyond_dedup_count (export.py) — the latter guards that an unexplained loss on top of a dedup-explained one still refuses.
  • pytest tests/test_build.py tests/test_export.py tests/test_manifest_ingest.py tests/test_build_merge_shrink_guard.py tests/test_hollow_chunks_arm_shrink_guard.py tests/test_incomplete_build_guard.py tests/test_unverified_semantic_shrink.py tests/test_no_dedup_flag.py tests/test_cli_export.py — 260 passed.

🤖 Generated with Claude Code

…ink guard

build_from_json's Graphify-Labs#1145 ghost-merge pass collapses non-AST duplicate nodes
(e.g. a manifest package node and its AST-canonical twin sharing the same
source_file/label) into their AST canonical counterpart. That is a
legitimate, data-preserving node-count reduction, but to_json's Graphify-Labs#479 shrink
guard could not distinguish it from an unverified shrink and refused to
overwrite graph.json even when nothing was actually lost — reproduced on a
23,934-node graph.json where reloading via build_from_json produced 23,933
NetworkX nodes (crate:mlsirm-core merged into its AST twin pkg_mlsirm_core,
zero incident edges dropped) and cluster-only refused the write.

build_from_json now records the collapsed-node count as
G.graph["_ghost_dedup_count"]; to_json's shrink guard treats a node-count
drop as safe when it is fully explained by that count, and still refuses
when any part of the drop is unexplained.

Fixes downstream report at ContextualWisdomLab/fast-mlsirm#1847.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXYRNTJhYLB2bYveuaFe74

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

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. 1 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_match\_cited\_file changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_match\_cited\_file 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 \{"cited":"'\./'","doc\_file":"'\.mdx'","source\_files":"\{1, 2, 3\}"\}, the old code produced None but the new code produces raises AttributeError. Paste that input straight into a regression test.


Graphify review — findings

Teaches the to_json shrink guard to distinguish legitimate node-count drops from data loss: build_from_json now records on G.graph["_ghost_dedup_count"] how many ghost nodes it collapsed into their (source_file, label) AST twins, and the guard subtracts that count before deciding. A drop fully explained by dedup proceeds; any unexplained remainder still refuses to overwrite (unless force=True), and the warning now reports the explained/unexplained split.

No blocking issues surfaced. 7 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1459 functions depend on the 414 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _rebuild_code() — 123 callers, 53 callees
  • new: build_from_json() — 209 callers, 19 callees
  • new: build_merge() — 76 callers, 14 callees
  • new: to_obsidian() — 38 callers, 14 callees
  • new: to_json() — 58 callers, 7 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: build() — 52 callers, 6 callees
  • new: _call_claude_cli() — 33 callers, 9 callees
  • …and 42 more — each is listed as a finding

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

Test selection

Test selection

92 of 283 test file(s) selected (33%) via static blast radius.

  • tests/test_analyze.py — impact
  • tests/test_atomic_canvas_export.py — impact
  • tests/test_atomic_writes.py — impact
  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_build.py — impact, changed-test
  • 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_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_community_labels_skill.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_cross_repo_external_call_guards.py — impact
  • tests/test_dedup.py — impact
  • tests/test_dedup_remaps_hyperedges.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, changed-test
  • 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_lua_import.py — impact
  • tests/test_manifest_ingest.py — impact
  • … and 42 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: \_match\_cited\_file changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_match\_cited\_file 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 \{"cited":"'\./'","doc\_file":"'\.mdx'","source\_files":"\{1, 2, 3\}"\}, the old code produced None but the new code produces raises AttributeError. Paste that input straight into a regression test.

Could not verify: Could not verify build\_from\_json.

The verifier did not have enough to check build\_from\_json, 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 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, 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 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

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 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly IndexError — 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.

Could not verify: Could not verify extract\_elixir.

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

No difference found (not proven): No behavior difference found in \_code\_span\_mention (not a proof).

The verifier ran both versions of \_code\_span\_mention 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 extract\_markdown.

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

Could not verify: Could not verify extract\_rust.

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

No difference found (not proven): No behavior difference found in resolve\_markdown\_mentions (not a proof).

The verifier ran both versions of resolve\_markdown\_mentions 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 resolve\_ruby\_member\_calls.

The verifier did not have enough to check resolve\_ruby\_member\_calls, 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: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

Could not verify: Could not verify \_rebuild\_code.

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

Could not verify: Could not verify \_reconcile\_markdown\_links.

The verifier did not have enough to check \_reconcile\_markdown\_links, 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: parameter `project_root` is annotated `Path` — outside the synthesizable primitive/collection set

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant