feat(serve): report build provenance in graph_stats - #3355
feat(serve): report build provenance in graph_stats#3355winesoft-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. Refs Graphify-Labs#3354.
There was a problem hiding this comment.
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. 3 change(s) tested, no difference found (not proven).
Graphify review — findings
Adds a built_at wall-clock stamp to graph.json: to_json writes a fixed-width UTC YYYY-MM-DDTHH:MM:SSZ string via _utc_now_stamp, injectable through a new built_at parameter for byte-stable round-trips, and always present since a clock reading never fails. Surfaces both built_at and built_at_commit in the graph_stats MCP tool — _load_graph now stashes these top-level keys under _built_at/_built_at_commit (they'd otherwise be dropped by node_link_graph), appended only when present so pre-provenance graphs render identically. Excludes built_at from _canonical_graph_for_compare and _canonical_topology_for_compare so the ever-changing stamp doesn't defeat the incremental no-op skip.
Worth a look
- to_json now writes a new top-level built_at key by default —
graphify/export.py:429· 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 — 1237 functions depend on the 751 functions this change touches.
Health — this change adds coupling hotspots:
- new:
_rebuild_code()— 113 callers, 50 callees - new:
to_obsidian()— 38 callers, 14 callees - new:
to_json()— 59 callers, 8 callees - new:
dispatch_command()— 2 callers, 124 callees - new:
_make_graph()— 33 callers, 6 callees - new:
_query_graph_text()— 21 callers, 9 callees - new:
run_pipeline()— 8 callers, 13 callees - new:
to_canvas()— 20 callers, 5 callees - …and 21 more — each is listed as a finding
Verification — 1237 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: 1062 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; 28 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); 59 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Closes #3354.
Problem
graph_statsis the only place an MCP client can ask "what am I querying?", and it cannot answer "how old is it?". An agent readinghas 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_jsonwritesbuilt_at_commitat the top level (export.py:407), andcallflow_html.py:299/cli.py:2278/report.py:172all consume it — butserve.pynever references it, becausejson_graph.node_link_graphcopies onlydata["graph"]ontoG.graphand silently drops every other top-level key.grep -c built_at_commit graphify/serve.pyreturns0on 0.9.54.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 path can never round-trip these into a nesteddata["graph"]. Every existing graph gains the commit line with no rebuild.graph_statsappends the provenance it finds, and nothing when there is none:Appended rather than prepended, and each line omitted when its field is absent, so a pre-provenance graph renders exactly as it does today.
to_jsonrecords a top-levelbuilt_atUTC stamp (YYYY-MM-DDTHH:MM:SSZ). It answers a different question than the commit — when the file was written, not which revision it describes — and only the stamp measures staleness: a graph can be a week old while sitting on a commit that is stillHEAD. Always written rather than conditional, because a clock read cannot fail the way_git_headcan outside a repo.Three constraints the patch respects
graph.jsonis byte-identical across two writes; no wall-clock field can satisfy that unless the caller can pin it. Those tests now pin the stamp exactly as they already pinned the commit._canonical_graph_for_compareand_canonical_topology_for_comparepopbuilt_atalongsidebuilt_at_commit. A field that changes on every write would otherwise make "did the graph change?" answer yes forever, rewritinggraph.jsonandGRAPH_REPORT.mdon every incremental run and destroying the no-op skip those helpers exist for.Community {cid}sentinel,tree --root <abs>flattens the hierarchy, built_at_commit stamped from cwd #2534 preserves the commit. The commit describes the extraction, which--no-clusterdoes not redo; the stamp describes the file, which it does rewrite.Tests
10 new tests across three files (17 instances, one is parametrised over 8 junk values), plus the four
to_jsoncall sites in the existing byte-identity round-trip tests, which now pin the stamp the way they already pinned the commit:test_export.py— the stamp's exact UTC format, verbatim write when pinned, and presence outside a git repo (where the commit is absent).test_serve_http.py— driven through the real MCP HTTP app: unchanged six-line output for a graph with no provenance (asserted as full-string equality, not a substring, so a placeholder row would fail), both fields present, commit-only, non-string junk ignored, and provenance following a--projectpath.test_watch.py— both comparators ignore a new stamp, and still detect a real change that arrives alongside one.graph_statsoutput is asserted by full-string equality including the complete 40-char SHA. A truncating regression would slip past anincheck — which is how an earlier provenance change of mine shipped a wrong path shape behind a greenendswith()assertion (#3223).Verification
Full suite on this branch and on
v8(937e59a) with the same interpreter: identical failure sets, 115 failures on both, all pre-existing Windows/locale issues intest_export.py's GraphML/HTML readers and twotest_watch.pycwd-deletion cases. This branch adds 17 passing tests and no new failures.History
This supersedes #3056, which was opened against 0.9.49 and has been closed. Same change, rebased onto 0.9.54, re-verified, with the CHANGELOG entry dropped — that file looks like maintainer bookkeeping rather than something a contributor PR should touch.