From 12090a0227a14f515c6617eb1305c5fdf56c8e39 Mon Sep 17 00:00:00 2001 From: HerenderKumar Date: Thu, 23 Jul 2026 11:54:22 +0530 Subject: [PATCH] feat(export): record extractor backend/model/version in graph.json (#2077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit graph.json carried no record of which backend or model produced it, so model-vs-model comparisons were unverifiable after the fact and identical node/edge counts across runs were indistinguishable — a benchmark had to be discarded over exactly this. `to_json` now stamps a top-level `extractor` block alongside `built_at_commit` with backend, model, graphify_version and mode, threaded from the extract command's `--backend`/`--model`/`--mode`. The block is only written when there is extractor identity to record, so plain AST runs keep the existing schema. A re-write that supplies no new provenance (community labeling via `cluster-only`, watch) carries forward the block already on disk so the subsequent labeling pass the tool prompts for doesn't strip attribution. --- graphify/cli.py | 3 ++- graphify/export.py | 59 +++++++++++++++++++++++++++++++++++++++++++- tests/test_export.py | 37 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index 91df09672..0501abcfc 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3428,7 +3428,8 @@ def _invalidate_file_manifest_for_db_graph() -> None: # passing --allow-partial (the good graph is preserved and the manifest # is not stamped, so the retry re-extracts). _force_write = cli_allow_partial or not _extraction_incomplete - _wrote = _to_json(G, communities, str(graph_json_path), force=_force_write) + _wrote = _to_json(G, communities, str(graph_json_path), force=_force_write, + backend=backend, model=model, mode=extract_mode) if not _wrote: # The shrink guard refused: this partial build is smaller than the # existing graph. Exit before writing the manifest/marker below, which diff --git a/graphify/export.py b/graphify/export.py index e1f2caa99..d8ffd121d 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -229,7 +229,58 @@ def existing_graph_node_count(path: "str | Path"): return len(nodes) if isinstance(nodes, list) else MALFORMED_GRAPH -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: +_MAX_PROVENANCE_STR = 256 + + +def _graphify_version() -> str | None: + try: + from importlib.metadata import version + return version("graphifyy") + except Exception: + return None + + +def _extractor_block(backend: str | None, model: str | None, + mode: str | None) -> dict | None: + """Assemble the extraction-provenance block for graph.json (#2077). Returns + None when there is no extractor identity to record, so plain AST runs keep the + existing schema. Records what produced the graph (backend/model/mode) plus the + graphify version, so model-vs-model comparisons stay attributable.""" + def _clean(v: str | None) -> str | None: + return str(v)[:_MAX_PROVENANCE_STR] if v else None + backend, model, mode = _clean(backend), _clean(model), _clean(mode) + if not (backend or model or mode): + return None + block: dict[str, str] = {} + if backend: + block["backend"] = backend + if model: + block["model"] = model + ver = _graphify_version() + if ver: + block["graphify_version"] = ver + if mode: + block["mode"] = mode + return block + + +def _read_existing_extractor(output_path: str) -> dict | None: + """Return the `extractor` block already recorded in an existing graph.json so a + re-write that supplies no new provenance (community labeling, watch) doesn't + drop it (#2077). Size-capped and best-effort.""" + p = Path(output_path) + if not p.exists(): + return None + try: + from graphify.security import check_graph_file_size_cap + check_graph_file_size_cap(p) + block = json.loads(p.read_text(encoding="utf-8")).get("extractor") + except Exception: + return None + return block if isinstance(block, dict) and block else None + + +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, backend: str | None = None, model: str | None = None, mode: str | None = None) -> bool: # Safety check: refuse to silently shrink an existing graph (#479) existing_path = Path(output_path) if not force and existing_path.exists(): @@ -315,6 +366,12 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, commit = built_at_commit if built_at_commit is not None else _git_head() if commit: data["built_at_commit"] = commit + # Extraction provenance (#2077): stamp what produced this graph. Carry forward + # any block an earlier extract wrote when this re-write supplies none, so a + # later labeling/watch pass doesn't strip attribution. + extractor = _extractor_block(backend, model, mode) or _read_existing_extractor(output_path) + if extractor: + data["extractor"] = extractor from graphify.paths import write_json_atomic # Atomic write: a crash/ENOSPC mid-write must not truncate a good graph.json. write_json_atomic(output_path, data, indent=2) diff --git a/tests/test_export.py b/tests/test_export.py index 28a4707a7..253f68674 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -40,6 +40,43 @@ def test_to_json_nodes_have_community(): for node in data["nodes"]: assert "community" in node +def test_to_json_records_extractor_provenance(tmp_path): + """`graphify extract --backend X --model Y` must stamp graph.json so a graph + can be attributed to what produced it (#2077).""" + G = make_graph() + communities = cluster(G) + out = tmp_path / "graph.json" + to_json(G, communities, str(out), + backend="ollama", model="gemma4:12b", mode="semantic") + data = json.loads(out.read_text()) + prov = data["extractor"] + assert prov["backend"] == "ollama" + assert prov["model"] == "gemma4:12b" + assert prov["mode"] == "semantic" + assert isinstance(prov.get("graphify_version"), str) and prov["graphify_version"] + +def test_to_json_omits_extractor_block_when_no_identity(tmp_path): + """A plain AST run with no extractor identity leaves the schema unchanged.""" + G = make_graph() + communities = cluster(G) + out = tmp_path / "graph.json" + to_json(G, communities, str(out)) + assert "extractor" not in json.loads(out.read_text()) + +def test_to_json_carries_forward_extractor_on_relabel(tmp_path): + """A later community-labeling rewrite (which passes no backend/model) must not + drop the provenance recorded at extraction time (#2077).""" + G = make_graph() + communities = cluster(G) + out = tmp_path / "graph.json" + to_json(G, communities, str(out), + backend="ollama", model="gemma4:12b", mode="semantic") + # relabel pass: same graph, no provenance args (mirrors `graphify cluster-only`) + to_json(G, communities, str(out), community_labels={0: "Core"}) + prov = json.loads(out.read_text())["extractor"] + assert prov["backend"] == "ollama" + assert prov["model"] == "gemma4:12b" + def test_to_cypher_creates_file(): G = make_graph() with tempfile.TemporaryDirectory() as tmp: