Skip to content

fix(build): don't treat intentional ghost folds as drops in build_merge (#4955) - #4957

Open
Deepjyoti-Sarmah wants to merge 4 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4955-merge-guard-ghost-folds
Open

Deepjyoti-Sarmah wants to merge 4 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4955-merge-guard-ghost-folds

Conversation

@Deepjyoti-Sarmah

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #4955.

build_from_json folds three kinds of duplicate on purpose: a semantic ghost into its AST twin (#1145), a document node whose label names a file into that file (#3344), and a bare method into its AST method (#3705). The #479 guard in build_merge excuses only the #1799 doc twin, so it read these folds as nodes lost from sources nobody re-extracted or pruned. A graph that holds such a twin could not be updated through build_merge(dedup=False), even with an empty chunk, and the error pointed at prune_sources, which was not what the caller wanted.

dedup=False does not stop the fold. Nodes loaded from disk are stamped _origin by shape, which makes the AST twin the canonical node, and test_ast_twin_remains_canonical_despite_semantic_location_drift pins that an AST twin wins even with dedup=False. So the fold is intended, and the guard has to know about it.

This change reports the ghost folds on the {merged-away id: survivor id} channel entity dedup already uses (#4940), and lets the build_merge guard read it:

  build_from_json
    Pass 2 / Pass 2b / method branch -> _ghost_remap
+   G.graph["_dedup_remap"].update(_ghost_remap)
  build
-   G.graph["_dedup_remap"] = _dedup_remap
+   G.graph.setdefault("_dedup_remap", {}).update(_dedup_remap)
  build_merge  (#479 guard)
    _in_new_graph(saved node)
+     folded into a survivor that is still in G -> not lost
  to_json
    take_dedup_remap(G)      # already excuses it, and keeps it out of graph.json
saved:   src_client (L1, ast)    client_py (no loc, semantic)    same file + label
fold:    client_py -> src_client
guard:   before  "client_py dropped"            -> ValueError
         after   survivor src_client is present -> write

A survivor that is itself gone excuses nothing.

to_json has its own guard. It re-derives the #1145 and #3344 folds from labels but never learned the #3705 method fold, so with only the build_merge change a saved method ghost would pass the merge and then be refused on write. Feeding it the real fold map fixes that, and removes the need for it to guess.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Evidence

Before: on upstream/v8 (8e6a6d21), the issue's two cases with an empty chunk and dedup=False.

same file, same label (#1145 path): ValueError: graphify: build_merge would drop 1 node(s) ... graph would go 2 → 1 nodes. (#479)
doc mentions the file by name (#3344 path): ValueError: graphify: build_merge would drop 1 node(s) ... graph would go 2 → 1 nodes. (#479)

An ordinary update raises too: a re-extracted file brings a new AST node while a saved semantic twin of it exists.

tests/test_build_merge_ghost_fold_4955.py
8 failed, 6 passed

After:

same file, same label: written, 1 node (src_client)
doc mentions the file: written, 1 node (src_client)
method ghost (.run() + run): build_merge ok, to_json writes
an edge that pointed at the ghost now points at the survivor

tests/test_build_merge_ghost_fold_4955.py
14 passed

full suite
9577 passed, 18 skipped, 1 xfailed in 503.72s

The full suite ran serially with PYTHONPATH=/var/tmp/wt-4955-A, so subprocess CLI tests imported this worktree and not the installed package.

Verification & Invariants

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant.
  • Made the smallest fix necessary.
  • Added a regression test (if bug fix) or isolated boundary test.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly.

The invariant: the guard may refuse a write, but only when content is gone, never because build_from_json folded a node into a twin that is still there.

The tests assert on what build_merge returns and what reaches graph.json, so they hold for any fix of this bug. They pin:

  • The issue's two cases, a directory-qualified file label, and a method ghost no longer raise, and each leaves one node.
  • An edge into a folded ghost is kept and lands on the survivor.
  • An ordinary update with a new AST node next to a saved semantic twin folds and writes.
  • build_merge(dedup=False) followed by to_json round-trips all three folds.
  • A clean graph, semantic duplicates under dedup=False, a ghost with its own rationale (watch liveness check resolves relative source identities against process CWD instead of watch root #4896), an ambiguous file label and prune_sources behave as before. These pass before and after.
  • The fold map is never written into graph.json.

I tried a second fix against the same shapes: under dedup=False, skip Pass 2 and 2b for nodes loaded from disk. It leaves the nodes at 2 → 2, as 0.8.31 did, but it breaks test_ast_twin_remains_canonical_despite_semantic_location_drift, which pins the opposite on purpose. So I kept the fold and fixed the guard.

Known limitations:

  • dedup=False still merges an AST twin with its ghost, as the existing test pins. Nothing here changes node counts, only whether the guard refuses.
  • watch._check_shrink calls _lost_saved_semantic_nodes without the fold map. A graphify update through it could still refuse a extraction-spec method IDs omit the class segment the AST extractor emits, so method nodes never dedupe #3705 method fold. I did not reproduce that, and it needs a separate change.
  • The fold map is keyed on the id after semantic rekeying. A saved id that is rekeyed and then folded in the same run might not match. The rekey-only and fold-only shapes pass, and I did not build the combination.
  • The reporter's three graphs that still fail after filtering these patterns are unexplained. I could not reproduce them without one of their graphs.
  • No cache schema bump and no graph format change.

How was this tested?

PYTHONPATH=. .venv/bin/python -m pytest tests/test_build_merge_ghost_fold_4955.py -q
PYTHONPATH=. .venv/bin/python -m pytest tests -q -p no:cacheprovider --basetemp=/var/tmp/pt4955-full3
.venv/bin/python -m ruff check graphify/build.py tests/test_build_merge_ghost_fold_4955.py

Branch fix/4955-merge-guard-ghost-folds is based on upstream/v8 (8e6a6d21). It has four commits:

8d3c88f9 test(build): pin the build_merge guard behaviours that already hold (#4955)
2e6831e8 fix(build): report ghost folds on the dedup remap channel (#4955)
9839bc9a fix(build): don't count intentional ghost folds as drops in build_merge (#4955)
539fc628 test(build): cover edge repoint, incremental update and write-back for ghost folds (#4955)

Merge Danger

Door: Two-way.

When build_from_json folds nothing the default path is unchanged. No cache schema bump and no graph format change. Reverting restores the old guard.

Blast Radius: Write guard.

Every graph written after a merge passes the build_merge guard and to_json, so a mistake here either blocks a good write or lets a bad one through. A fold is excused only when its survivor is in the new graph, and the control tests pin the refusals. After merge, a graph holding these twins can be updated through build_merge(dedup=False) instead of being refused with no way out. Extraction, ignore rules and secret filtering are unchanged.

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. No source fragments changed.
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

…raphify-Labs#4955)

Clean graphs, semantic-vs-semantic duplicates under dedup=False, ghosts
with their own rationale (Graphify-Labs#4896), ambiguous file labels and
prune_sources are all left alone by build_merge. Pinned first so the
guard change that follows cannot loosen them.
…abs#4955)

build_from_json folds a semantic ghost into its AST twin (Graphify-Labs#1145), a
document node that names a file into that file (Graphify-Labs#3344), and a bare
method into its AST method (Graphify-Labs#3705). Entity dedup already reports its
folds as {merged-away id: survivor} on G.graph (Graphify-Labs#4940) so to_json's
shrink guard can excuse them; the ghost folds were never reported.
to_json re-derived the first two from labels and missed the third, so
a saved method ghost made it refuse to write.

Record the ghost folds on the same channel, and merge into it in build()
instead of overwriting it. to_json already pops the key before it
serialises the graph.
…ge (Graphify-Labs#4955)

The Graphify-Labs#479 guard in build_merge excused one legitimate collapse, the Graphify-Labs#1799
doc twin. The Graphify-Labs#1145, Graphify-Labs#3344 and Graphify-Labs#3705 folds that build_from_json performs
on purpose looked like unexplained losses, so an on-disk graph holding
such a twin could not be updated through build_merge(dedup=False): the
saved nodes are stamped by shape on load, which makes the AST twin the
canonical node, and the fold runs even under dedup=False.

Excuse an id the fold mapped onto a survivor that is still in the graph.
A survivor that is gone excuses nothing.
…r ghost folds (Graphify-Labs#4955)

An edge into a folded ghost lands on the survivor, an ordinary update
that brings a new AST node next to a saved semantic twin no longer
raises, and build_merge(dedup=False) followed by to_json round-trips
each of the three folds.
@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, @Deepjyoti-Sarmah. 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.

No issues found by static checks — no coupling regressions or blocking issues in the code graph. That is not the same as safe to merge: see what was not checked.

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

Formal verification. PR-changed functions: 0/3 verified (0 proven, 0 may-equivalent, 0 distinguished) · 3 not verified (3 vacuous).

Not verified on this run: build (vacuous: never exercised), build\_from\_json (vacuous: never exercised), build\_merge (vacuous: never exercised).


Graphify review — findings

Fixes false "lost node" refusals when build_from_json intentionally folds ghost nodes (#1145, #3344, #3705): it now records each fold as a {merged-away id: survivor id} entry in G.graph["_dedup_remap"], the same channel entity dedup uses, and build merges into that map instead of overwriting it. In build_merge with dedup=False, the #479 check treats a saved node as present when its fold survivor is still in the graph, while a fold whose survivor is gone still counts as a loss. Because the map is now written by build_from_json itself, it sits in G.graph for every direct caller, and only to_json strips it.

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

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2062 functions depend on the 147 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: build_merge() — 109 callers, 16 callees
  • worse: to_json() — 100 callers, 13 callees

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

Test selection

Test selection

146 of 502 test file(s) selected (29%) 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_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_ghost_fold_4955.py — impact, changed-test
  • 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_cluster.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_duplicate_annotation_edges.py — impact
  • tests/test_edge_also_seen.py — impact
  • tests/test_evidence_binding.py — impact
  • tests/test_export.py — impact
  • tests/test_export_control_characters.py — impact
  • tests/test_export_direction.py — impact
  • tests/test_export_idempotent_writes.py — impact
  • tests/test_export_path_length.py — impact
  • tests/test_ext_tagged_id_clash.py — impact
  • tests/test_external_stub_endpoints.py — impact
  • … and 96 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: 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 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 11 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

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: no capturable inputs from the test suite; property tier: not verifiable: all 224 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)

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

This branch has not been deployed

No deployments
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.

[Bug]: build_merge(dedup=False) refuses a no-op merge: #479 guard counts intentional ghost merges (#1145, #3344) as drops

1 participant