Files
podman/docs/live-ui-spec.md
T
sb-iam 5a96e2b1ae docs(graph): spec for the live continual-learning UI
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>
2026-06-27 19:07:31 -07:00

23 KiB
Raw Blame History

Real Continual-Learning UI — Spec

Owner: graph data + visualization (live data). Status: demo-backed graph shipped (docs/graph.md); this spec makes it REAL. Extends docs/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 behind loadPodGraph, an outcomes aggregation, a ws push of GRAPH_DIRTY, and the per-pod GraphView wiring. Everything in graph.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 realteam_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. A collisions doc is written, an interventions doc (status pending). The graph grows a red collision triangle and a warns edge to the intervention diamond. Copy: "new — first time PodMan saw this path."
  • After: The human clicks Accept → POST /api/outcome writes {accepted:true, wasRealCollision:true}. The materializer turns that outcome into a learned_from edge (intervention → engineer, label learned: owns auth.ts), flips the engineer node to status:'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 by collisions.memorySignature recall, 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:

  1. Observe (observations, ~1fps; engineer_states, 15s): engineer nodes light active; editing edges thicken with Gemini confidence. A small caption under the canvas: "Yahya — editing auth.ts (unpushed)" from the latest observation + git chip.
  2. Store / Predict (collisions + vector recall): when ≥2 engineers + unpushed, a red collision triangle and collides/touches edges appear. If memorySignature recall hit (severity critical), the node carries a "seen before" badge — the self-improvement signal.
  3. Intervene (interventions): a warns edge draws to a new intervention diamond labeled by suggestedAction.kind. The view shows a "PodMan is speaking" pulse on that edge when a VOICE_CUE arrives over ws (§5).
  4. Outcome (POST /api/outcome from the PodView card): the moment the human clicks Accept.
  5. Adapt (outcomeslearned_from): on the next graph refresh, a dashed purple learned_from edge animates in, the engineer node flips to learned (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. loadPodGraphbackend/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 just PodGraphMetric[] (the outcomes aggregation: learned owners, open risk paths, accept rate) so the rail can poll cheaply without re-sending the whole graph. Backed by a db.collection('outcomes').aggregate group on podId (count, $sum accepted).
  • POST /api/outcome (MODIFY): after recordOutcome, call materializeAndSeed(podId) (best-effort, never throws) and broadcast {type:'GRAPH_DIRTY', podId} to ws /api/events clients (reuse the existing clients set / 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/events on mount; on {type:'GRAPH_DIRTY', podId} (matching this pod) or COLLISION/VOICE_CUE, re-call fetchPodGraph(podId) (and fetchGraphMetrics). Also a 5s poll of fetchPodGraph as the floor (mirrors App.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_from edges animate the dashed stroke.
  • "new" vs "seen before" copy in the detail rail from collision node summary badge (§1).
  • Speaking pulse on the warns edge when a VOICE_CUE lands.

4c. frontend/src/lib/graph.ts (MODIFY)

  • Fix BACKEND_URL to follow lib/api.ts's resolution (empty string in prod) instead of hardcoding localhost:8787.
  • Add fetchGraphMetrics(podId)GET /api/pods/:id/graph/metrics.
  • Add openGraphEvents(podId, onDirty) → thin WebSocket('/api/events') subscription helper (reused by GraphView).

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: fetchPodGraph rejects → 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/outcomematerializeAndSeedGRAPH_DIRTY, the one path that must be solid.

6. Demo path

Must-have (the 3-min money moment):

  • materializePodGraph reading collisions + interventions + outcomes + engineer_states so the graph reflects this session.
  • POST /api/outcomematerializeAndSeed + GRAPH_DIRTY; GraphView refetches and the learned_from edge + 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_CUE speaking pulse on the warns edge.
  • /graph/reach/:node lit risk-path walk ($graphLookup) once materializeAndSeed populates graph_edges.
  • Fade/stroke animations on incoming edges.
  • Per-PodCard graph entry button.

Cut if behind (per CLAUDE.md 12h box):

  • Vector-search dependency for recall (exact memorySignature recall 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.md land before code. Add task Pxx — Live continual-learning graph to PLAN.md with the Files list below.

Create:

  • backend/src/graph/live.tsmaterializePodGraph(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 ("Swap loadPodGraph…") to "done via materializePodGraph." (documentation-first)
  • docs/PLAN.md — add the task + Files list. (documentation-first)
  • backend/src/graph/store.tsloadPodGraph prefers live (§3b); add materializeAndSeed(podId) (§3c). (Task: live wiring + $graphLookup)
  • backend/src/server.tsPOST /api/outcome calls materializeAndSeed + broadcasts GRAPH_DIRTY; new GET /api/pods/:id/graph/metrics; ws passthrough of COLLISION/VOICE_CUE/GRAPH_DIRTY. (Task: routes + realtime)
  • backend/src/agent/podman.ts — also emit COLLISION/VOICE_CUE onto ws /api/events (mirror of the LiveKit data-channel publish) so the dashboard-level GraphView reacts. (Task: realtime mirror)
  • frontend/src/lib/graph.tsBACKEND_URL resolution 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 not pods[0]. (Task: entry point)
  • frontend/src/components/PodCard.tsxonOpenGraph(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_states is keyed by name; observations by engineerId; collisions by names in engineers[]. The materializer must reconcile these to one engineer:<name> node. Assumption (from data map): LiveKit identity == --name == engineer name. If they diverge, edges will orphan. Mitigation: key all engineer nodes off pods.members and match case-insensitively; drop unmatched.
  • outcomes has no engineerId/file — only interventionId/collisionId. To draw the learned_from edge to the right engineer/file, live.ts must join outcome → intervention.collisionId → collision.file/engineers and pick the de-facto primaryOwner (most-recent observation on that file). If observations expired (6h TTL), fall back to the first name in collision.engineers.
  • observations TTL may not fire (init.ts indexes observedAt as Date but it's stored as ISO string) — so "active" windowing must compare parsed ISO timestamps in live.ts, not rely on TTL eviction; old observations could otherwise inflate "active." Open: cap to most-recent observation per (engineerId,file).
  • intervention.status never advances past pending — confirmed; the UI must color interventions from the outcomes join, never from status. Already handled in §1, but worth a one-line code comment so a future dev doesn't "fix" it by reading status.
  • ws /api/events has no pod-scopingGRAPH_DIRTY carries podId and the client filters; acceptable for the demo, flagged as the known shortcut.
  • Open question: should materializeAndSeed run on every POST /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 materializePodGraph graph and a team_model.graph exist, live wins (§3b). Confirm no flow expects team_model.graph to be authoritative — currently nothing writes it except the dead seedGraph, so live-wins is safe.