Skip to content

fix(update): refuse a saved semantic id when the total grows - #4115

Open
SrijanSriv wants to merge 5 commits into
Graphify-Labs:v8from
SrijanSriv:fix/2229-semantic-layer-shrink
Open

SrijanSriv wants to merge 5 commits into
Graphify-Labs:v8from
SrijanSriv:fix/2229-semantic-layer-shrink

Conversation

@SrijanSriv

Copy link
Copy Markdown
Contributor

What does this PR do?

A saved semantic node id must still be in the graph when graphify update or to_json writes, even if the node total grew.

to_json in graphify/export.py compares new_n < existing_n and only when force-write is false. graphify update writes a temp file with force=True, then _check_shrink in graphify/watch.py returns success at len(new_nodes) >= len(existing_nodes). Five new AST nodes can replace one lost rationale id and the total still rises. A sourceless stub such as pathlib_Path keeps a semantic count flat, and a rebuilt src/app.ts was treated as a reason to drop the rationale node on that file.

_lost_saved_semantic_nodes returns saved nodes for which _is_ast_tier is false, the id is absent from the new graph, and source_file is not in the deleted set. to_json calls it inside if not force. _check_shrink calls it after the force-write return and the legacy deletion return, and before the larger-total return. The deleted set is the files the user removed. The rebuilt set is not passed. A legacy semantic id that build_from_json rewrites to its canonical stem, including a bare doc id folded into its _doc twin, is the same node.

Related to #2229. This does not use a closing keyword: a hyperedge that disappears while every saved semantic id remains is still written.

Type of change

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

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.

  • Reproduced on v8 at 35adf43: pytest tests/test_watch.py::test_check_shrink_refuses_lost_semantic_id_when_total_grows tests/test_export.py::test_to_json_refuses_lost_semantic_id_when_total_grows -q failed with assert True is False on both. The saved graph has 4 nodes, including rationale_lost. The new graph has 7 nodes, drops that id, and adds a sourceless pathlib_Path stub. src/app.ts is in the rebuilt set and not in the deleted set. graph.json lost rationale_lost.

  • Decoy: a total compare returns true because 7 > 4. A semantic count stays at 2 because the stub fills the slot. An excuse that trusts the rebuilt set returns true because src/app.ts was rebuilt. The test passes rebuilt_sources={"src/app.ts"} and asserts the write is refused and rationale_lost is still in the file.

  • Left alone: force-write on a complete extract, --allow-dedup-shrink, and dedup matching. Those are Default dedup=True + force=True on every normal run leaves the #479 shrink guard permanently bypassed unless --no-dedup is known and passed #3774 and Entity dedup merges nodes across different source files, silently removing whole documents from the graph #3094.

  • Limitations: a hyperedge that disappears while every saved semantic id remains still writes. An AST rename can drop a hyperedge for that reason, so hyperedge ids are not part of this check.

How was this tested?

Python 3.12. Python 3.10 and 3.13 were not run.

PYTHONPATH=. python -m pytest tests/test_watch.py::test_check_shrink_refuses_lost_semantic_id_when_total_grows tests/test_export.py::test_to_json_refuses_lost_semantic_id_when_total_grows -q
# before the fix, on v8 at 35adf43: FAILED assert True is False (both tests)

PYTHONPATH=. python -m pytest tests/test_watch.py tests/test_export.py -q
# after the fix: 252 passed, 3 skipped

PYTHONPATH=. python -m pytest tests/test_cli_export.py::test_cluster_only_preserves_parallel_edges_across_an_id_remap tests/test_watch.py::test_check_shrink_refuses_lost_semantic_id_when_total_grows tests/test_watch.py::test_check_shrink_allows_deleted_code_file_when_semantic_ids_remain tests/test_export.py::test_to_json_refuses_lost_semantic_id_when_total_grows tests/test_export.py::test_to_json_allows_ast_growth_that_keeps_semantic_ids -q
# after treating a canonical id rewrite as the same node: 7 passed
# the cluster-only test had failed on the first version of the guard (refused claude_alpha, beta)

PYTHONPATH=. python -m ruff check graphify/build.py graphify/export.py graphify/watch.py tests/test_watch.py tests/test_export.py
# All checks passed

PYTHONPATH=. python -m pytest tests -q
# 6394 passed, 30 failed, 103 skipped, before the canonical-id follow-up
# 29 failures are missing optional parsers or the openai package
# the other failure was the cluster-only test above, which passes after that follow-up

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments.
  • 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.

A larger total used to make to_json accept a write that dropped a saved
rationale id. The new stub keeps the semantic count flat, so a count
check would miss the same loss.
graphify update accepts any new node list that is at least as long as
the saved one, and a rebuilt code file was treated as a reason to drop
the rationale node on that file. A deleted code file must still write.

The Graphify-Labs#1116 fixture nodes are marked AST so a removed code symbol stays a
code deletion.
Compare saved semantic ids with the new graph instead of the node total.
A sourceless stub cannot fill a missing id, and a code rebuild is not a
deletion. A legacy id that build_from_json rewrites to its canonical
stem is the same node, so cluster-only still writes.
@SrijanSriv
SrijanSriv requested a review from safishamsi as a code owner October 5, 2026 15:15
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Thanks for the pull request, @SrijanSriv. 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.

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

Behavior changes: to\_json changes behavior, here is the input that shows it.

The verifier found a concrete input on which to\_json 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":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\(1, \{\}\), \(2, \{\}\), \(3, \{\}\)\]\), \_g\.add\_edges\_from\(\[\(1, 2, \{\}\), \(1, 3, \{\}\), \(2, 3, \{\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","communities":"\{'k': 'v'\}","output\_path":"'racecar'"\}, the old code produced True but the new code produces False. Paste that input straight into a regression test.

Not verified on this run: \_check\_shrink (unsupported).


Graphify review — findings

Tightens the overwrite guards in to_json and _check_shrink so they refuse to replace graph.json when any saved semantic (non-AST) node id is missing from the new graph, even if the total node count grew. _lost_saved_semantic_nodes treats legacy ids that were remapped to a canonical stem or _doc twin as still present. In watch rebuilds, semantic nodes on files the user actually deleted are allowed to go, but files that were only re-extracted are not counted as deletions; --force still bypasses the check.

Worth a look

  • to_json passes an empty deleted set, so legitimate deletions are refused — graphify/export.py:328 · 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 — 1815 functions depend on the 739 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _rebuild_code() — 149 callers, 56 callees
  • new: build_from_json() — 227 callers, 20 callees
  • new: build_merge() — 82 callers, 14 callees
  • new: to_obsidian() — 41 callers, 14 callees
  • new: to_json() — 63 callers, 9 callees
  • new: to_wiki() — 45 callers, 8 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: build() — 54 callers, 6 callees
  • …and 51 more — each is listed as a finding

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

Test selection

Test selection

104 of 329 test file(s) selected (32%) 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
  • 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_dedup_shrink_refuses_force_write.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_elixir_import_resolution.py — impact
  • tests/test_elixir_unqualified_call_scope.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_idempotent_writes.py — impact
  • tests/test_export_path_length.py — impact
  • tests/test_external_stub_endpoints.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_kotlin_grammar.py — impact
  • tests/test_labeling.py — impact
  • … and 54 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.

Docs that may be stale (advisory)

…and 10 more.

Formal verification

Behavior changes: to\_json changes behavior, here is the input that shows it.

The verifier found a concrete input on which to\_json 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":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\(1, \{\}\), \(2, \{\}\), \(3, \{\}\)\]\), \_g\.add\_edges\_from\(\[\(1, 2, \{\}\), \(1, 3, \{\}\), \(2, 3, \{\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","communities":"\{'k': 'v'\}","output\_path":"'racecar'"\}, the old code produced True but the new code produces False. Paste that input straight into a regression test.

Could not verify: Could not verify \_check\_shrink.

The verifier did not have enough to check \_check\_shrink, 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 `tmp` is annotated `'Path | None'` — outside the synthesizable primitive/collection set

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

The verifier ran both versions of \_rebuild\_code 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.

· 1 grounded finding(s) anchored inline below; 58 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/build.py
The semantic check only treated a string id as present, so a second
to_json of nodes 1, 2, and 3 refused the same graph.
@SrijanSriv

Copy link
Copy Markdown
Contributor Author

The to_json difference is the guard, with one real hole in it.

Nodes 1, 2, and 3 have no _origin and no L location, so they count as semantic. A second save of that same graph returned false because the check only treated a string id as present. Integer 1 was already in the new graph. 93c43cf looks up the id first, and only then applies the string remap. A missing integer id still refuses: saved 4, new graph {1, 2, 3}.

to_json still passes an empty deleted set. That function has no list of files the user removed. graphify update passes deleted_paths into _check_shrink, and that is the path that may drop a semantic node on a deleted file. I am not inventing a deleted set inside to_json.

The coupling note and the findings outside this diff are functions this change does not edit. Leaving those.

@safishamsi

Copy link
Copy Markdown
Member

Thanks @SrijanSriv — the core idea is right. "Total grows" was never the real signal; refusing when a specific saved semantic id vanishes is the correct invariant, and the canonical-id / doc-twin remap handling is good.

One blocking issue: the not _is_ast_tier(node) filter is too broad. It also catches auto-minted external import stubs (written with source_file="", external=True, type="external", and re-derived deterministically from AST import edges every run). So a routine update that just removes an import while adding code — total grows — trips the guard and forces the user to pass --force. That's stricter on growth than the existing shrink guard is, which treats sourceless loss as legitimate.

Please exclude auto-minted externals from _lost_saved_semantic_nodes — skip nodes where node.get("external") is true / type == "external" (sourceless stubs) — so only genuine rationale/doc nodes stay protected. And add a regression test that an import removal on a growing update still writes without --force. Then this is good to land.

@SrijanSriv

Copy link
Copy Markdown
Contributor Author

thanks for the insight @safishamsi

roger, roger! o7

An auto-minted external node is rebuilt from import edges. Dropping one
while code grows is not a lost rationale node, so the update writes
without --force.
@SrijanSriv

Copy link
Copy Markdown
Contributor Author

b1ecfc3 skips a saved node when external is true or type is external.

A growing update that drops the json import stub now writes without --force. A saved rationale id that is still missing is refused. The sourceless pathlib_Path concept in the existing test is not an external stub, so that refusal stays.

@SrijanSriv

Copy link
Copy Markdown
Contributor Author

made updates. please see if possible
cc: @safishamsi

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants