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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
53 changes: 44 additions & 9 deletions docs/provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -44,6 +59,7 @@ vouch trace <a> --to <b> # shortest typed path between two artifa
vouch impact <claim_id> # forward: pages, downstream claims that depend on it
vouch impact <claim_id> --if archive # dry-run a lifecycle op; exits non-zero if it breaks something
vouch graph --session <session_id> # 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
```

Expand All @@ -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):
Expand All @@ -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
Expand Down
11 changes: 7 additions & 4 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
30 changes: 30 additions & 0 deletions src/vouch/index_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
);
"""


Expand Down Expand Up @@ -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_%';"
)
Expand Down Expand Up @@ -212,13 +220,15 @@ 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`) --------


def clear_prov_edges(conn: sqlite3.Connection) -> None:
conn.execute("DELETE FROM prov_edges")
conn.execute("DELETE FROM prov_nodes")


def index_prov_edge(
Expand All @@ -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 (?, ?)",
Expand Down
7 changes: 4 additions & 3 deletions src/vouch/provenance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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,
Expand All @@ -42,6 +42,7 @@
"EdgeKind",
"LifecycleOp",
"NodeKind",
"NodeMeta",
"ProvGraph",
"build_graph",
"graph_export",
Expand Down
51 changes: 40 additions & 11 deletions src/vouch/provenance/cache.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,44 @@
"""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

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:
Expand All @@ -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)

Expand All @@ -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.

Expand All @@ -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))
Loading
Loading