Skip to content

perf(serve): build the MCP traversal view and shortest-path graphs once per graph - #4192

Open
rohit-jsfreaky wants to merge 1 commit into
Graphify-Labs:v8from
rohit-jsfreaky:perf/mcp-cache-traversal-graphs
Open

rohit-jsfreaky wants to merge 1 commit into
Graphify-Labs:v8from
rohit-jsfreaky:perf/mcp-cache-traversal-graphs

Conversation

@rohit-jsfreaky

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #4191.

Same pattern as the trigram index: a hot reload swaps in a new G, so a cached structure never outlives its graph.

About the design note this changes. _traversal_view's docstring said: "A fresh copy per query rather than a cached one: _filter_graph_by_context already copies per query when a filter applies, and the copy shares node data dicts with G, so only the edge dicts are duplicated." That was a memory/simplicity trade-off. The numbers below show the per-call copy is most of a warm query and the cache costs ~27 MB, so this PR flips it and updates the docstring. Happy to drop either half if you prefer to keep the old trade-off.

Why sharing is safe. Nothing writes to the cached structures (checked every function on the path with an AST scan for add/remove/update/setdefault/pop/item assignment):

  • _filter_graph_by_context builds a new graph when a filter applies and never touches its input;
  • _bfs, _dfs, _complete_induced_edges, _subgraph_to_text only read (their writes go to local sets/dicts), and none reads .graph;
  • only nx.shortest_path reads the path graphs.
    Each structure has a single call site.

Type of change

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

(performance; no behaviour change)

Verification & Invariants

Invariant: every query_graph / shortest_path answer is byte-identical to a freshly built structure, in any order of calls, and a reloaded graph never sees the previous graph's structures.

graphify's own graph (18,865 nodes / 38,458 edges). The same calls run interleaved in one process with the cache cleared (= v8 behaviour) vs kept, so thermal drift hits both sides; outputs asserted equal on every call (240 calls):

MCP call v8 (median) this PR (median)
query_graph, no filter (n=120) 430 ms 266 ms 1.6x
query_graph, "what calls X" heuristic filter (n=30) 428 ms 274 ms 1.6x
shortest_path, directed (n=45) 235 ms 0.8 ms ~300x
shortest_path, undirected=true (n=45) 137 ms 0.8 ms ~170x

Separate processes (v8 vs PR, alternated, 2 runs each; 80 queries + 30 paths; all outputs identical across the 4 processes):

v8 this PR
warm query_graph median 367 / 269 ms 164 / 166 ms
warm shortest_path median 117 / 103 ms 0.2 / 0.2 ms
first query_graph call (pays the one build) 220 / 142 ms 168 / 143 ms
RSS after load 123 MB 123 MB
RSS after the 110 calls 204 MB 231 MB (+27 MB: view +8, both path graphs +24)

(An earlier run on a hotter machine gave the same ratios: 524 -> 316 ms, 211 -> 0.8 ms.)

  • 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. (A query with a context filter still builds its filtered copy per call, as before; caching that per filter set is a possible follow-up.)

How was this tested?

pytest tests/test_mcp_graph_view_cache.py      # 6 passed; the 2 "built once" tests fail on f765dcb,
                                               # the 4 equality/reload guards pass on both
pytest tests/test_mcp_graph_view_cache.py tests/test_query_mcp_direction.py tests/test_path_cli.py \
       tests/test_query_induced_edges.py tests/test_query_names_its_graph.py tests/test_serve.py   # 207 passed
pytest tests -q                                # 6678 passed, 18 failed: every one also fails on clean v8 (0 new failures)
ruff check graphify/serve.py tests/test_mcp_graph_view_cache.py   # All checks passed
pyright graphify/serve.py                      # 50 errors, same as v8
bandit graphify/serve.py                       # same findings as v8

Graphify-specific checklist

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

🤖 Generated with Claude Code

…ce per graph

query_graph copied every edge of the loaded graph into an undirected
traversal view on every call, and shortest_path rebuilt a sorted
DiGraph (or Graph) of every edge on every call. The structures are the
same for a given graph object, so build each once and cache it on
G.graph, exactly like the trigram index: a hot reload swaps in a new G,
so a cached structure never outlives its graph.

Sharing is safe because nothing writes to them: _filter_graph_by_context
builds a new graph when a filter applies, the traversals and the
renderer only read the view, and only nx.shortest_path reads the path
graphs. The path graph is still built from sorted node and edge lists,
so Graphify-Labs#2074's deterministic route is unchanged. The traversal-view
docstring that chose a fresh copy per query is updated.

graphify's own graph (18,865 nodes / 38,458 edges), the same calls run
interleaved with the cache cleared vs kept, 240 outputs identical:
query_graph 524 -> 316 ms median (filtered "what calls X": 508 -> 360),
shortest_path 211 -> 0.8 ms directed, 109 -> 0.7 ms undirected.
Memory: +8 MB for the view, +24 MB for both path graphs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

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

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

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

Not verified on this run: \_shortest\_path\_text (vacuous: never exercised), \_traversal\_view (vacuous: never exercised).


Graphify review — findings

Caches the undirected traversal view and the sorted shortest-path search graphs on G.graph, like the trigram index, so warm query_graph and shortest_path calls no longer copy every edge of the graph per request. The path graph is cached per direction mode via the new _path_search_graph helper. A hot reload swaps in a fresh G, so neither cache can outlive the graph it was built from.

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

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 559 functions depend on the 151 functions this change touches.

Health — this change adds coupling hotspots:

  • new: main() — 102 callers, 3 callees
  • new: _query_graph_text() — 30 callers, 10 callees
  • new: dispatch_command() — 2 callers, 128 callees
  • new: _score_query() — 15 callers, 7 callees
  • new: _load_graph() — 23 callers, 3 callees
  • new: _query_terms() — 20 callers, 3 callees
  • new: run_benchmark() — 16 callers, 3 callees
  • new: _run_cli() — 6 callers, 7 callees
  • …and 15 more — each is listed as a finding

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

Test selection

Test selection

11 of 338 test file(s) selected (3%) via static blast radius.

  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_mcp_graph_view_cache.py — impact, changed-test
  • tests/test_path_cli.py — impact
  • tests/test_query_induced_edges.py — impact
  • tests/test_query_mcp_direction.py — impact
  • tests/test_query_names_its_graph.py — impact
  • tests/test_serve.py — impact
  • tests/test_serve_http.py — impact
  • tests/test_terraform.py — impact

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 \_shortest\_path\_text.

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

Could not verify: Could not verify \_traversal\_view.

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

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

Comment thread graphify/serve.py
G.graph["_traversal_view"] = H
return H

def _query_graph_text(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — _query_graph_text()

fans out to 10 callees (efferent coupling); 30 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/serve.py
return H


def _shortest_path_text(G: nx.Graph, arguments: dict) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — _shortest_path_text()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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.

[Bug]: MCP query_graph and shortest_path rebuild a copy of the whole graph on every call

1 participant