Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## Unreleased

- Feature: `graphify curate` — durable human corrections that survive a rebuild. Every edge in `graph.json` is extractor-derived, so a hand correction was transient by construction, in both directions. A **deleted** edge came back: the semantic cache is keyed by file content, not graph state, so an unchanged doc keeps its cached edges and the next `extract` re-injected the edge (and wrote it with `force=True`, past the shrink guard). An **added** edge was destroyed: `build_merge` replaces per `source_file`, dropping everything belonging to a re-extracted file from the base before merging — a human-authored edge on that file is dropped, and no extractor re-emits it. Neither is reported, and the node count *grows* while the second one happens, so the loss reads as a successful update. (Measured on a 3,384-node Angular graph: one `graphify update` took it to 3,425 nodes and silently dropped 3 verified edges.) `save-result --outcome dead_end` records that an edge is false but is advisory — it informs a future reader through `LESSONS.md` and never gates extraction, so the graph and the lessons drift apart. A new `graphify-out/curation.json` overlay (`curate deny` / `curate pin` / `list` / `apply`) is now applied at the end of `build_from_json` — the funnel `build`, `build_merge`, `watch` and the skill's agent path all construct the graph through — so corrections are re-applied on every build, and clustering, god nodes and `GRAPH_REPORT.md` see the corrected graph (a disproved edge stops being re-advertised in Surprising Connections, not merely absent from `graph.json`). The `--no-cluster` write paths, which never build a NetworkX graph, apply the overlay to the payload directly. `curation.json` joins the dated-folder backup set (#834). Inert when absent; `GRAPHIFY_NO_CURATION=1` disables it.

## 0.9.15 (2026-07-13)

- Fix: detection now honors nested `.gitignore`/`.graphifyignore` files below the scan root, not just those at the scan root and above (#1847, thanks @Mohak-Agrawal). git applies a `.gitignore` to everything under its own directory, but graphify only loaded ignore files from the VCS-root-down-to-scan-root chain — so a `vendor/sub/.gitignore` deeper in the tree was never read and its exclusions leaked into the graph. Each directory's own ignore files are now read during the walk and anchored to that directory, preserving last-match-wins precedence (nearer files win, including over `.git/info/exclude`) and the parent-exclusion rule.
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,37 @@ graphify-out/cost.json # local only

---

## Curating the graph

Extraction is not perfect. Sometimes an LLM asserts an edge that doesn't exist, and sometimes it misses one you can prove is real. Fixing `graph.json` by hand does not stick:

- **A deleted edge comes back.** The semantic cache is keyed by file *content*, not by graph state, so an unchanged doc keeps its cached edges and the next `graphify extract` re-injects the edge you deleted.
- **An added edge is destroyed.** `build_merge` replaces per `source_file`: everything belonging to a re-extracted file is dropped from the base graph before merging. A hand-authored edge on that file is dropped, and no extractor re-emits it. The node count *grows* while this happens, so nothing warns you.

`graphify curate` records the correction in `graphify-out/curation.json` and re-applies it on **every** build:

```bash
# an edge the extractor keeps inventing
graphify curate deny page_not_found patient_search --relation semantically_similar_to \
--reason "lexical collision on 'not found'; no shared service, model, or import"

# an edge you verified in the source but extraction missed
graphify curate pin usage_table medicare_table --relation shares_data_with --score 0.95 \
--source-file src/usage_table.py --source-location L45 \
--reason "both read CareManagementReport via subReportLoaded"

graphify curate list # review the overlay
graphify curate apply # apply to graph.json now, without waiting for a rebuild
```

The overlay is applied when the graph is *built*, not when it is written — so clustering, god nodes and `GRAPH_REPORT.md` all see the corrected graph. A disproved edge stops being re-advertised in Surprising Connections, rather than merely vanishing from `graph.json`.

Denies are matched on the unordered pair, so endpoint order doesn't matter; omit `--relation` to deny every edge between two nodes. A pinned edge whose endpoints aren't both in the graph is skipped, never invented. `curation.json` is small, reviewable, and worth committing — it is the record of what your team has actually verified. Set `GRAPHIFY_NO_CURATION=1` to ignore it for one run.

This complements `save-result --outcome dead_end`, which records *that* a path was false for future readers of `LESSONS.md`. Curation makes the graph itself stop asserting it.

---

## Using the graph directly

```bash
Expand Down Expand Up @@ -637,6 +668,13 @@ graphify-out/
/graphify path "DigestAuth" "Response"
/graphify explain "SwinTransformer"

graphify curate deny Foo Bar --relation calls --reason "lexical collision; no call site" # mark an edge false — removed on every build
graphify curate deny Foo Bar # deny every edge between the pair, whatever the relation
graphify curate pin Foo Bar --relation shares_data_with --score 0.95 # add a verified edge — re-added on every build
graphify curate list # show the overlay (graphify-out/curation.json)
graphify curate apply # apply it to graph.json without a rebuild
GRAPHIFY_NO_CURATION=1 graphify update . # ignore the overlay for one run

graphify save-result --question "Q" --answer "A" --nodes Foo Bar --outcome useful # record how a Q&A turned out (work memory; outcome ∈ useful|dead_end|corrected)
graphify reflect # aggregate graphify-out/memory/ outcomes into reflections/LESSONS.md
graphify reflect --if-stale # no-op when LESSONS.md is already newer than every input (cheap to run each session)
Expand Down
10 changes: 10 additions & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,16 @@ def _run_cli() -> None:
print(" --outcome O work-memory signal: useful|dead_end|corrected")
print(" --correction TEXT what the right answer was (pairs with --outcome corrected)")
print(" --memory-dir DIR memory directory (default: graphify-out/memory)")
print(" curate durable human corrections that survive every rebuild")
print(" deny SRC TGT mark an edge false; removed on every build")
print(" --relation R deny only this relation (default: all edges between the pair)")
print(" --reason TEXT why it is false")
print(" --evidence REF e.g. src/foo.py:42")
print(" pin SRC TGT add a verified edge; re-added on every build")
print(" --relation R required, e.g. calls, references, shares_data_with")
print(" --confidence C EXTRACTED | INFERRED | AMBIGUOUS (default: INFERRED)")
print(" list show the current overlay")
print(" apply apply the overlay to graph.json without a rebuild")
print(" reflect aggregate graphify-out/memory/ outcomes into a deterministic lessons doc")
print(" --memory-dir DIR memory directory (default: graphify-out/memory)")
print(" --out FILE output path (default: graphify-out/reflections/LESSONS.md)")
Expand Down
34 changes: 31 additions & 3 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import unicodedata
from pathlib import Path
import networkx as nx
from .curation import apply_curation, format_stats, load_curation
from .ids import make_id, normalize_id as _normalize_id
from .paths import default_graph_json as _default_graph_json
from .validate import validate_extraction
Expand Down Expand Up @@ -380,13 +381,24 @@ def _doc_twin_remap(nodes: list) -> dict[str, str]:
return remap


def build_from_json(extraction: dict, *, directed: bool = False, root: str | Path | None = None) -> nx.Graph:
def build_from_json(
extraction: dict,
*,
directed: bool = False,
root: str | Path | None = None,
curation: dict | None = None,
) -> nx.Graph:
"""Build a NetworkX graph from an extraction dict.

directed=True produces a DiGraph that preserves edge direction (source→target).
directed=False (default) produces an undirected Graph for backward compatibility.
root: if given, absolute source_file paths from semantic subagents are made
relative to root so all nodes share a consistent path key (#932).
curation: user-owned overlay applied last (denied edges removed, verified edges
pinned). Defaults to loading ``graphify-out/curation.json``; pass an explicit
dict to override, or ``{}`` to skip. Applied here rather than at write time so
clustering, god-nodes and GRAPH_REPORT.md all see the corrected graph — a
disproved edge must stop being re-advertised, not merely stop being written.
"""
_root = str(Path(root).resolve()) if root else None
# NetworkX <= 3.1 serialised edges as "links"; remap to "edges" for compatibility.
Expand Down Expand Up @@ -759,6 +771,17 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
if isinstance(he, dict) and he.get("source_file"):
he["source_file"] = _norm_source_file(he["source_file"], _root)
G.graph["hyperedges"] = hyperedges

# Curation is applied LAST, on every build path (build, build_merge, watch, and
# the skill's agent path all funnel through here). Human corrections are otherwise
# transient: build_merge's replace-per-source drops pinned edges belonging to a
# re-extracted file, and the content-keyed semantic cache resurrects denied ones.
_curation = load_curation() if curation is None else curation
if _curation:
_stats = apply_curation(G, _curation)
_msg = format_stats(_stats)
if _msg:
print(_msg, file=sys.stderr)
return G


Expand All @@ -769,6 +792,7 @@ def build(
dedup: bool = True,
dedup_llm_backend: str | None = None,
root: str | Path | None = None,
curation: dict | None = None,
) -> nx.Graph:
"""Merge multiple extraction results into one graph.

Expand Down Expand Up @@ -797,7 +821,7 @@ def build(
combined["nodes"], combined["edges"], communities={},
dedup_llm_backend=dedup_llm_backend,
)
return build_from_json(combined, directed=directed, root=root)
return build_from_json(combined, directed=directed, root=root, curation=curation)


def _norm_label(label: str | None) -> str:
Expand Down Expand Up @@ -870,6 +894,7 @@ def build_merge(
directed: bool = False,
dedup: bool = True,
dedup_llm_backend: str | None = None,
curation: dict | None = None,
root: str | Path | None = None,
) -> nx.Graph:
"""Load existing graph.json, merge new chunks into it, and save back.
Expand Down Expand Up @@ -952,7 +977,10 @@ def _kept(item: dict) -> bool:
base = [{"nodes": existing_nodes, "edges": existing_edges}] if had_graph else []

all_chunks = base + list(new_chunks)
G = build(all_chunks, directed=directed, dedup=dedup, dedup_llm_backend=dedup_llm_backend, root=root)
G = build(
all_chunks, directed=directed, dedup=dedup,
dedup_llm_backend=dedup_llm_backend, root=root, curation=curation,
)

# Prune set for deleted source files — both the raw form (matches nodes that
# kept absolute source_file) and the normalised relative form (matches nodes
Expand Down
165 changes: 165 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,161 @@ def dispatch_command(cmd: str) -> None:
correction=opts.correction,
)
print(f"Saved to {out}")
elif cmd == "curate":
# graphify curate deny SRC TGT [--relation R] [--reason TEXT] [--evidence REF]
# graphify curate pin SRC TGT --relation R [--confidence C] [--score F]
# [--source-file F] [--source-location L] [--reason TEXT]
# graphify curate list
# graphify curate apply [--graph PATH]
import argparse as _ap

from graphify.curation import (
CURATION_SCHEMA_VERSION,
apply_curation_to_payload,
curation_path,
empty_curation,
format_stats,
load_curation,
save_curation,
)

p = _ap.ArgumentParser(
prog="graphify curate",
description=(
"Durable human corrections. Denied edges stay denied and pinned edges "
"stay pinned across rebuilds, which delete-from-graph.json alone does not."
),
)
sub = p.add_subparsers(dest="action", required=True)

p_deny = sub.add_parser("deny", help="mark an edge false; it is removed on every build")
p_deny.add_argument("source")
p_deny.add_argument("target")
p_deny.add_argument("--relation", default=None,
help="deny only this relation (default: every edge between the pair)")
p_deny.add_argument("--reason", default=None)
p_deny.add_argument("--evidence", default=None, help="e.g. src/foo.py:42")

p_pin = sub.add_parser("pin", help="add a verified edge; it is re-added on every build")
p_pin.add_argument("source")
p_pin.add_argument("target")
p_pin.add_argument("--relation", required=True)
p_pin.add_argument("--confidence", default="INFERRED",
choices=("EXTRACTED", "INFERRED", "AMBIGUOUS"))
p_pin.add_argument("--score", type=float, default=None, dest="confidence_score")
p_pin.add_argument("--source-file", default=None, dest="source_file")
p_pin.add_argument("--source-location", default=None, dest="source_location")
p_pin.add_argument("--reason", default=None)

sub.add_parser("list", help="show the current overlay")

p_apply = sub.add_parser(
"apply", help="apply the overlay to an existing graph.json without a rebuild")
p_apply.add_argument("--graph", default=str(Path(_GRAPHIFY_OUT) / "graph.json"))

for _sp in (p_deny, p_pin):
_sp.add_argument("--out-dir", default=_GRAPHIFY_OUT)

opts = p.parse_args(sys.argv[2:])

if opts.action == "list":
cur = load_curation(_GRAPHIFY_OUT)
if not cur:
print(f"No curation overlay at {curation_path(_GRAPHIFY_OUT)}")
return
denies = cur.get("deny_edges", [])
adds = cur.get("add_edges", [])
print(f"{curation_path(_GRAPHIFY_OUT)} (schema v{cur.get('version', '?')})")
print(f"\nDENIED ({len(denies)}):")
for e in denies:
rel = e.get("relation") or "*"
why = f" — {e['reason']}" if e.get("reason") else ""
ev = f" [{e['evidence']}]" if e.get("evidence") else ""
print(f" {e.get('source')} --{rel}-- {e.get('target')}{why}{ev}")
print(f"\nPINNED ({len(adds)}):")
for e in adds:
why = f" — {e['reason']}" if e.get("reason") else ""
print(
f" {e.get('source')} --{e.get('relation')}--> {e.get('target')} "
f"[{e.get('confidence', 'INFERRED')}]{why}"
)
return

if opts.action == "apply":
graph_path = Path(opts.graph)
if not graph_path.exists():
print(f"error: no graph at {graph_path}", file=sys.stderr)
raise SystemExit(1)
cur = load_curation(graph_path.parent)
if not cur:
print(f"No curation overlay at {curation_path(graph_path.parent)}")
return
data = json.loads(graph_path.read_text(encoding="utf-8"))
stats = apply_curation_to_payload(data, cur)
msg = format_stats(stats)
if msg is None:
print("[graphify] curation: graph already matches the overlay")
return
graph_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
print(msg)
print(f"[graphify] wrote {graph_path}")
return

# deny / pin — mutate the overlay file
cur = load_curation(opts.out_dir) or empty_curation()
cur.setdefault("version", CURATION_SCHEMA_VERSION)
cur.setdefault("deny_edges", [])
cur.setdefault("add_edges", [])

if opts.action == "deny":
entry = {"source": opts.source, "target": opts.target}
if opts.relation:
entry["relation"] = opts.relation
if opts.reason:
entry["reason"] = opts.reason
if opts.evidence:
entry["evidence"] = opts.evidence
dupe = any(
e.get("source") == entry["source"]
and e.get("target") == entry["target"]
and e.get("relation") == entry.get("relation")
for e in cur["deny_edges"]
)
if dupe:
print("[graphify] curation: already denied")
return
cur["deny_edges"].append(entry)
path = save_curation(cur, opts.out_dir)
rel = opts.relation or "*"
print(f"[graphify] denied {opts.source} --{rel}-- {opts.target} in {path}")
print("[graphify] the edge is removed on every build from now on")
return

entry = {
"source": opts.source,
"target": opts.target,
"relation": opts.relation,
"confidence": opts.confidence,
}
if opts.confidence_score is not None:
entry["confidence_score"] = opts.confidence_score
for k in ("source_file", "source_location", "reason"):
if getattr(opts, k, None):
entry[k] = getattr(opts, k)
dupe = any(
e.get("source") == entry["source"]
and e.get("target") == entry["target"]
and e.get("relation") == entry["relation"]
for e in cur["add_edges"]
)
if dupe:
print("[graphify] curation: already pinned")
return
cur["add_edges"].append(entry)
path = save_curation(cur, opts.out_dir)
print(f"[graphify] pinned {opts.source} --{opts.relation}--> {opts.target} in {path}")
print("[graphify] the edge is re-added on every build from now on")
return
elif cmd == "reflect":
import argparse as _ap

Expand Down Expand Up @@ -2487,6 +2642,16 @@ def _progress(idx: int, total: int, _result: dict) -> None:
_node_sf.get(_e.get("source")) or _node_sf.get(_e.get("target")) or ""
)
_backup(graphify_out)
# --no-cluster never builds a NetworkX graph, so it bypasses
# build_from_json's curation pass. Apply to the payload directly.
from graphify.curation import (
apply_curation_to_payload as _apply_cur,
format_stats as _cur_stats,
load_curation as _load_cur,
)
_cur_msg = _cur_stats(_apply_cur(merged, _load_cur(graphify_out)))
if _cur_msg:
print(_cur_msg)
graph_json_path.write_text(
json.dumps(merged, indent=2), encoding="utf-8"
)
Expand Down
Loading