Skip to content

feat(serve): report build provenance in graph_stats - #3355

Open
winesoft-namjin-yun wants to merge 1 commit into
Graphify-Labs:v8from
winesoft-namjin-yun:feat/mcp-graph-stats-provenance
Open

feat(serve): report build provenance in graph_stats#3355
winesoft-namjin-yun wants to merge 1 commit into
Graphify-Labs:v8from
winesoft-namjin-yun:feat/mcp-graph-stats-provenance

Conversation

@winesoft-namjin-yun

Copy link
Copy Markdown
Contributor

Closes #3354.

Problem

graph_stats is the only place an MCP client can ask "what am I querying?", and it cannot answer "how old is it?". An agent reading

Nodes: 15613
Edges: 40764
Communities: 480
EXTRACTED: 96%
INFERRED: 4%
AMBIGUOUS: 0%

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_json writes built_at_commit at the top level (export.py:407), and callflow_html.py:299 / cli.py:2278 / report.py:172 all consume it — but serve.py never references it, because json_graph.node_link_graph copies only data["graph"] onto G.graph and silently drops every other top-level key. grep -c built_at_commit graphify/serve.py returns 0 on 0.9.54.

What this changes

  1. _load_graph lifts the provenance keys out of the raw payload onto G.graph, the same way the existing _logical_directed flag 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 nested data["graph"]. Every existing graph gains the commit line with no rebuild.

  2. graph_stats appends the provenance it finds, and nothing when there is none:

    Built at: 2026-08-25T09:15:42Z
    Built from commit: d6ff04064219c45e6cb1aeda8e66c292b6650307
    

    Appended rather than prepended, and each line omitted when its field is absent, so a pre-provenance graph renders exactly as it does today.

  3. to_json records a top-level built_at UTC 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 still HEAD. Always written rather than conditional, because a clock read cannot fail the way _git_head can outside a repo.

Three constraints the patch respects

Tests

10 new tests across three files (17 instances, one is parametrised over 8 junk values), plus the four to_json call 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 --project path.
  • test_watch.py — both comparators ignore a new stamp, and still detect a real change that arrives alongside one.

graph_stats output is asserted by full-string equality 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 (#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 in test_export.py's GraphML/HTML readers and two test_watch.py cwd-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.

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.

@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. 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 defaultgraphify/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).

Comment thread graphify/export.py


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:

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

fans out to 8 callees (efferent coupling); 59 callers depend on it (afferent coupling).

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

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.

graph_stats cannot tell an agent how stale its graph is: built_at_commit is dropped by _load_graph, and no build timestamp is recorded at all

1 participant