Adds docs/live-ui-spec.md — how to make the demo-backed graph REAL by materializing it from the live Mongo collections the agent already writes (pods, engineer_states, observations, collisions, interventions, outcomes). Extends docs/graph.md; spec only, no implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
23 KiB
Real Continual-Learning UI — Spec
Owner: graph data + visualization (live data). Status: demo-backed graph shipped (
docs/graph.md); this spec makes it REAL. Extendsdocs/graph.md— does not duplicate it. Same files, same contracts (shared/src/graph.ts,PodGraph, the two collections, the two routes). This spec only adds: a live materializer behindloadPodGraph, an outcomes aggregation, a ws push ofGRAPH_DIRTY, and the per-pod GraphView wiring. Everything ingraph.md(node/edge kinds,$graphLookup, demo fallback) remains the contract.
0. Purpose & framing
The demo graph already renders the "it learned" story (createDemoPodGraph → Karti owns auth, learned_from edge, 86% accept rate). The problem: none of it is real — team_model/graph_nodes/graph_edges are never written (seedGraph has zero callers), so loadPodGraph always returns the hardcoded demo. The graph looks alive but is a poster.
This spec makes the same poster a live render of the 6 collections the agent actually writes (pods, engineer_states, observations, collisions, interventions, outcomes), so the continual-learning loop the judges see is backed by data the pipeline produced this session.
The visible self-improving loop, before → after:
- Before: Two engineers edit
auth.ts, one unpushed. Acollisionsdoc is written, aninterventionsdoc (statuspending). The graph grows a redcollisiontriangle and awarnsedge to the intervention diamond. Copy: "new — first time PodMan saw this path." - After: The human clicks Accept →
POST /api/outcomewrites{accepted:true, wasRealCollision:true}. The materializer turns that outcome into alearned_fromedge (intervention → engineer, labellearned: owns auth.ts), flips the engineer node tostatus:'learned', and bumps the Learned owners metric. Copy on the next similar collision: "I've seen this before — last time the team accepted sync PR" (driven bycollisions.memorySignaturerecall, already live).
Why this is not a dashboard (hard constraint): it stays the secondary, toggle-opened view behind the pods list (graph.md), keeps the dark-Bauhaus single-canvas SVG (one graph, not a grid of charts), and every visible element is anchored to a live write + an action loop. The metrics rail is 3 numbers derived from real counts, not a wall of KPIs. The hero remains the intervention card in PodView; this view exists only to make "PodMan got better" legible in 10 seconds.
1. Live data → graph mapping
The materializer (backend/src/graph/live.ts, §3) reads these collections per podId and emits PodGraph (shared/src/graph.ts). Node ids stay stable so realtime refreshes don't reshuffle: engineer:<name>, file:<normalizedFile>, collision:<collision.id>, intervention:<intervention.id>.
| Source collection | Fields read | Produces |
|---|---|---|
pods |
members[], name, repo |
Baseline engineer: nodes for every roster member (so the graph isn't empty pre-activity). summary = pod repo. |
engineer_states (via getGitStates) |
name, changedFiles[], branch, recentCommit, gitUpdatedAt |
engineer:<name> node status:'risk' when changedFiles.length>0 (= hasUnpushedChanges); file: node per entry in changedFiles; editing edge engineer→file, strength 0.6. Engineer summary = "N changed files on <branch>". |
observations (EngineerContext) |
engineerId, currentFile, currentSymbol, activity, confidence, observedAt |
engineer:<engineerId> node status:'active' if a observedAt within last 60s exists; file:<currentFile> node; editing edge engineer→file, strength = confidence (Gemini meter). Most-recent observedAt wins for a file's de-facto primaryOwner (used to label the owns edge — there is no ownership_map). Edge label = activity. |
collisions (Collision) |
id, file, symbol, engineers[], severity, detectedAt, memorySignature |
One collision:<id> node, kind:'collision', status:'risk', weight by severity (info0.4/warn0.7/critical1.0). collides edge per name in engineers[] (engineer→collision, strength from severity). touches edge file:<file>→collision. A summary badge "seen before" when memorySignature matched a prior collision (severity escalated to critical — already the live recall signal). |
interventions (Intervention) |
id, collisionId, kind, suggestedAction.kind, status, createdAt |
One intervention:<id> diamond. warns edge collision:<collisionId>→intervention, label from suggestedAction.kind (open_sync_pr→"sync PR", ping_teammate→"ping", none→"watch"). Color cannot come from status (always pending — never updated), so it is joined to outcomes (next row). |
outcomes (InterventionOutcome) |
interventionId, wasRealCollision, accepted, recordedAt |
The learning signal. For each outcome where accepted===true && wasRealCollision===true: emit a learned_from edge intervention:<interventionId>→engineer:<primaryOwnerOfFile>, label = learned: owns <file>, strength 0.6; flip that engineer node status:'learned' and the intervention node status:'learned'. accepted===false → intervention node status:'stable' (grey, "dismissed"). |
Metrics rail (PodGraphMetric[], replacing demo's hardcoded 5 / 2 / 86%), computed live in live.ts:
label |
value |
detail |
Source |
|---|---|---|---|
Learned owners |
count of accepted+real outcomes | "Ownership edges retained from accepted interventions." |
outcomes aggregation |
Open risk paths |
count of collisions with engineers.length>=2 and any colliding engineer has unpushed (engineer_states.changedFiles non-empty) |
"Files with converging editors and unpushed work." |
collisions × engineer_states |
Accept rate |
round(accepted / total outcomes * 100)% ("—" when total 0) |
"Interventions accepted this session." |
outcomes aggregation |
x/y layout: live.ts runs a deterministic column layout (engineers x≈78, files x≈300, collisions x≈470, interventions/features x≈620; y spread evenly per column within the 0..472 viewBox) so the existing SVG renders unchanged.
2. The visible workflow (observe → store → predict → outcome → adapt)
The graph view narrates the same loop the agent runs, mapped to what the viewer sees:
- Observe (
observations, ~1fps;engineer_states, 15s): engineer nodes lightactive;editingedges thicken with Geminiconfidence. A small caption under the canvas: "Yahya — editing auth.ts (unpushed)" from the latest observation + git chip. - Store / Predict (
collisions+ vector recall): when ≥2 engineers + unpushed, a redcollisiontriangle andcollides/touchesedges appear. IfmemorySignaturerecall hit (severitycritical), the node carries a "seen before" badge — the self-improvement signal. - Intervene (
interventions): awarnsedge draws to a newinterventiondiamond labeled bysuggestedAction.kind. The view shows a "PodMan is speaking" pulse on that edge when aVOICE_CUEarrives over ws (§5). - Outcome (
POST /api/outcomefrom thePodViewcard): the moment the human clicks Accept. - Adapt (
outcomes→learned_from): on the next graph refresh, a dashed purplelearned_fromedge animates in, the engineer node flips tolearned(gold/locked), and Learned owners + Accept rate tick up.
The live "money moment" (sequence the demo lands): collision detected → red triangle + edges appear and PodMan speaks (pulse) → human clicks Accept on the card in PodView → switch to Team memory → the learned_from edge to engineer:karti (learned: owns auth.ts) is now present, the engineer is gold, metrics rose. A second collision on the same signature renders with the "seen before" badge. That single before→after transition, all from collections written this session, is the continual-learning proof.
3. Backend changes
All additive / behind existing signatures — graph.md contracts unchanged.
3a. Live materializer — backend/src/graph/live.ts (NEW)
export async function materializePodGraph(podId: string): Promise<PodGraph>. Reads the 6 collections via collections() + getGitStates(podId) (memory/db.ts), builds nodes/edges/metrics per §1, runs the column layout, returns PodGraph. Pure-read; never writes. Wrapped so any Mongo error throws to the caller (which falls back to demo, §3b). Helper normalizeFile() reused from collision/detector.ts so file ids match collision files exactly.
3b. loadPodGraph — backend/src/graph/store.ts (MODIFY)
Replace the body so it prefers live, then team_model, then demo:
1. const graph = await materializePodGraph(podId)
2. if (graph.nodes.length > <BASELINE>) return graph // real activity exists
3. const doc = team_model.findOne({podId}); if (doc?.graph) return doc.graph
4. return createDemoPodGraph(podId) // demo-stability fallback
<BASELINE> = the count of pure roster engineer: nodes (no files/collisions). If only roster nodes exist (no observations/collisions yet) the view shows demo so the canvas is never empty mid-demo (§5). Signature, route, and demo fallback all unchanged.
3c. Keep $graphLookup real — mirror into graph_nodes/graph_edges
Add export async function materializeAndSeed(podId) in store.ts: calls materializePodGraph, then reuses seedGraph's existing upsert/deleteMany/insertMany block to write team_model.graph + graph_nodes + graph_edges from the live graph (today seedGraph writes the demo graph from createDemoPodGraph; this variant takes the live one). Called (a) on every POST /api/outcome (so reachFrom reflects the new learned_from edge), and (b) lazily inside the graph route after loadPodGraph returns a live graph. This is the only way reachFrom//graph/reach/:node stops returning empty.
3d. Routes — backend/src/server.ts (MODIFY, additive only)
GET /api/pods/:id/graph— unchanged signature; now returns live graph via §3b.GET /api/pods/:id/graph/reach/:node— unchanged; now non-empty once §3c runs.GET /api/pods/:id/graph/metrics(NEW, small) — returns justPodGraphMetric[](the outcomes aggregation:learned owners,open risk paths,accept rate) so the rail can poll cheaply without re-sending the whole graph. Backed by adb.collection('outcomes').aggregategroup onpodId(count,$sum accepted).POST /api/outcome(MODIFY): afterrecordOutcome, callmaterializeAndSeed(podId)(best-effort, never throws) and broadcast{type:'GRAPH_DIRTY', podId}to ws/api/eventsclients (reuse the existingclientsset /c.send). This is the only place the loop closes.
3e. Realtime push — ws /api/events (MODIFY)
The relay already fans out any JSON. Add server-originated GRAPH_DIRTY (above) and pass through agent COLLISION / VOICE_CUE (the agent already publishes these on the LiveKit data channel; mirror them onto ws so the dashboard-level GraphView — which is not in a LiveKit room — can react). No new transport; just two more message types on the existing bus.
4. Frontend changes
4a. Per-pod entry point — frontend/src/App.tsx (MODIFY)
The "Team memory" button currently passes pods[0]?.id ?? 'demo-pod' (line 271) — wrong pod for a multi-pod demo. Change to open the graph for the pod in context: add a BrainCircuitIcon action on each PodCard (onOpenGraph(pod.id)) wired to setGraphPodId(pod.id), and keep the header button as a fallback that opens the selected/first live pod (the one with presence). The router slot (if (graphPodId) return <GraphView podId={graphPodId} …/>, line 237-239) is unchanged.
4b. GraphView.tsx — keep dark-Bauhaus, add realtime
Stays dark-Bauhaus (per graph.md it is the deliberately distinct "it learned" surface — do not convert the SVG/.pm-* palette to shadcn). What changes:
- Realtime refresh: open a ws to
/api/eventson mount; on{type:'GRAPH_DIRTY', podId}(matching this pod) orCOLLISION/VOICE_CUE, re-callfetchPodGraph(podId)(andfetchGraphMetrics). Also a 5s poll offetchPodGraphas the floor (mirrorsApp.tsx's existing 5s presence/memory poll) so it's live even if ws drops. New incoming nodes/edges fade in (CSS opacity transition on<line>/shape);learned_fromedges animate the dashed stroke. - "new" vs "seen before" copy in the detail rail from
collisionnodesummarybadge (§1). - Speaking pulse on the
warnsedge when aVOICE_CUElands.
4c. frontend/src/lib/graph.ts (MODIFY)
- Fix
BACKEND_URLto followlib/api.ts's resolution (empty string in prod) instead of hardcodinglocalhost:8787. - Add
fetchGraphMetrics(podId)→GET /api/pods/:id/graph/metrics. - Add
openGraphEvents(podId, onDirty)→ thinWebSocket('/api/events')subscription helper (reused byGraphView).
4d. Loading / empty / error states (collections empty is the common real case)
- Loading: existing on-mount spinner; keep.
- Empty (no activity yet): §3b returns the demo graph so the canvas is never blank — but the detail rail shows a small "Live mode — waiting for the first observation" note when metrics total is 0, so it's honest that nothing has been learned yet. No empty-grid placeholder.
- Error / backend down:
fetchPodGraphrejects → render the last good graph if any, else the demo graph rendered client-side is not available; show a single-line.pm-error chip "memory offline — retrying" and keep polling. ws errors are swallowed (poll covers it).
5. Realtime & data freshness
| Surface | Transport | Cadence |
|---|---|---|
Engineer active/file edits |
ws COLLISION passthrough + 5s fetchPodGraph poll |
~1fps source data, surfaced ≤5s |
| Git chip (changed files / branch) | folded into 5s graph poll (engineer_states) |
15s underlying write |
| Collision / intervention nodes | ws COLLISION (instant) → triggers refetch |
instant on event |
| Speaking pulse | ws VOICE_CUE |
instant |
learned_from edge + metrics |
ws GRAPH_DIRTY on POST /api/outcome → refetch |
instant on Accept |
Polling is the floor, ws is the accelerator — never gate the canvas on ws. Demo-stability fallback: if the live materializer yields ≤ baseline nodes or Mongo is unreachable, the route serves createDemoPodGraph (§3b) so the toggle always shows a coherent graph on stage. The Accept→learned_from beat is driven by POST /api/outcome → materializeAndSeed → GRAPH_DIRTY, the one path that must be solid.
6. Demo path
Must-have (the 3-min money moment):
materializePodGraphreadingcollisions+interventions+outcomes+engineer_statesso the graph reflects this session.POST /api/outcome→materializeAndSeed+GRAPH_DIRTY; GraphView refetches and thelearned_fromedge + gold node + risen metrics appear after Accept.- Live metrics rail (Learned owners / Open risk paths / Accept rate) from the outcomes aggregation.
- "seen before" badge from
collisions.memorySignature(already live) on the second collision. - Demo-graph fallback when collections are empty (stage safety).
Nice-to-have:
- ws
VOICE_CUEspeaking pulse on thewarnsedge. /graph/reach/:nodelit risk-path walk ($graphLookup) oncematerializeAndSeedpopulatesgraph_edges.- Fade/stroke animations on incoming edges.
- Per-
PodCardgraph entry button.
Cut if behind (per CLAUDE.md 12h box):
- Vector-search dependency for recall (exact
memorySignaturerecall already covers the learning beat). - Any new chart primitive / recharts — keep the SVG.
- Auth/pod-scoping on ws
/api/events. - Historical/time-scrubbed graph; only "now" is needed.
The 3-min beat (extends PLAN.md §9, ends in this view): IDE with unpushed auth.ts → live caption → second engineer opens same file → COLLISION card in PodView (named teammate + sync PR action) → Hermes voice → human clicks Accept → toggle Team memory → the learned_from "learned: Karti owns auth.ts" edge is now real, metrics rose → second collision shows "I've seen this before" → close on the public repo PR URL + the live metrics.
7. Files & tasks (documentation-first)
Per CLAUDE.md gate: this spec + the new task entries in
docs/PLAN.mdland before code. Add taskPxx — Live continual-learning graphtoPLAN.mdwith the Files list below.
Create:
backend/src/graph/live.ts—materializePodGraph(podId),normalizeFile, metrics aggregation, column layout. (Task: live materializer)
Modify:
docs/graph.md— append a "Live data backing" section pointing to this spec (the §1 mapping table +live.ts); change the "Demo-first plan" step 3 ("SwaploadPodGraph…") to "done viamaterializePodGraph." (documentation-first)docs/PLAN.md— add the task + Files list. (documentation-first)backend/src/graph/store.ts—loadPodGraphprefers live (§3b); addmaterializeAndSeed(podId)(§3c). (Task: live wiring + $graphLookup)backend/src/server.ts—POST /api/outcomecallsmaterializeAndSeed+ broadcastsGRAPH_DIRTY; newGET /api/pods/:id/graph/metrics; ws passthrough ofCOLLISION/VOICE_CUE/GRAPH_DIRTY. (Task: routes + realtime)backend/src/agent/podman.ts— also emitCOLLISION/VOICE_CUEonto ws/api/events(mirror of the LiveKit data-channel publish) so the dashboard-level GraphView reacts. (Task: realtime mirror)frontend/src/lib/graph.ts—BACKEND_URLresolution fix;fetchGraphMetrics;openGraphEvents. (Task: client)frontend/src/components/GraphView.tsx— ws subscription + 5s poll + animations + "new/seen-before" rail copy + speaking pulse; keep dark-Bauhaus. (Task: client)frontend/src/App.tsx— per-pod graph entry; header button opens live/selected pod notpods[0]. (Task: entry point)frontend/src/components/PodCard.tsx—onOpenGraph(pod.id)action. (Task: entry point)
Unchanged contracts (do not edit): shared/src/graph.ts (types already cover live), the two graph routes' signatures, createDemoPodGraph (kept as fallback), seedGraph/graph:seed (kept; materializeAndSeed reuses its write block).
8. Risks & open questions
engineer_statesis keyed byname;observationsbyengineerId; collisions by names inengineers[]. The materializer must reconcile these to oneengineer:<name>node. Assumption (from data map): LiveKit identity ==--name== engineer name. If they diverge, edges will orphan. Mitigation: key all engineer nodes offpods.membersand match case-insensitively; drop unmatched.outcomeshas noengineerId/file— onlyinterventionId/collisionId. To draw thelearned_fromedge to the right engineer/file,live.tsmust joinoutcome → intervention.collisionId → collision.file/engineersand pick the de-factoprimaryOwner(most-recent observation on that file). If observations expired (6h TTL), fall back to the first name incollision.engineers.observationsTTL may not fire (init.ts indexesobservedAtas Date but it's stored as ISO string) — so "active" windowing must compare parsed ISO timestamps inlive.ts, not rely on TTL eviction; old observations could otherwise inflate "active." Open: cap to most-recent observation per(engineerId,file).intervention.statusnever advances pastpending— confirmed; the UI must color interventions from theoutcomesjoin, never fromstatus. Already handled in §1, but worth a one-line code comment so a future dev doesn't "fix" it by readingstatus.- ws
/api/eventshas no pod-scoping —GRAPH_DIRTYcarriespodIdand the client filters; acceptable for the demo, flagged as the known shortcut. - Open question: should
materializeAndSeedrun on everyPOST /api/outcome(simple, slightly heavy) or be debounced? For a 3-engineer demo, run inline; revisit only if outcome volume spikes. - Open question: when both a live
materializePodGraphgraph and ateam_model.graphexist, live wins (§3b). Confirm no flow expectsteam_model.graphto be authoritative — currently nothing writes it except the deadseedGraph, so live-wins is safe.