Skip to content

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

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

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

Conversation

@winesoft-namjin-yun

Copy link
Copy Markdown
Contributor

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, and callflow_html.py / cli.py both
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. So the HTML report shows the commit and the MCP tools cannot.

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"].

  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 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.

  3. export.to_json records a new top-level built_at UTC stamp
    (YYYY-MM-DDTHH:MM:SSZ), injectable via a built_at= kwarg exactly like
    built_at_commit.

Why built_at is a separate field, not derived from the commit

They answer different questions, and only one of them is about freshness:

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's if 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_compare decide whether an
incremental 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.json and GRAPH_REPORT.md on every single
run. Both helpers now pop built_at too.

The same property is why the stamp is injectable: test_to_json_field_order_stable_across_read_rebuild
asserts graph.json is byte-identical across two writes, which no wall-clock field
can 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-identity
guarantee is unchanged.

Testing

Full suite green (pytest -q), plus targeted work:

  • tests/test_export.py — exact-format assertions on the stamp
    (re.fullmatch + strict strptime + a UTC-vs-local drift check), verbatim
    write-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 in
    built_at as equal, and still detect a real node change arriving alongside a
    new stamp (a test that can only pass is not a test).
  • tests/test_serve_http.pygraph_stats output asserted by full-string
    equality
    , 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 in check), commit-only when there is no stamp, junk/non-string
    values ignored rather than rendered as Built at: None, and provenance following
    project_path instead 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 original
six 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(...) instead
of the whole string.

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.

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

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); 53 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.

1 participant