diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b0113b4..839c598f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- **memory network — the kb as a graph, pending included** (#604): vouch knew + the shape of its own knowledge and had no way to *look* at it. `vouch graph` + and `kb.graph_export` gain `format=json` — `{nodes: [{id, kind, label, + status}], edges: [{src, dst, kind}]}` — and the console gains a **Memory** + view that renders it as a pannable, zoomable graph where clicking a node + opens the existing artifact drawer. `dot` and `mermaid` are untouched. + The load-bearing part is `status`: **pending proposals are now graph nodes**, + keyed on the proposal id rather than the artifact id their payload would + create, wired in by the same edge kinds a durable artifact uses (`cites` to + the sources they quote, `embeds` to the claims a proposed page would collect, + `proposedIn` to the filing session) plus one new kind, `targets`, for the + artifact a pending delete would remove. A graph that shows only approved + knowledge hides exactly the part a reviewer is there to look at; colouring by + status makes the review gate the thing you see. Kind, status and label are + captured during the build and cached in a new `prov_nodes` table, so `json` + costs no file reads per node — it is as cheap as `dot`, and a cache-loaded + graph reports the same facts as a freshly built one. - **bench: composite guards** (#616): `efficiency`, `consistency` and `canary` as bounded multipliers over the composite, plus a `bench_version` stamp on every report. Reported **beside** the composite, never folded into it — diff --git a/docs/provenance.md b/docs/provenance.md index 915aa68b..59835193 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -30,10 +30,25 @@ on B*, so `why` walks outward and `impact` walks inward. | `embeds` | page → claim | `page.claims` | | `proposedIn` | claim → session | approved proposal `session_id` | | `approvedBy` | claim → audit event | `proposal.*.approve` log entry | +| `targets` | pending delete → artifact | delete proposal payload | The two `*By` mirrors are computed at query time by walking inbound, so the -`prov_edges` cache stays free of duplicate rows — only the seven canonical kinds -are persisted. +`prov_edges` cache stays free of duplicate rows — only the canonical kinds are +persisted. + +## The pending frontier + +Pending proposals are nodes too. A proposal is keyed on its own id rather than +on the artifact id its payload would create — until approval that artifact does +not exist, and the prospective id may already be taken — and it hangs off the +graph by the same edge kinds a durable artifact uses: `cites` to the sources it +quotes, `embeds` to the claims a proposed page would collect, `proposedIn` to +the session that filed it, and `targets` to the artifact a delete would remove. + +The only thing separating a pending node from an approved one is its `status`. +That is the point: the review gate is the product, and a picture of the KB that +shows only what has already been approved hides the part a reviewer is there to +look at. ## Commands @@ -44,6 +59,7 @@ vouch trace --to # shortest typed path between two artifa vouch impact # forward: pages, downstream claims that depend on it vouch impact --if archive # dry-run a lifecycle op; exits non-zero if it breaks something vouch graph --session # render the DAG for one agent run as dot/mermaid +vouch graph --format json # nodes + edges for a renderer, status included vouch provenance rebuild # rebuild the prov_edges cache from durable files ``` @@ -62,6 +78,25 @@ vouch provenance rebuild # rebuild the prov_edges cache from dura Reviewer output is bare prose + indentation — no curses, no colours by default — so it diffs cleanly into a `gh pr comment` or a session log. +## Graph export formats + +`dot` (default) and `mermaid` render diagram text. `json` returns the graph +itself for a renderer that lays it out: + +```json +{ + "nodes": [{"id": "c-new", "kind": "claim", "label": "the newer fact", + "status": "working"}], + "edges": [{"src": "page-alpha", "dst": "c-new", "kind": "embeds"}] +} +``` + +`kind`, `status` and `label` are captured while the graph is built and carried +on it, so serialising costs no file reads — `json` is exactly as cheap as `dot`. +Structural nodes (sources, sessions, audit events) have no review status of +their own and report `""`. The console's Memory view is the first consumer; +`webapp/src/views/MemoryNetworkView.tsx` colours each node by `status`. + ## `kb.*` methods The same surface is reachable over every transport (MCP stdio, JSONL, HTTP): @@ -78,17 +113,17 @@ They appear in `kb.capabilities` and pass the JSONL capabilities cross-check. ## The cache -The `prov_edges(src_id, dst_id, kind, event_ts, session_id)` table in `state.db` -is a derived index, gitignored alongside the rest of the cache. A freshness -stamp (claim count + page count + audit-event count) lets a cold query decide -whether the cache can be trusted; when stale, `load_graph` rebuilds it +The `prov_edges(src_id, dst_id, kind, event_ts, session_id)` and +`prov_nodes(id, kind, status, label)` tables in `state.db` are a derived index, +gitignored alongside the rest of the cache. A freshness stamp (claim count + +page count + audit-event count + pending-proposal count) lets a cold query +decide whether the cache can be trusted; when stale, `load_graph` rebuilds it transparently. Correctness never depends on the cache — a rebuild is always an -exact reconstruction of the live in-memory build, which a CI test asserts. +exact reconstruction of the live in-memory build, which a CI test asserts, and +a cache-loaded graph reports the same kind, status and label as a fresh one. ## Out of scope -- A graphical web visualization of the DAG — a natural extension of the - `review-ui`, not this. - Mutating the graph directly; provenance is derived state. - Cross-KB / federated provenance. - Embedding-based "semantic neighbors" — provenance edges are strictly the diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 1f1aebc8..ff82c2cb 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -3940,15 +3940,18 @@ def impact(claim_id: str, depth: int, if_op: str | None, as_json: bool) -> None: "fmt", default="dot", show_default=True, - type=click.Choice(["dot", "mermaid"]), + type=click.Choice(["dot", "mermaid", "json"]), help="Output format for the DAG.", ) def graph(session: str | None, fmt: str) -> None: - """Render the provenance DAG as Graphviz dot or a mermaid flowchart.""" + """Render the provenance DAG as Graphviz dot, mermaid, or json.""" store = _load_store() with _cli_errors(): - text = prov_mod.graph_export(store, session=session, fmt=fmt) - click.echo(text, nl=False) + rendered = prov_mod.graph_export(store, session=session, fmt=fmt) + if isinstance(rendered, dict): + click.echo(json.dumps(rendered, indent=2)) + return + click.echo(rendered, nl=False) @cli.group(name="agents") diff --git a/src/vouch/index_db.py b/src/vouch/index_db.py index dc450697..042023e2 100644 --- a/src/vouch/index_db.py +++ b/src/vouch/index_db.py @@ -86,6 +86,13 @@ CREATE INDEX IF NOT EXISTS prov_edges_dst ON prov_edges(dst_id); CREATE INDEX IF NOT EXISTS prov_edges_kind ON prov_edges(kind); + +CREATE TABLE IF NOT EXISTS prov_nodes ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT '', + label TEXT NOT NULL DEFAULT '' +); """ @@ -127,6 +134,7 @@ def reset(kb_dir: Path) -> None: "DELETE FROM query_embedding_cache;" "DELETE FROM embedding_dupes;" "DELETE FROM prov_edges;" + "DELETE FROM prov_nodes;" "DELETE FROM index_meta WHERE key LIKE 'embedding_%';" "DELETE FROM index_meta WHERE key LIKE 'prov_%';" ) @@ -212,6 +220,7 @@ def deindex(conn: sqlite3.Connection, *, kind: str, id: str) -> None: conn.execute( "DELETE FROM prov_edges WHERE src_id = ? OR dst_id = ?", (id, id) ) + conn.execute("DELETE FROM prov_nodes WHERE id = ?", (id,)) # --- provenance edges (derived cache for `vouch why/trace/impact`) -------- @@ -219,6 +228,7 @@ def deindex(conn: sqlite3.Connection, *, kind: str, id: str) -> None: def clear_prov_edges(conn: sqlite3.Connection) -> None: conn.execute("DELETE FROM prov_edges") + conn.execute("DELETE FROM prov_nodes") def index_prov_edge( @@ -242,6 +252,26 @@ def read_prov_edges(kb_dir: Path) -> list[tuple[str, str, str, str, str | None]] return [(r[0], r[1], r[2], r[3], r[4]) for r in rows] +def index_prov_node( + conn: sqlite3.Connection, *, id: str, kind: str, status: str = "", + label: str = "", +) -> None: + conn.execute( + "INSERT OR REPLACE INTO prov_nodes (id, kind, status, label) " + "VALUES (?, ?, ?, ?)", + (id, kind, status, label), + ) + + +def read_prov_nodes(kb_dir: Path) -> list[tuple[str, str, str, str]]: + """Return the cached node facts (kind, status, label), ordered by id.""" + with open_db(kb_dir) as conn: + rows = conn.execute( + "SELECT id, kind, status, label FROM prov_nodes ORDER BY id" + ).fetchall() + return [(r[0], r[1], r[2], r[3]) for r in rows] + + def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None: conn.execute( "INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)", diff --git a/src/vouch/provenance/__init__.py b/src/vouch/provenance/__init__.py index fdb0f521..c6af75b1 100644 --- a/src/vouch/provenance/__init__.py +++ b/src/vouch/provenance/__init__.py @@ -15,8 +15,8 @@ * ``trace`` — the shortest typed-edge path between two artifacts. The graph is *derived state*. Nothing here is a source of truth: every edge is -rebuilt from durable files, and the persistent ``prov_edges`` table in -``state.db`` is a disposable cache that ``vouch provenance rebuild`` +rebuilt from files on disk, and the persistent ``prov_edges`` / ``prov_nodes`` +tables in ``state.db`` are a disposable cache that ``vouch provenance rebuild`` reconstructs byte-for-byte. All mutations still flow through the existing proposal + lifecycle code paths. """ @@ -25,7 +25,7 @@ from .cache import load_graph, prov_stamp, rebuild_prov_edges from .graph import ProvGraph, build_graph -from .model import Edge, EdgeKind, NodeKind +from .model import Edge, EdgeKind, NodeKind, NodeMeta from .query import ( LifecycleOp, graph_export, @@ -42,6 +42,7 @@ "EdgeKind", "LifecycleOp", "NodeKind", + "NodeMeta", "ProvGraph", "build_graph", "graph_export", diff --git a/src/vouch/provenance/cache.py b/src/vouch/provenance/cache.py index 0debfb35..322598c4 100644 --- a/src/vouch/provenance/cache.py +++ b/src/vouch/provenance/cache.py @@ -1,11 +1,12 @@ """Persist and reload the provenance DAG via the ``prov_edges`` table. The cache is a pure acceleration: :func:`rebuild_prov_edges` serialises exactly -what :func:`~.graph.build_graph` produces, and :func:`load_graph` returns a graph +what :func:`~.graph.build_graph` produces — edges into ``prov_edges``, node kind +/ status / label into ``prov_nodes`` — and :func:`load_graph` returns a graph backed by the cache when it is fresh, transparently rebuilding it when the KB has changed underneath. A freshness *stamp* (claim count + page count + audit event -count) is stored alongside the rows so a cold query knows whether the cache can -be trusted without re-reading every file. +count + pending proposal count) is stored alongside the rows so a cold query +knows whether the cache can be trusted without re-reading every file. """ from __future__ import annotations @@ -13,22 +14,31 @@ from .. import audit, index_db from ..storage import KBStore from .graph import ProvGraph, build_graph -from .model import Edge, EdgeKind +from .model import Edge, EdgeKind, NodeKind, NodeMeta _STAMP_KEY = "prov_stamp" +def _pending_count(store: KBStore) -> int: + """Pending proposals on disk, counted without parsing them.""" + return sum(1 for _ in (store.kb_dir / "proposed").glob("*.yaml")) + + def prov_stamp(store: KBStore) -> str: """A cheap fingerprint of the inputs the graph is derived from. Counting beats hashing here: it is O(files) without parsing every claim, - and any propose/approve/lifecycle action changes at least one of the three - counts (the audit log is append-only, so its count is monotonic). + and any propose/approve/lifecycle action changes at least one of the four + counts (the audit log is append-only, so its count is monotonic). The + pending count is what a proposal filed and then rejected moves, and its + presence in the stamp is also what invalidates every cache written before + pending proposals were graph nodes. """ claims = len(store.list_claims()) pages = len(store.list_pages()) events = audit.count_events(store.kb_dir) - return f"{claims}:{pages}:{events}" + pending = _pending_count(store) + return f"{claims}:{pages}:{events}:{pending}" def rebuild_prov_edges(store: KBStore) -> int: @@ -50,6 +60,14 @@ def rebuild_prov_edges(store: KBStore) -> int: event_ts=e.event_ts, session_id=e.session_id, ) + for node, meta in sorted(graph.meta().items()): + index_db.index_prov_node( + conn, + id=node, + kind=meta.kind.value, + status=meta.status, + label=meta.label, + ) index_db.set_meta(conn, _STAMP_KEY, stamp) return len(graph.edges) @@ -69,6 +87,18 @@ def load_edges(store: KBStore) -> list[Edge]: ] +def load_meta(store: KBStore) -> dict[str, NodeMeta]: + """Load cached node facts. An id whose kind is no longer known is dropped.""" + meta: dict[str, NodeMeta] = {} + for node, kind, status, label in index_db.read_prov_nodes(store.kb_dir): + try: + parsed = NodeKind(kind) + except ValueError: + continue + meta[node] = NodeMeta(parsed, status, label) + return meta + + def load_graph(store: KBStore, *, use_cache: bool = True) -> ProvGraph: """Return a provenance graph, using the cache when fresh. @@ -82,7 +112,6 @@ def load_graph(store: KBStore, *, use_cache: bool = True) -> ProvGraph: cached_stamp = index_db.get_meta(store.kb_dir, _STAMP_KEY) except Exception: cached_stamp = None - if cached_stamp is not None and cached_stamp == prov_stamp(store): - return ProvGraph(load_edges(store)) - rebuild_prov_edges(store) - return ProvGraph(load_edges(store)) + if cached_stamp is None or cached_stamp != prov_stamp(store): + rebuild_prov_edges(store) + return ProvGraph(load_edges(store), load_meta(store)) diff --git a/src/vouch/provenance/graph.py b/src/vouch/provenance/graph.py index 890b8258..5176c517 100644 --- a/src/vouch/provenance/graph.py +++ b/src/vouch/provenance/graph.py @@ -9,19 +9,20 @@ from __future__ import annotations from collections import deque -from collections.abc import Iterable +from collections.abc import Callable, Iterable, Mapping +from typing import Any from .. import audit -from ..models import PageStatus, ProposalKind, ProposalStatus +from ..models import PageStatus, Proposal, ProposalKind, ProposalStatus from ..storage import ArtifactNotFoundError, KBStore -from .model import Edge, EdgeKind, NodeKind, sort_edges +from .model import Edge, EdgeKind, NodeKind, NodeMeta, sort_edges class ProvGraph: """An in-memory typed DAG with outward/inward/undirected traversal.""" def __init__( - self, edges: Iterable[Edge], node_kinds: dict[str, NodeKind] | None = None + self, edges: Iterable[Edge], node_meta: Mapping[str, NodeMeta] | None = None ) -> None: self.edges: list[Edge] = sort_edges(edges) self._out: dict[str, list[Edge]] = {} @@ -29,23 +30,39 @@ def __init__( for e in self.edges: self._out.setdefault(e.src_id, []).append(e) self._in.setdefault(e.dst_id, []).append(e) - self._node_kinds: dict[str, NodeKind] = dict(node_kinds or {}) + self._meta: dict[str, NodeMeta] = dict(node_meta or {}) # --- node introspection ------------------------------------------------- def nodes(self) -> set[str]: return set(self._out) | set(self._in) + def meta(self) -> dict[str, NodeMeta]: + """Every node the build recorded, including any with no edges.""" + return dict(self._meta) + + def status_of(self, node: str) -> str: + """The node's review status, or ``""`` for nodes that have none.""" + known = self._meta.get(node) + return known.status if known is not None else "" + + def label_of(self, node: str) -> str: + """The node's own words, falling back to its id.""" + known = self._meta.get(node) + return known.label if known is not None and known.label else node + def kind_of(self, node: str) -> NodeKind: """Best-effort node kind, inferred from incident edges when unknown. - Inference keeps cache-loaded graphs (which carry no explicit kind map) - as informative as freshly-built ones. + Inference keeps graphs built from a bare edge list (an older cache, a + hand-assembled one in a test) as informative as freshly-built ones. """ - known = self._node_kinds.get(node) + known = self._meta.get(node) if known is not None: - return known + return known.kind for e in self._out.get(node, []): + if e.kind is EdgeKind.TARGETS: + return NodeKind.PROPOSAL if e.kind in ( EdgeKind.CITES, EdgeKind.SUPERSEDES, @@ -136,15 +153,71 @@ def _reconstruct( return chain +#: The two accumulators `build_graph` hands to its per-artifact helpers. +_AddEdge = Callable[[str, str, EdgeKind, str, str | None], None] +_NoteNode = Callable[[str, NodeKind, str, str], None] + + +def _proposal_label(payload: Mapping[str, Any]) -> str: + """A pending proposal's own words — the same fallback ``vouch pending`` uses.""" + for key in ("text", "title", "name"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _payload_refs(payload: Mapping[str, Any], key: str) -> list[str]: + """String ids under ``key``, tolerating a payload that predates the field.""" + value = payload.get(key) + if not isinstance(value, list): + return [] + return [ref for ref in value if isinstance(ref, str)] + + +def _add_pending(pr: Proposal, add: _AddEdge, note: _NoteNode) -> None: + """Wire one pending proposal into the graph. + + The node is keyed on the *proposal* id rather than on the artifact id the + payload would create: until approval that artifact does not exist, and a + proposal's prospective id may already be taken by a claim on disk — the + collision `approve` exists to catch. Edges reuse the durable kinds, so a + pending claim hangs off the sources it cites exactly as an approved one + does, and the only thing separating them in a renderer is the status. + """ + payload = pr.payload + ts = pr.proposed_at.isoformat() + note(pr.id, NodeKind.PROPOSAL, pr.status.value, _proposal_label(payload)) + if pr.session_id: + note(pr.session_id, NodeKind.SESSION, "", "") + add(pr.id, pr.session_id, EdgeKind.PROPOSED_IN, ts, pr.session_id) + if pr.kind is ProposalKind.DELETE: + # For a delete the payload id names an artifact that already exists — + # the one edge in the graph that points at something on its way out. + target = payload.get("id") + if isinstance(target, str): + add(pr.id, target, EdgeKind.TARGETS, ts, pr.session_id) + return + for ref in _payload_refs(payload, "evidence"): + add(pr.id, ref, EdgeKind.CITES, ts, pr.session_id) + for cid in _payload_refs(payload, "claims"): + add(pr.id, cid, EdgeKind.EMBEDS, ts, pr.session_id) + + def build_graph(store: KBStore) -> ProvGraph: - """Reconstruct the full provenance graph from durable files. + """Reconstruct the full provenance graph from files on disk. Deterministic: claims and pages are read in sorted order and the audit log in append order, so the emitted edge set is stable across runs — that is what makes the ``prov_edges`` cache verifiable against it. + + Pending proposals are nodes too. They are the only part of the graph that + has not been through the gate, which is exactly why a reviewer needs to see + them: the frontier where the KB is about to change is not visible from the + durable artifacts alone. """ edges: dict[tuple[str, str, str], Edge] = {} - node_kinds: dict[str, NodeKind] = {} + nodes: dict[str, NodeMeta] = {} def add( src: str, @@ -157,10 +230,13 @@ def add( if key not in edges: edges[key] = Edge(src, dst, kind, ts, session) + def note(node: str, kind: NodeKind, status: str = "", label: str = "") -> None: + nodes[node] = NodeMeta(kind, status, label) + claims = store.list_claims() claim_ids = {c.id for c in claims} for c in claims: - node_kinds[c.id] = NodeKind.CLAIM + note(c.id, NodeKind.CLAIM, c.status.value, c.text) # claim -> proposing session, from approved claim proposals proposed_in: dict[str, str] = {} @@ -189,10 +265,10 @@ def add( try: evd = store.get_evidence(ref) except ArtifactNotFoundError: - node_kinds.setdefault(ref, NodeKind.SOURCE) + nodes.setdefault(ref, NodeMeta(NodeKind.SOURCE)) else: - node_kinds[ref] = NodeKind.EVIDENCE - node_kinds[evd.source_id] = NodeKind.SOURCE + note(ref, NodeKind.EVIDENCE) + note(evd.source_id, NodeKind.SOURCE) add( ref, evd.source_id, @@ -212,12 +288,12 @@ def add( add(c.id, other, EdgeKind.CONTRADICTS, c_ts, sess) if sess: - node_kinds[sess] = NodeKind.SESSION + note(sess, NodeKind.SESSION) add(c.id, sess, EdgeKind.PROPOSED_IN, c_ts, sess) if c.id in approve_event: eid, ts = approve_event[c.id] - node_kinds[eid] = NodeKind.EVENT + note(eid, NodeKind.EVENT) add(c.id, eid, EdgeKind.APPROVED_BY, ts, sess) for p in store.list_pages(): @@ -227,9 +303,12 @@ def add( # live set. if p.status is PageStatus.ARCHIVED: continue - node_kinds[p.id] = NodeKind.PAGE + note(p.id, NodeKind.PAGE, p.status.value, p.title) p_ts = p.updated_at.isoformat() for cid in p.claims: add(p.id, cid, EdgeKind.EMBEDS, p_ts) - return ProvGraph(edges.values(), node_kinds) + for pr in store.list_proposals(ProposalStatus.PENDING): + _add_pending(pr, add, note) + + return ProvGraph(edges.values(), nodes) diff --git a/src/vouch/provenance/model.py b/src/vouch/provenance/model.py index b70d2479..a9388f97 100644 --- a/src/vouch/provenance/model.py +++ b/src/vouch/provenance/model.py @@ -5,9 +5,13 @@ was proposed in, the audit event that approved it, and the older claim it supersedes. ``why`` therefore walks edges *outward* from a node; ``impact`` walks them *inward* (who points at me). The two reverse kinds (``supersededBy`` / -``contradictedBy``) are query-time labels for inbound traversal — only the seven +``contradictedBy``) are query-time labels for inbound traversal — only the canonical kinds in :data:`STORED_KINDS` are ever persisted, which keeps the ``prov_edges`` cache free of duplicate mirror rows. + +:class:`NodeMeta` is the other half of a node: the kind, review status and +human label a renderer needs, captured once during the build so no formatter +has to re-read the artifact to answer "what is this, and has it been reviewed". """ from __future__ import annotations @@ -27,6 +31,7 @@ class EdgeKind(StrEnum): EMBEDS = "embeds" # page -> claim it embeds as evidence PROPOSED_IN = "proposedIn" # claim -> session it was proposed in APPROVED_BY = "approvedBy" # claim -> audit event that approved it + TARGETS = "targets" # pending delete proposal -> artifact it would remove class NodeKind(StrEnum): @@ -36,6 +41,7 @@ class NodeKind(StrEnum): PAGE = "page" SESSION = "session" EVENT = "event" + PROPOSAL = "proposal" UNKNOWN = "unknown" @@ -50,6 +56,7 @@ class NodeKind(StrEnum): EdgeKind.EMBEDS, EdgeKind.PROPOSED_IN, EdgeKind.APPROVED_BY, + EdgeKind.TARGETS, } ) @@ -103,6 +110,23 @@ def to_dict(self) -> dict[str, str | None]: } +@dataclass(frozen=True) +class NodeMeta: + """What a renderer needs about one node, captured at build time. + + ``status`` is the artifact's own review status — ``active``/``superseded`` + for a claim, ``draft``/``active`` for a page, ``pending`` for an unreviewed + proposal — and is empty for structural nodes (sources, sessions, audit + events) that have none. ``label`` is the artifact's own words: a claim's + text, a page's title. Both are persisted with the edges so a cold read + renders identically to a hot one. + """ + + kind: NodeKind + status: str = "" + label: str = "" + + def sort_edges(edges: Iterable[Edge]) -> list[Edge]: """Deterministic edge ordering — the basis for cache/build equivalence.""" return sorted(edges, key=lambda e: e.sort_key) diff --git a/src/vouch/provenance/query.py b/src/vouch/provenance/query.py index ea995682..cb8fa472 100644 --- a/src/vouch/provenance/query.py +++ b/src/vouch/provenance/query.py @@ -8,6 +8,7 @@ from __future__ import annotations from enum import StrEnum +from typing import Any from ..models import PageStatus from ..storage import ArtifactNotFoundError, KBStore @@ -260,32 +261,58 @@ def _session_subgraph_edges(graph: ProvGraph, session_id: str) -> list: # type: ] +def _export_nodes( + graph: ProvGraph, + edges: list, # type: ignore[type-arg] + session: str | None, +) -> list[str]: + nodes: list[str] = [] + seen: set[str] = set() + for e in edges: + for n in (e.src_id, e.dst_id): + if n not in seen: + seen.add(n) + nodes.append(n) + if session is None: + # A pending proposal that cites nothing has no edges to be found by, + # and it is precisely the artifact a reviewer needs to see. Session + # subgraphs are defined by their edges, so they are left alone. + for node, meta in graph.meta().items(): + if meta.kind is NodeKind.PROPOSAL and node not in seen: + seen.add(node) + nodes.append(node) + nodes.sort() + return nodes + + def graph_export( store: KBStore, *, session: str | None = None, fmt: str = "dot", use_cache: bool = True, -) -> str: - """Render the DAG (or one session's subgraph) as Graphviz ``dot`` or - ``mermaid`` flowchart text.""" - if fmt not in ("dot", "mermaid"): - raise ValueError(f"unknown graph format: {fmt!r} (use 'dot' or 'mermaid')") +) -> str | dict[str, Any]: + """Render the DAG (or one session's subgraph) for a consumer. + + ``dot`` and ``mermaid`` return diagram text; ``json`` returns + ``{nodes, edges}`` for a renderer that lays the graph out itself, with each + node carrying the ``status`` that separates reviewed knowledge from the + pending frontier. + """ + if fmt not in ("dot", "mermaid", "json"): + raise ValueError( + f"unknown graph format: {fmt!r} (use 'dot', 'mermaid' or 'json')" + ) graph = load_graph(store, use_cache=use_cache) edges = ( _session_subgraph_edges(graph, session) if session is not None else graph.edges ) - nodes: list[str] = [] - seen: set[str] = set() - for e in edges: - for n in (e.src_id, e.dst_id): - if n not in seen: - seen.add(n) - nodes.append(n) - nodes.sort() + nodes = _export_nodes(graph, edges, session) if fmt == "dot": return _to_dot(graph, nodes, edges) - return _to_mermaid(graph, nodes, edges) + if fmt == "mermaid": + return _to_mermaid(graph, nodes, edges) + return _to_json(graph, nodes, edges) def _dot_escape(text: str) -> str: @@ -319,6 +346,33 @@ def _to_mermaid(graph: ProvGraph, nodes: list[str], edges: list) -> str: # type return "\n".join(lines) + "\n" +def _to_json( + graph: ProvGraph, + nodes: list[str], + edges: list, # type: ignore[type-arg] +) -> dict[str, Any]: + """Nodes and edges for a renderer, at the cost of zero extra file reads. + + Kind, status and label all come off the in-memory graph — the same place + ``dot`` and ``mermaid`` read from — so this stays a pure formatting call on + a path whose whole point is that it does not re-open the KB per node. + """ + return { + "nodes": [ + { + "id": n, + "kind": graph.kind_of(n).value, + "label": graph.label_of(n), + "status": graph.status_of(n), + } + for n in nodes + ], + "edges": [ + {"src": e.src_id, "dst": e.dst_id, "kind": e.kind.value} for e in edges + ], + } + + # --- human rendering ------------------------------------------------------ diff --git a/src/vouch/server.py b/src/vouch/server.py index c2b3221a..054857ca 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -1382,7 +1382,7 @@ def kb_impact( @mcp.tool() def kb_graph_export(*, session: str | None = None, format: str = "dot") -> dict[str, Any]: - """Render the provenance DAG (or one session's subgraph) as dot/mermaid.""" + """Render the provenance DAG (or one session's subgraph) as dot/mermaid/json.""" from . import provenance as prov graph = prov.graph_export(_store(), session=session, fmt=format) return {"format": format, "graph": graph} diff --git a/tests/test_provenance.py b/tests/test_provenance.py index d2063584..de049de4 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -9,19 +9,34 @@ from __future__ import annotations import json +from unittest.mock import patch import pytest from click.testing import CliRunner -from vouch import audit +from vouch import audit, index_db from vouch import lifecycle as life from vouch import provenance as prov from vouch import sessions as sess_mod from vouch.capabilities import capabilities from vouch.cli import cli from vouch.jsonl_server import HANDLERS, handle_request -from vouch.models import Claim, Page, PageStatus, PageType -from vouch.proposals import approve, propose_claim +from vouch.models import ( + Claim, + Evidence, + Page, + PageStatus, + PageType, + Proposal, + ProposalKind, +) +from vouch.proposals import ( + approve, + propose_claim, + propose_delete, + propose_entity, + propose_page, +) from vouch.storage import KBStore @@ -166,6 +181,13 @@ def _edge_tuples(edges) -> list[tuple]: ) +def _edge(graph, src: str, dst: str) -> str: + """The kind of the single edge src -> dst, for readable assertions.""" + kinds = [e.kind.value for e in graph.out_edges(src) if e.dst_id == dst] + assert len(kinds) == 1, f"expected one {src} -> {dst} edge, got {kinds}" + return kinds[0] + + def test_rebuild_matches_live_graph(store: KBStore) -> None: _seed(store) live = prov.build_graph(store).edges @@ -218,6 +240,194 @@ def test_graph_export_session_subgraph(store: KBStore) -> None: assert "c-new" in dot and "c-old" in dot +# --- the pending frontier ------------------------------------------------- + + +def test_pending_claim_is_a_node_keyed_on_the_proposal(store: KBStore) -> None: + ids = _seed(store) + pr = propose_claim( + store, text="an unreviewed fact", evidence=[ids["src"]], + proposed_by="agentA", slug_hint="c-pending", session_id=ids["session"], + ).proposal + + graph = prov.build_graph(store) + assert graph.kind_of(pr.id) is prov.NodeKind.PROPOSAL + assert graph.status_of(pr.id) == "pending" + assert graph.label_of(pr.id) == "an unreviewed fact" + # The prospective claim id is not a node — until approval it does not exist. + assert "c-pending" not in graph.nodes() + assert _edge(graph, pr.id, ids["src"]) == "cites" + assert _edge(graph, pr.id, ids["session"]) == "proposedIn" + + +def test_pending_delete_targets_the_artifact_it_would_remove(store: KBStore) -> None: + ids = _seed(store) + store.put_claim(Claim(id="c-lonely", text="referenced by nobody", + evidence=[ids["src"]])) + pr = propose_delete( + store, target_kind="claim", target_id="c-lonely", proposed_by="agentA", + ) + + graph = prov.build_graph(store) + assert _edge(graph, pr.id, "c-lonely") == "targets" + # and so the pending delete shows up as something depending on the claim + dependents = prov.impact(store, claim_id="c-lonely")["dependents"] + assert [d["source"] for d in dependents] == [pr.id] + + +def test_pending_page_embeds_the_claims_it_would_collect(store: KBStore) -> None: + _seed(store) + pr = propose_page( + store, title="A pending page", body="draft", claim_ids=["c-new"], + proposed_by="agentA", + ) + + graph = prov.build_graph(store) + assert graph.label_of(pr.id) == "A pending page" + assert _edge(graph, pr.id, "c-new") == "embeds" + + +def test_pending_proposal_with_no_edges_is_still_exported(store: KBStore) -> None: + """An orphan proposal is the one a reviewer is most likely to forget.""" + _seed(store) + pr = propose_entity( + store, name="Acme Example", entity_type="company", proposed_by="agentA", + ) + + graph = prov.build_graph(store) + assert pr.id not in graph.nodes() # no edges to be found by + exported = prov.graph_export(store, fmt="json") + assert pr.id in [n["id"] for n in exported["nodes"]] + # a session subgraph is edge-defined, so it does not pick the orphan up + scoped = prov.graph_export(store, session="does-not-exist", fmt="json") + assert scoped["nodes"] == [] + + +def test_a_claim_citing_evidence_keeps_the_span_between_it_and_the_source( + store: KBStore, +) -> None: + """Citing an Evidence id, not a Source id, is the two-hop form.""" + src = store.put_source(b"the retry limit is 3", title="runbook") + store.put_evidence(Evidence(id="ev-retry", source_id=src.id, locator="L1", + quote="the retry limit is 3")) + store.put_claim(Claim(id="c-retry", text="retries stop at 3", + evidence=["ev-retry"])) + + graph = prov.build_graph(store) + assert graph.kind_of("ev-retry") is prov.NodeKind.EVIDENCE + assert graph.kind_of(src.id) is prov.NodeKind.SOURCE + assert _edge(graph, "c-retry", "ev-retry") == "cites" + assert _edge(graph, "ev-retry", src.id) == "derivedFrom" + + +def test_a_bare_edge_list_still_reads_a_delete_proposal_as_one() -> None: + """A graph handed only edges — an older cache — infers kinds from them.""" + graph = prov.ProvGraph([prov.Edge("20260101-000000-abcd1234", "c-1", + prov.EdgeKind.TARGETS)]) + assert graph.kind_of("20260101-000000-abcd1234") is prov.NodeKind.PROPOSAL + assert graph.status_of("20260101-000000-abcd1234") == "" + assert graph.label_of("c-1") == "c-1" + + +def test_a_proposal_the_payload_cannot_describe_falls_back_to_its_id( + store: KBStore, +) -> None: + """Payloads written by an older vouch still have to render.""" + store.put_proposal( + Proposal( + id="20260101-000000-deadbeef", kind=ProposalKind.CLAIM, + proposed_by="agentA", payload={"id": "c-odd", "evidence": "not-a-list"}, + ) + ) + graph = prov.build_graph(store) + assert graph.label_of("20260101-000000-deadbeef") == "20260101-000000-deadbeef" + assert graph.out_edges("20260101-000000-deadbeef") == [] + + +# --- the json format ------------------------------------------------------ + + +def test_graph_export_json_carries_kind_status_and_label(store: KBStore) -> None: + ids = _seed(store) + pending = propose_claim( + store, text="an unreviewed fact", evidence=[ids["src"]], + proposed_by="agentA", slug_hint="c-pending", + ).proposal + + data = prov.graph_export(store, fmt="json") + by_id = {n["id"]: n for n in data["nodes"]} + assert by_id["c-new"] == { + "id": "c-new", "kind": "claim", "label": "the newer fact", + "status": "working", + } + assert by_id["c-old"]["status"] == "superseded" + assert by_id["page-alpha"] == { + "id": "page-alpha", "kind": "page", "label": "Alpha", "status": "active", + } + assert by_id[pending.id]["status"] == "pending" + # structural nodes have no review status of their own + assert by_id[ids["src"]] == { + "id": ids["src"], "kind": "source", "label": ids["src"], "status": "", + } + assert {"src": "page-alpha", "dst": "c-new", "kind": "embeds"} in data["edges"] + + +def test_graph_export_json_reads_no_artifact_per_node(store: KBStore) -> None: + """The whole point of carrying status on the graph: json stays a formatter. + + `dot` and `mermaid` do zero I/O once the graph is loaded. If `json` had to + re-fetch each node to learn its status it would be the expensive format on + a path that exists to avoid re-reading. + """ + _seed(store) + prov.load_graph(store) # warm the cache; the export below must not re-read + + def boom(*_args, **_kwargs): + raise AssertionError("graph_export re-read an artifact from disk") + + with patch.object(KBStore, "get_claim", boom), patch.object(KBStore, "get_page", boom): + data = prov.graph_export(store, fmt="json") + assert data["nodes"] and data["edges"] + + +def test_cached_and_freshly_built_graphs_agree_on_status(store: KBStore) -> None: + ids = _seed(store) + propose_claim( + store, text="an unreviewed fact", evidence=[ids["src"]], + proposed_by="agentA", slug_hint="c-pending", + ) + fresh = prov.graph_export(store, fmt="json", use_cache=False) + cached = prov.graph_export(store, fmt="json", use_cache=True) + assert cached == fresh + + +def test_filing_a_proposal_invalidates_the_cache(store: KBStore) -> None: + ids = _seed(store) + before = prov.prov_stamp(store) + prov.load_graph(store) + pr = propose_claim( + store, text="an unreviewed fact", evidence=[ids["src"]], + proposed_by="agentA", slug_hint="c-pending", + ).proposal + assert prov.prov_stamp(store) != before + assert pr.id in prov.load_graph(store).meta() + + +def test_cached_node_of_an_unknown_kind_is_dropped(store: KBStore) -> None: + """A cache written by a newer vouch must not break an older one.""" + _seed(store) + prov.rebuild_prov_edges(store) + with index_db.open_db(store.kb_dir) as conn: + index_db.index_prov_node(conn, id="c-new", kind="hologram") + assert "c-new" not in prov.cache.load_meta(store) + + +def test_graph_export_rejects_an_unknown_format(store: KBStore) -> None: + _seed(store) + with pytest.raises(ValueError, match="dot"): + prov.graph_export(store, fmt="svg") + + # --- CLI ------------------------------------------------------------------ @@ -271,6 +481,14 @@ def test_cli_graph_dot(store: KBStore) -> None: assert res.output.startswith("digraph provenance") +def test_cli_graph_json(store: KBStore) -> None: + _seed(store) + res = CliRunner().invoke(cli, ["graph", "--format", "json"]) + assert res.exit_code == 0, res.output + data = json.loads(res.output) + assert {n["id"] for n in data["nodes"]} >= {"c-new", "page-alpha"} + + # --- kb.* RPC surface ----------------------------------------------------- @@ -306,6 +524,15 @@ def test_kb_trace_over_jsonl(store: KBStore) -> None: assert resp["result"]["found"] is True +def test_kb_graph_export_json_over_jsonl(store: KBStore) -> None: + _seed(store) + resp = handle_request({"id": "5", "method": "kb.graph_export", + "params": {"format": "json"}}) + assert resp["ok"] is True, resp + assert resp["result"]["format"] == "json" + assert "c-new" in [n["id"] for n in resp["result"]["graph"]["nodes"]] + + def test_kb_why_missing_param_over_jsonl(store: KBStore) -> None: _seed(store) resp = handle_request({"id": "4", "method": "kb.why", "params": {}}) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 653dea16..3e4ad146 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -7,6 +7,7 @@ import { BrowseView } from './views/BrowseView' import { ChatView } from './views/ChatView' import { ClaimsView } from './views/ClaimsView' import { DashboardView } from './views/DashboardView' +import { MemoryNetworkView } from './views/MemoryNetworkView' import { PendingView } from './views/PendingView' import { ReviewView } from './views/ReviewView' import { SessionsView } from './views/SessionsView' @@ -26,6 +27,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/webapp/src/components/Shell.tsx b/webapp/src/components/Shell.tsx index 719cd575..3329c609 100644 --- a/webapp/src/components/Shell.tsx +++ b/webapp/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Activity, BadgeCheck, FileClock, History, Inbox, LayoutDashboard, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' +import { Activity, BadgeCheck, FileClock, History, Inbox, LayoutDashboard, Library, MessageSquare, Plug, SunMoon, Waypoints } from 'lucide-react' import { useEffect, useState } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { ConnectDialog } from '../connection/ConnectDialog' @@ -16,6 +16,7 @@ const NAV = [ { to: '/pending', label: 'Pending', icon: Inbox }, { to: '/claims', label: 'Claims', icon: BadgeCheck }, { to: '/browse', label: 'Browse', icon: Library }, + { to: '/memory', label: 'Memory', icon: Waypoints }, { to: '/sessions', label: 'Sessions', icon: History }, { to: '/stats', label: 'Stats', icon: Activity }, ] @@ -26,6 +27,7 @@ const TITLES: Record = { '/pending': 'Pending review', '/claims': 'Approved claims', '/browse': 'Knowledge', + '/memory': 'Memory network — the KB as a graph, pending included', '/sessions': 'Sessions — compiled conversation history', '/dashboard': 'Dashboard — KB activity', '/stats': 'Stats & health', diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 566d7105..bfe38215 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -91,6 +91,27 @@ export interface Proposal { [k: string]: unknown } +/** A node in kb.graph_export's json format — one artifact, or one pending proposal. */ +export interface GraphNode { + id: string + kind: 'claim' | 'page' | 'evidence' | 'source' | 'session' | 'event' | 'proposal' | 'unknown' + /** The artifact's own words: a claim's text, a page's title. Falls back to the id. */ + label: string + /** Review status — 'pending' for an unreviewed proposal, '' for structural nodes. */ + status: string +} + +export interface GraphEdge { + src: string + dst: string + kind: string +} + +export interface GraphExport { + nodes: GraphNode[] + edges: GraphEdge[] +} + /** One row of kb.list_sessions — a captured agent session in the summary pipeline. */ export interface SessionEntry { /** Null when the capture never recorded a session id (legacy buffers). */ diff --git a/webapp/src/views/MemoryNetworkView.test.tsx b/webapp/src/views/MemoryNetworkView.test.tsx new file mode 100644 index 00000000..f26eb6fc --- /dev/null +++ b/webapp/src/views/MemoryNetworkView.test.tsx @@ -0,0 +1,124 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { MemoryNetworkView } from './MemoryNetworkView' + +const CAPS = { + name: 'vouch', + level: 3, + methods: ['kb.graph_export', 'kb.read_claim', 'kb.read_page'], + review_gated: true, +} + +const GRAPH = { + nodes: [ + { id: 'c-new', kind: 'claim', label: 'the newer fact', status: 'working' }, + { id: 'c-old', kind: 'claim', label: 'the older fact', status: 'superseded' }, + { id: 'page-alpha', kind: 'page', label: 'Alpha', status: 'active' }, + { id: 'src-1', kind: 'source', label: 'src-1', status: '' }, + { + id: '20260731-120000-abcd1234', + kind: 'proposal', + label: 'an unreviewed fact', + status: 'pending', + }, + ], + edges: [ + { src: 'page-alpha', dst: 'c-new', kind: 'embeds' }, + { src: 'c-new', dst: 'src-1', kind: 'cites' }, + { src: 'c-new', dst: 'c-old', kind: 'supersedes' }, + { src: '20260731-120000-abcd1234', dst: 'src-1', kind: 'cites' }, + ], +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.graph_export') return { format: 'json', graph: GRAPH } + if (method === 'kb.read_claim') { + return { + id: 'c-new', + text: 'the newer fact', + type: 'observation', + status: 'working', + confidence: 0.9, + } + } + throw new Error(`unexpected ${method}`) + }) + seedConnection() +}) + +test('draws a node per artifact and summarises the graph for a screen reader', async () => { + renderWithProviders() + expect(await screen.findByText('the newer fact')).toBeInTheDocument() + expect(screen.getByText('an unreviewed fact')).toBeInTheDocument() + expect(screen.getByText(/5 nodes and 4 links, 1 pending review/)).toBeInTheDocument() +}) + +test('a node carries its kind and status in its accessible name', async () => { + renderWithProviders() + expect(await screen.findByRole('button', { name: 'claim the older fact (superseded)' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'source src-1' })).toBeInTheDocument() +}) + +test('clicking an approved node opens the artifact drawer', async () => { + renderWithProviders() + await userEvent.click(await screen.findByRole('button', { name: /claim the newer fact/ })) + expect(await screen.findByTestId('drawer')).toBeInTheDocument() +}) + +test('a pending proposal is not a readable artifact, so it points at review instead', async () => { + renderWithProviders() + await userEvent.click(await screen.findByRole('button', { name: /proposal an unreviewed fact/ })) + expect(await screen.findByText(/decide it under Review or Pending/)).toBeInTheDocument() + expect(screen.queryByTestId('drawer')).not.toBeInTheDocument() +}) + +test('the pending-frontier filter narrows to proposals and what they touch', async () => { + renderWithProviders() + expect(await screen.findByText('the newer fact')).toBeInTheDocument() + await userEvent.click(screen.getByRole('checkbox', { name: /pending frontier only/i })) + expect(screen.getByText('an unreviewed fact')).toBeInTheDocument() + expect(screen.getByText('src-1')).toBeInTheDocument() + expect(screen.queryByText('the newer fact')).not.toBeInTheDocument() +}) + +test('zoom controls scale the canvas and reset returns it', async () => { + const { container } = renderWithProviders() + await screen.findByText('the newer fact') + const canvas = () => container.querySelector('svg g[transform]') + expect(canvas()).toHaveAttribute('transform', 'translate(0 0) scale(1)') + await userEvent.click(screen.getByRole('button', { name: 'zoom in' })) + expect(canvas()).toHaveAttribute('transform', 'translate(0 0) scale(1.25)') + await userEvent.click(screen.getByRole('button', { name: 'reset view' })) + expect(canvas()).toHaveAttribute('transform', 'translate(0 0) scale(1)') +}) + +test('an endpoint that does not advertise the export renders a note, not endless loading', async () => { + vi.mocked(fetchCapabilities).mockResolvedValue({ ...CAPS, methods: ['kb.list_claims'] }) + renderWithProviders() + expect(await screen.findByText(/not available on this endpoint/i)).toBeInTheDocument() +}) + +test('an empty kb gets an instructive empty state', async () => { + vi.mocked(rpc).mockResolvedValue({ format: 'json', graph: { nodes: [], edges: [] } }) + renderWithProviders() + expect(await screen.findByText(/nothing to draw yet/i)).toBeInTheDocument() +}) + +test('a failed export surfaces the server error', async () => { + vi.mocked(rpc).mockRejectedValue(new Error("unknown graph format: 'json'")) + renderWithProviders() + expect(await screen.findByText(/unknown graph format/)).toBeInTheDocument() +}) diff --git a/webapp/src/views/MemoryNetworkView.tsx b/webapp/src/views/MemoryNetworkView.tsx new file mode 100644 index 00000000..967dbfb7 --- /dev/null +++ b/webapp/src/views/MemoryNetworkView.tsx @@ -0,0 +1,339 @@ +import { useMemo, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Minus, Plus, RotateCcw } from 'lucide-react' +import { ArtifactDrawer } from '../components/ArtifactDrawer' +import type { DrawerTarget } from '../components/ArtifactDrawer' +import { EmptyState } from '../components/EmptyState' +import { ErrorCard } from '../components/ErrorCard' +import { useErrorToast, useToast } from '../components/Toast' +import { useConnection } from '../connection/ConnectionContext' +import type { ProjectState } from '../connection/ConnectionContext' +import { canRead } from '../lib/resolveArtifact' +import { rpc } from '../lib/rpc' +import type { GraphEdge, GraphExport, GraphNode } from '../lib/types' + +const METHOD = 'kb.graph_export' + +/** + * Left-to-right reading order, the same flow `vouch graph` renders with + * rankdir=LR: what is proposed or written, then the claims it rests on, then + * the evidence and sources under those, with sessions and audit events last. + */ +const COLUMNS: GraphNode['kind'][] = [ + 'proposal', + 'page', + 'claim', + 'evidence', + 'source', + 'session', + 'event', + 'unknown', +] + +const COL_W = 210 +const ROW_H = 30 +const PAD = 26 +const NODE_R = 6 +const MIN_ZOOM = 0.25 +const MAX_ZOOM = 3 + +/** Statuses that mean "no longer the live answer" — drawn muted. */ +const RETIRED = new Set(['superseded', 'archived', 'redacted']) + +function fillFor(status: string): string { + if (status === 'pending') return 'var(--accent)' + if (RETIRED.has(status)) return 'var(--sepia)' + if (status) return 'var(--ok)' + return 'var(--ink-2)' +} + +const LEGEND: { label: string; fill: string }[] = [ + { label: 'pending review', fill: 'var(--accent)' }, + { label: 'approved', fill: 'var(--ok)' }, + { label: 'superseded / archived', fill: 'var(--sepia)' }, + { label: 'source, session, audit event', fill: 'var(--ink-2)' }, +] + +interface Placed extends GraphNode { + x: number + y: number +} + +interface Layout { + nodes: Placed[] + edges: { edge: GraphEdge; x1: number; y1: number; x2: number; y2: number }[] + width: number + height: number +} + +/** + * A deterministic layered layout: one column per node kind, nodes stacked in + * the order the server returned them (which is sorted by id). No simulation — + * the same KB draws the same picture every time, which is what makes a graph + * screenshot worth comparing against the last one. + */ +function layout(graph: GraphExport): Layout { + const columns = COLUMNS.filter((kind) => graph.nodes.some((n) => n.kind === kind)) + const rows = new Map() + const nodes: Placed[] = [] + for (const node of graph.nodes) { + const col = Math.max(columns.indexOf(node.kind), 0) + const row = rows.get(node.kind) ?? 0 + rows.set(node.kind, row + 1) + nodes.push({ ...node, x: PAD + col * COL_W, y: PAD + row * ROW_H }) + } + const at = new Map(nodes.map((n) => [n.id, n])) + const edges = graph.edges.flatMap((edge) => { + const from = at.get(edge.src) + const to = at.get(edge.dst) + return from && to ? [{ edge, x1: from.x, y1: from.y, x2: to.x, y2: to.y }] : [] + }) + const tallest = Math.max(1, ...rows.values()) + return { + nodes, + edges, + width: PAD * 2 + Math.max(1, columns.length) * COL_W, + height: PAD * 2 + tallest * ROW_H, + } +} + +function truncate(text: string, max = 38): string { + return text.length > max ? `${text.slice(0, max - 1)}…` : text +} + +function ZoomButton({ + label, + onClick, + children, +}: { + label: string + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +function Network({ graph, project }: { graph: GraphExport; project: ProjectState }) { + const { toast } = useToast() + const [drawer, setDrawer] = useState(null) + const [pendingOnly, setPendingOnly] = useState(false) + const [view, setView] = useState({ x: 0, y: 0, k: 1 }) + const grab = useRef<{ x: number; y: number } | null>(null) + + const shown = useMemo(() => { + if (!pendingOnly) return graph + // The frontier plus what it touches — a pending claim is only legible + // next to the source it cites and the claim it would replace. + const seeds = new Set(graph.nodes.filter((n) => n.kind === 'proposal').map((n) => n.id)) + const keep = new Set(seeds) + for (const e of graph.edges) { + if (seeds.has(e.src)) keep.add(e.dst) + if (seeds.has(e.dst)) keep.add(e.src) + } + return { + nodes: graph.nodes.filter((n) => keep.has(n.id)), + edges: graph.edges.filter((e) => keep.has(e.src) && keep.has(e.dst)), + } + }, [graph, pendingOnly]) + + const placed = useMemo(() => layout(shown), [shown]) + const pending = graph.nodes.filter((n) => n.status === 'pending').length + const summary = + `Memory network: ${shown.nodes.length} node${shown.nodes.length === 1 ? '' : 's'} ` + + `and ${shown.edges.length} link${shown.edges.length === 1 ? '' : 's'}, ` + + `${pending} pending review.` + + const open = (node: GraphNode) => { + if (node.kind === 'proposal') { + toast('info', 'pending proposal — decide it under Review or Pending') + return + } + if (!canRead(project, node.kind)) { + toast('error', `${node.kind} ${truncate(node.id, 24)} is not readable here`) + return + } + setDrawer({ kind: node.kind, id: node.id }) + } + + const zoom = (factor: number) => + setView((v) => ({ ...v, k: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.k * factor)) })) + + if (graph.nodes.length === 0) { + return ( + + ) + } + + return ( +
+
+ {LEGEND.map(({ label, fill }) => ( + + + {label} + + ))} + +
+ zoom(1 / 1.25)}> + + + zoom(1.25)}> + + + setView({ x: 0, y: 0, k: 1 })}> + + +
+
+ +

{summary}

+ +
+ { + grab.current = { x: e.clientX - view.x, y: e.clientY - view.y } + }} + onPointerMove={(e) => { + const from = grab.current + if (from) setView((v) => ({ ...v, x: e.clientX - from.x, y: e.clientY - from.y })) + }} + onPointerUp={() => { + grab.current = null + }} + onPointerLeave={() => { + grab.current = null + }} + onWheel={(e) => zoom(e.deltaY < 0 ? 1.1 : 1 / 1.1)} + > + + + + + + + {placed.edges.map(({ edge, x1, y1, x2, y2 }) => ( + + ))} + {placed.nodes.map((node) => ( + open(node)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') open(node) + }} + > + + + {truncate(node.label)} + + + ))} + + +
+ + setDrawer(null)} + onOpen={(kind, id) => setDrawer({ kind, id })} + /> +
+ ) +} + +function ProjectNetwork({ project, titled }: { project: ProjectState; titled: boolean }) { + const supported = project.caps === null || project.caps.methods.includes(METHOD) + const q = useQuery({ + queryKey: ['graph-export', project.conn.endpoint], + queryFn: () => + rpc<{ format: string; graph: GraphExport }>(project.conn, METHOD, { format: 'json' }), + enabled: supported, + }) + useErrorToast(q.isError, q.error) + + return ( +
+ {titled && ( +

+ {project.label} +

+ )} + {!supported && ( + + )} + {supported && q.isPending &&

loading…

} + {supported && q.isError && ( +
+ +
+ )} + {supported && q.data && } +
+ ) +} + +export function MemoryNetworkView() { + const { scoped, aggregated } = useConnection() + return ( +
+ {scoped.map((p) => ( + + ))} +
+ ) +}