Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,11 @@ def dispatch_command(cmd: str) -> None:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
G = json_graph.node_link_graph(_raw)
# Let serve._get_trigram_index find its on-disk cache. This path does
# not go through serve._load_graph, which is where the stash normally
# happens, so without this the cache key is unresolvable and every CLI
# call rebuilds the index from scratch.
G.graph["_graph_path"] = str(gp)
src_scored = _score_nodes(G, [t.lower() for t in source_label.split()])
tgt_scored = _score_nodes(G, [t.lower() for t in target_label.split()])
if not src_scored:
Expand Down Expand Up @@ -1725,6 +1730,11 @@ def dispatch_command(cmd: str) -> None:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
G = json_graph.node_link_graph(_raw)
# Let serve._get_trigram_index find its on-disk cache. This path does
# not go through serve._load_graph, which is where the stash normally
# happens, so without this the cache key is unresolvable and every CLI
# call rebuilds the index from scratch.
G.graph["_graph_path"] = str(gp)
matches = _find_node(G, label)
if not matches:
print(f"No node matching '{label}' found.")
Expand Down
105 changes: 105 additions & 0 deletions graphify/serve.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# MCP stdio server - exposes graph query tools to Claude and other agents
from __future__ import annotations
import hashlib
import json
import math
import os
import pickle
import re
import sys
from array import array
Expand Down Expand Up @@ -73,6 +75,10 @@ def _load_graph(graph_path: str) -> nx.Graph:
G.graph["_learning_overlay"] = _llo(resolved)
except Exception:
G.graph["_learning_overlay"] = {}
# Source path for the on-disk trigram cache key (`_get_trigram_index`).
# The index is derived purely from this file, so its mtime and size are
# a sufficient generation marker.
G.graph["_graph_path"] = str(resolved)
return G
except json.JSONDecodeError as exc:
print(f"error: graph.json is corrupted ({exc}). Re-run /graphify to rebuild.", file=sys.stderr)
Expand Down Expand Up @@ -366,6 +372,93 @@ class 0 and therefore survive the combining-character filter. The field is
return "\x00".join(fields)


# The in-memory cache below is keyed on the graph object, which is right for a
# long-lived server but never hits from the CLI, where every invocation is a
# fresh process. Building the index dominates a cold CLI call (~6.7s of ~10.3s
# on the Masque graph) while the result is small and pickles in ~0.05s, so it is
# also persisted beside graph.json. Set GRAPHIFY_TRIGRAM_CACHE_DISABLE=1 to skip.
_TRIGRAM_CACHE_VERSION = 1


def _trigram_cache_key(G: nx.Graph):
"""Identity of the graph file the index was built from, or None if unknown."""
if os.environ.get("GRAPHIFY_TRIGRAM_CACHE_DISABLE", "").lower() in ("1", "true", "yes"):
return None
raw = G.graph.get("_graph_path")
if not raw:
return None
try:
st = Path(raw).stat()
except OSError:
return None
return [str(raw), _TRIGRAM_CACHE_VERSION, st.st_mtime_ns, st.st_size]


def _trigram_cache_path(graph_path: str) -> Path:
"""Where the persisted index lives.

Deliberately outside the corpus. Writing an 80 MB derived binary next to
graph.json puts it inside whatever VCS tracks the graph -- for the Masque
depot that means one `p4 reconcile -a` away from being submitted -- and a
per-machine cache is per-machine anyway. GRAPHIFY_TRIGRAM_CACHE_DIR
overrides.
"""
root = os.environ.get("GRAPHIFY_TRIGRAM_CACHE_DIR", "").strip()
if root:
base = Path(root).expanduser()
elif os.name == "nt" and os.environ.get("LOCALAPPDATA"):
# ~/.cache is a POSIX convention; on Windows the per-user cache lives
# under LOCALAPPDATA, which is also excluded from roaming profiles --
# correct for an 80 MB derived file nobody wants synced between machines.
base = Path(os.environ["LOCALAPPDATA"]) / "graphify" / "cache"
else:
base = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "graphify"
digest = hashlib.sha1(str(Path(graph_path).resolve()).encode("utf-8")).hexdigest()[:16]
return base / f"trigram-{digest}.pkl"


def _load_trigram_cache(G: nx.Graph):
"""Read a previously built index, or None on any miss. Never raises.

The cache sits beside graph.json inside the workspace, so unpickling it is
exactly as trusted as loading the graph itself: anyone who can plant this
file can already rewrite the graph it was derived from.
"""
key = _trigram_cache_key(G)
if key is None:
return None
try:
with _trigram_cache_path(key[0]).open("rb") as fh:
blob = pickle.load(fh)
if blob.get("key") != key:
return None
# `set_cache` memoizes within one process only; it is never persisted.
return {"ids": blob["ids"], "postings": blob["postings"], "set_cache": {}}
except Exception:
return None


def _store_trigram_cache(G: nx.Graph, idx: dict) -> None:
"""Persist the index next to graph.json. Never raises; a failure just costs
the next process a rebuild."""
key = _trigram_cache_key(G)
if key is None:
return
dest = _trigram_cache_path(key[0])
tmp = dest.with_suffix(".pkl.tmp")
try:
dest.parent.mkdir(parents=True, exist_ok=True)
blob = {"key": key, "ids": idx["ids"], "postings": idx["postings"]}
with tmp.open("wb") as fh:
pickle.dump(blob, fh, protocol=5)
tmp.replace(dest) # atomic, so a partially-written file is never read back
except Exception:
try:
tmp.unlink()
except Exception:
pass


def _get_trigram_index(G: nx.Graph) -> dict:

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_get_trigram_index()

6 callers depend on it (afferent coupling).

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

"""Lazily build and cache a trigram -> node-position postings map on the graph.

Expand All @@ -376,6 +469,10 @@ def _get_trigram_index(G: nx.Graph) -> dict:
idx = G.graph.get("_trigram_index")
if idx is not None:
return idx
idx = _load_trigram_cache(G)
if idx is not None:
G.graph["_trigram_index"] = idx
return idx
ids = list(G.nodes())
postings: dict[str, array] = {}
for i, nid in enumerate(ids):
Expand All @@ -386,6 +483,7 @@ def _get_trigram_index(G: nx.Graph) -> dict:
postings[g] = bucket
bucket.append(i)
idx = {"ids": ids, "postings": postings, "set_cache": {}}
_store_trigram_cache(G, idx)
G.graph["_trigram_index"] = idx
return idx

Expand Down Expand Up @@ -1266,6 +1364,13 @@ def _find_node_tiers(
its consumers take `[0]` — which resolves by graph-iteration order when one
tier holds several nodes from different files. See `find_node_ambiguity`.
"""
# An exact node-id hit needs no search at all. Callers that already hold an
# id -- notably the second call of the ambiguity two-step, which is handed
# one in the error text -- would otherwise pay a full trigram index build to
# rediscover a node the graph can look up in constant time. A single-entry
# tier also means `find_node_ambiguity` correctly reports no ambiguity.
if G.has_node(label):
return ([], [label], [], [])
term = " ".join(_search_tokens(label))
if not term:
return []
Expand Down
Loading