From 6d533dd0149ee651eda8b90ea77b9e8c62c6c051 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Fri, 31 Jul 2026 11:42:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20memory-network=20view=20=E2=80=94?= =?UTF-8?q?=20the=20kb=20as=20a=20graph,=20pending=20included?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #604. `kb.graph_export` gains `format=json` and the console gains a Memory view that renders it: pan, zoom, click a node to open the existing artifact drawer, colour by status. the dot and mermaid serializers are untouched. the part that carries the value is that pending proposals are graph nodes now. `build_graph` walks `proposed/` and keys each proposal 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. they hang off the graph 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. the only thing separating a pending node from an approved one is its status, which is what makes colouring by status the review gate rendered rather than decoration. kind, status and label are captured during the build and carried on the graph, cached in a new prov_nodes table, so the json serializer does no i/o per node — it costs the same as dot, and a cache-loaded graph reports the same kind, status and label as a freshly built one. the freshness stamp gains the pending count, which catches a filed-then-rejected proposal and also invalidates every cache written before proposals were nodes. Co-authored-by: Cursor --- CHANGELOG.md | 17 + docs/provenance.md | 53 ++- src/vouch/cli.py | 11 +- src/vouch/index_db.py | 30 ++ src/vouch/provenance/__init__.py | 7 +- src/vouch/provenance/cache.py | 51 ++- src/vouch/provenance/graph.py | 117 +++++-- src/vouch/provenance/model.py | 26 +- src/vouch/provenance/query.py | 82 ++++- src/vouch/server.py | 2 +- tests/test_provenance.py | 233 +++++++++++++- webapp/src/App.tsx | 2 + webapp/src/components/Shell.tsx | 4 +- webapp/src/lib/types.ts | 21 ++ webapp/src/views/MemoryNetworkView.test.tsx | 124 +++++++ webapp/src/views/MemoryNetworkView.tsx | 339 ++++++++++++++++++++ 16 files changed, 1053 insertions(+), 66 deletions(-) create mode 100644 webapp/src/views/MemoryNetworkView.test.tsx create mode 100644 webapp/src/views/MemoryNetworkView.tsx 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) => ( + + ))} +
+ ) +}