feat(serve): report build provenance in graph_stats - #3056
feat(serve): report build provenance in graph_stats#3056winesoft-namjin-yun wants to merge 1 commit into
Conversation
graph_stats is the only place an MCP client can ask what it is querying, and it could not answer how old that graph is. Over MCP there is no fallback: the client cannot stat graph.json, so an agent had no way to tell a graph built minutes ago from one built last month, and answered questions about current code from either. The commit was already on disk and already consumed by the HTML report and the CLI, but json_graph.node_link_graph copies only data["graph"] onto G.graph and drops every other top-level key, so serve.py never saw it. _load_graph now lifts the provenance keys the same way the adjacent _logical_directed flag is stashed, under private names so a graph loaded on the read path cannot round-trip them into a nested data["graph"]. Every existing graph therefore gains the commit line with no rebuild. to_json also records a top-level built_at UTC stamp. It answers a different question than the commit -- which revision this describes, versus when this file was written -- and only the latter measures staleness: a graph can be a week old while sitting on a commit that is still HEAD. Consistent with that, the stamp is deliberately not preserved across a cluster-only rewrite the way Graphify-Labs#2534 preserves the commit, since cluster does not redo the extraction but does rewrite the file. It is always written rather than conditional, because a clock read cannot fail the way _git_head can outside a repo. Both graph comparators now pop built_at alongside built_at_commit. A field that changes on every write would otherwise make "did the graph change?" answer yes forever and rewrite graph.json and GRAPH_REPORT.md on every incremental run. For the same reason the stamp is injectable: the two round-trip tests assert byte-identity across two writes, which no wall-clock field can satisfy unless the caller can pin it, so they now pin the stamp exactly as they already pinned the commit. graph_stats output is asserted by full-string equality rather than substring, including the complete 40-char SHA -- a truncating regression would slip past an `in` check, which is how an earlier provenance change of mine shipped a wrong path shape behind a green endswith() assertion.
There was a problem hiding this comment.
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. 3 change(s) tested, no difference found (not proven).
Graphify review — findings
Adds build provenance to the MCP graph_stats tool: it now appends Built at: and Built from commit: lines when the graph carries them, so an agent that can't stat the file can judge how stale its answers are, and omits both lines for pre-provenance graphs so old output renders unchanged. Records a new top-level built_at UTC stamp (YYYY-MM-DDTHH:MM:SSZ) in graph.json via _utc_now_stamp, injectable like built_at_commit for byte-identical round-trips, and teaches _load_graph to stash both provenance keys under private G.graph names since node_link_graph otherwise drops every top-level key but graph. Excludes built_at from _canonical_graph_for_compare and _canonical_topology_for_compare so a field that changes on every write doesn't make every incremental rebuild look like a change and rewrite graph.json/GRAPH_REPORT.md forever.
No blocking issues surfaced. 2 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 1379 functions depend on the 883 functions this change touches.
Health — this change adds coupling hotspots:
- new:
_rebuild_code()— 98 callers, 50 callees - new:
build_merge()— 46 callers, 14 callees - new:
to_obsidian()— 36 callers, 13 callees - new:
to_json()— 53 callers, 8 callees - new:
dispatch_command()— 2 callers, 122 callees - new:
_make_graph()— 32 callers, 6 callees - new:
_query_graph_text()— 20 callers, 9 callees - new:
run_pipeline()— 8 callers, 13 callees - …and 22 more — each is listed as a finding
Verification — 1379 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: 1213 function(s) in the blast radius were not formally verified this run
Formal verification
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 \_build\_server.
The verifier did not have enough to check \_build\_server, 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: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous
Could not verify: Could not verify \_load\_graph.
The verifier did not have enough to check \_load\_graph, 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: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
No difference found (not proven): No behavior difference found in \_canonical\_graph\_for\_compare (not a proof).
The verifier ran both versions of \_canonical\_graph\_for\_compare 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.
No difference found (not proven): No behavior difference found in \_canonical\_topology\_for\_compare (not a proof).
The verifier ran both versions of \_canonical\_topology\_for\_compare 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; 29 more finding(s) on lines outside this diff (see the check run).
|
|
||
|
|
||
| def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool: | ||
| def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, built_at: str | None = None, community_labels: dict[int, str] | None = None) -> bool: |
There was a problem hiding this comment.
to_json()
fans out to 8 callees (efferent coupling); 53 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Problem
graph_statsis the only place an MCP client can ask "what am I querying?", and itcannot answer "how old is it?". An agent reading
has no way to distinguish a graph built ten minutes ago from one built last month,
and will answer questions about the current code from either. Over MCP there is no
fallback: the client cannot stat
graph.json.Provenance already exists on disk but never reaches that surface.
export.to_jsonwrites
built_at_commitat the top level, andcallflow_html.py/cli.pybothconsume it — but
serve.pynever references it, becausejson_graph.node_link_graphcopies only
data["graph"]ontoG.graphand silently drops every other top-levelkey. So the HTML report shows the commit and the MCP tools cannot.
What this changes
_load_graphlifts the provenance keys out of the raw payload ontoG.graph,the same way the existing
_logical_directedflag is stashed one line above.Private names (
_built_at,_built_at_commit) so a graph loaded on the read pathcan never round-trip these into a nested
data["graph"].graph_statsappends the provenance it finds, and nothing when there is none:Appended rather than prepended, and omitted entirely when absent, so a
pre-provenance graph renders exactly as before. Every graph already in the wild
gains the commit line with no rebuild — that half needs only the load fix.
export.to_jsonrecords a new top-levelbuilt_atUTC stamp(
YYYY-MM-DDTHH:MM:SSZ), injectable via abuilt_at=kwarg exactly likebuilt_at_commit.Why
built_atis a separate field, not derived from the commitThey answer different questions, and only one of them is about freshness:
built_at_commit— which revision this graph describes. Stable acrossre-clustering; Four silent failures with success exit codes: cluster-only ignores --backend, label prompt collides with the
Community {cid}sentinel,tree --root <abs>flattens the hierarchy, built_at_commit stamped from cwd #2534 deliberately preserves it through a cluster-only rewrite.built_at— when this file was written. This is what staleness is measuredagainst. A graph can be a week old while sitting on a commit that is still HEAD,
and the commit alone cannot tell you that.
Consistent with that split, the stamp is deliberately not preserved across a
cluster-only rewrite the way #2534 preserves the commit: cluster does not redo the
extraction (so the commit stands) but it does rewrite the file (so the stamp moves).
The stamp is always written, while the commit stays conditional — a clock read
cannot fail, so there is no "outside a repo" case to omit.
to_json'sif commit:shape is intentionally not copied.
The trap this had to avoid
A field that changes on every write is poison for
watch.py's no-op skip._canonical_graph_for_compare/_canonical_topology_for_comparedecide whether anincremental rebuild actually changed anything, and both already
pop("built_at_commit")for exactly this reason. Leaving the stamp in would make "did the graph change?"
answer yes forever, rewriting
graph.jsonandGRAPH_REPORT.mdon every singlerun. Both helpers now pop
built_attoo.The same property is why the stamp is injectable:
test_to_json_field_order_stable_across_read_rebuildasserts
graph.jsonis byte-identical across two writes, which no wall-clock fieldcan satisfy unless the caller can pin it. Those two round-trip tests already pinned
built_at_commit="fixed"; they now pin the stamp the same way, and their byte-identityguarantee is unchanged.
Testing
Full suite green (
pytest -q), plus targeted work:tests/test_export.py— exact-format assertions on the stamp(
re.fullmatch+ strictstrptime+ a UTC-vs-local drift check), verbatimwrite-through when pinned, and presence outside a git repo where the commit is
legitimately absent.
tests/test_watch.py— both comparators treat two graphs differing only inbuilt_atas equal, and still detect a real node change arriving alongside anew stamp (a test that can only pass is not a test).
tests/test_serve_http.py—graph_statsoutput asserted by full-stringequality, not substring: unchanged six lines for a graph without provenance,
both lines present with the complete 40-char SHA (a truncating regression
would slip past an
incheck), commit-only when there is no stamp, junk/non-stringvalues ignored rather than rendered as
Built at: None, and provenance followingproject_pathinstead of leaking from the server's default graph.Also verified against three real production graphs (C++ and TypeScript, 405–15,613
nodes) rather than fixtures only: the full SHA each file carries is reported exactly,
no phantom
Built at:line appears on graphs written before this change, the originalsix lines keep their order, and a round-trip with two different stamps produces
differing bytes while both comparators still report "unchanged".
The exact-format and full-SHA assertions are deliberate: a previous provenance change
of mine shipped a wrong path shape because its test asserted
endswith(...)insteadof the whole string.