feat(graph): dynamic force-directed Team-memory graph + learning-loop & activity rails

Rebuild the live "Team memory" view so the light/real-data version matches the
dark Bauhaus mock and the graph is genuinely DYNAMIC instead of dead static columns.

Frontend (frontend/src/components/graph/*, composed into GraphView.tsx):
- forceSim.ts: a tiny dependency-free force layout (charge repulsion, link springs,
  centroid recentering + gentle pull, 2-pass collision, bounds clamp, alpha anneal).
  No d3-force dependency added — keeps the shared pnpm-lock untouched so CI's
  frozen-lockfile install and the deploy path are unaffected.
- GraphCanvas.tsx: SVG render driven by the sim — draggable + pinnable nodes
  (double-click to release), curved edges that fan parallel pairs, weight-sized
  geometric node shapes, fade-in on new nodes/edges, animated learned_from dash,
  risk-path lighting with the rest dimmed, label collision-avoidance.
- MetricsRail / LearningLoop / ActivityStream / SelectedNodePanel / encoding.ts:
  the mock's rails + stream + detail panel, light shadcn (ToggleGroup, ScrollArea,
  Badge, Button) on theme tokens; only the SVG is bespoke.
- GraphView polls /api/pods/:id/graph every 5s and diffs (positions preserved across
  refreshes), with a best-effort ws /api/events nudge. A stale selection (node gone
  across a poll) is dropped so the canvas can't dim entirely.

Backend (additive — materializer de-noise untouched):
- live.ts: buildLoop() (observe→store→predict→outcome→adapt counts, deepest-recent
  stage active) and buildActivity() (time-sorted typed feed, same isFilePath /
  ENGINEER_NOISE / signature de-noise) emitted alongside nodes/edges/metrics.
- demo.ts: fallback loop + activity so the panels render on the demo path.
- shared/src/graph.ts: additive optional PodGraph.loop / .activity + LearningStage /
  ActivityEvent types.

Verified: pnpm lint + -r typecheck + -r build pass; Playwright on the dev build
confirmed force layout (distinct positions, ticks on load under StrictMode), drag,
risk-mode dimming (opacity 0.14), selection panel, legend, and no overlaps on both
the clean demo and the 31-node live hairball.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb-iam
2026-06-27 23:27:54 -07:00
parent 03a8af6da0
commit c97863e05e
13 changed files with 1362 additions and 253 deletions
+36
View File
@@ -8,9 +8,45 @@ import type { PodGraph } from '@podman/shared';
* auth* — is the continual-learning story the demo lights up.
*/
export function createDemoPodGraph(podId: string): PodGraph {
const base = Date.now();
const at = (secAgo: number): string => new Date(base - secAgo * 1000).toISOString();
return {
podId,
generatedAt: new Date().toISOString(),
loop: [
{ key: 'observe', title: 'OBSERVE', value: '5', detail: '~5/s vision contexts', active: false },
{ key: 'store', title: 'STORE', value: '124', detail: 'memory vectors · Atlas', active: false },
{ key: 'predict', title: 'PREDICT', value: '2', detail: 'collisions flagged', active: true },
{ key: 'outcome', title: 'OUTCOME', value: '1/0', detail: 'accepted · dismissed', active: false },
{ key: 'adapt', title: 'ADAPT', value: '5', detail: 'learned owners', active: false },
],
activity: [
{
id: 'demo-learn',
at: at(20),
kind: 'learned_from',
text: 'Memory updated: Karti owns auth.ts (confidence ↑)',
},
{ id: 'demo-out', at: at(24), kind: 'outcome', text: 'Intervention accepted by the pod' },
{
id: 'demo-warn',
at: at(40),
kind: 'warns',
text: 'PodMan: "Karti & Yahya are both in auth.ts — open a sync PR?" → card sent',
},
{
id: 'demo-col',
at: at(58),
kind: 'collision',
text: 'Critical overlap on auth.ts · Karti + Yahya',
},
{
id: 'demo-edit',
at: at(72),
kind: 'editing',
text: 'Yahya opened auth.ts — unpushed changes',
},
],
metrics: [
{
label: 'Learned owners',
+221
View File
@@ -6,6 +6,13 @@ import type {
PodGraphNodeKind,
PodGraphEdgeKind,
PodGraphNodeStatus,
LearningStage,
LearningStageKey,
ActivityEvent,
EngineerContext,
Collision,
Intervention,
InterventionOutcome,
} from '@podman/shared';
import { collections, getGitStates, getDb } from '../memory/db.js';
@@ -148,6 +155,185 @@ function layout(nodes: PodGraphNode[]): void {
const SEVERITY_WEIGHT: Record<string, number> = { info: 0.4, warn: 0.7, critical: 1 };
/** Parse any timestamp-ish value to epoch ms (0 when missing/unparseable). */
function ms(t: string | Date | null | undefined): number {
if (!t) return 0;
const v = new Date(t).getTime();
return Number.isFinite(v) ? v : 0;
}
const OBSERVE_WINDOW_MS = 60_000;
/**
* Live counts for the learning-loop rail (observe→store→predict→outcome→adapt).
* The "active" stage is the one whose latest underlying event is most recent —
* with deeper stages winning ties so the rail lights up at the furthest point
* the pod reached this session. Additive: derived from already-fetched docs.
*/
function buildLoop(opts: {
now: number;
observations: EngineerContext[];
collisions: Collision[];
outcomes: InterventionOutcome[];
riskPaths: number;
vectorCount: number;
learnedOwners: number;
}): LearningStage[] {
const { now, observations, collisions, outcomes, riskPaths, vectorCount, learnedOwners } = opts;
const recentObs = observations.filter((o) => now - ms(o.observedAt) < OBSERVE_WINDOW_MS).length;
const rate = (recentObs / 60).toFixed(1);
const accepted = outcomes.filter((o) => o.accepted).length;
const dismissed = outcomes.filter((o) => !o.accepted).length;
// Latest event time per stage; `store` sits just behind `predict` so a shared
// collision timestamp resolves to PREDICT rather than STORE.
const latestObs = Math.max(0, ...observations.map((o) => ms(o.observedAt)));
const latestCol = Math.max(0, ...collisions.map((c) => ms(c.detectedAt)));
const latestOut = Math.max(0, ...outcomes.map((o) => ms(o.recordedAt)));
const latestAdapt = Math.max(
0,
...outcomes.filter((o) => o.accepted && o.wasRealCollision).map((o) => ms(o.recordedAt)),
);
const refs: Array<[LearningStageKey, number]> = [
['observe', latestObs],
['store', latestCol ? latestCol - 1 : 0],
['predict', latestCol],
['outcome', latestOut],
['adapt', latestAdapt],
];
let activeKey: LearningStageKey = 'observe';
let best = 0;
for (const [k, t] of refs) {
if (t > 0 && t >= best) {
best = t;
activeKey = k;
}
}
const stages: Array<Omit<LearningStage, 'active'>> = [
{ key: 'observe', title: 'OBSERVE', value: String(recentObs), detail: `~${rate}/s vision contexts` },
{ key: 'store', title: 'STORE', value: String(vectorCount), detail: 'memory vectors · Atlas' },
{
key: 'predict',
title: 'PREDICT',
value: String(riskPaths),
detail: `${riskPaths === 1 ? 'collision' : 'collisions'} flagged`,
},
{ key: 'outcome', title: 'OUTCOME', value: `${accepted}/${dismissed}`, detail: 'accepted · dismissed' },
{
key: 'adapt',
title: 'ADAPT',
value: String(learnedOwners),
detail: `learned owner${learnedOwners === 1 ? '' : 's'}`,
},
];
return stages.map((s) => ({ ...s, active: s.key === activeKey }));
}
/**
* Merge + time-sort recent events into the activity stream feed. Reuses the same
* de-noise (isFilePath / ENGINEER_NOISE / signature collapse) as the graph so
* the feed never shows junk paths or test-artifact engineers. Capped to 8.
*/
function buildActivity(opts: {
observations: EngineerContext[];
collisions: Collision[];
interventions: Intervention[];
outcomes: InterventionOutcome[];
ownership: Record<string, string>;
}): ActivityEvent[] {
const { observations, collisions, interventions, outcomes, ownership } = opts;
const cleanEng = (n: string): boolean => Boolean(n) && !ENGINEER_NOISE.test(n);
const out: ActivityEvent[] = [];
// editing — newest observation per (engineer, file); observations arrive desc.
const seenEdit = new Set<string>();
for (const o of observations) {
if (!o.engineerId || !cleanEng(o.engineerId)) continue;
const file = o.currentFile ? normalizeFile(o.currentFile) : '';
if (!isFilePath(file)) continue;
const key = `${o.engineerId.toLowerCase()}|${file}`;
if (seenEdit.has(key)) continue;
seenEdit.add(key);
out.push({
id: `edit:${o.engineerId}:${file}`,
at: o.observedAt,
kind: 'editing',
text: `${o.engineerId} opened ${shortLabel(file)}${
o.hasUnpushedChanges ? ' — unpushed changes' : ''
}`,
});
}
// collision — collapse by signature, newest first.
const seenCol = new Set<string>();
for (const c of collisions) {
const file = normalizeFile(c.file);
if (!isFilePath(file)) continue;
const sig = (c as { memorySignature?: string }).memorySignature ?? `${file}#${c.symbol ?? ''}`;
if (seenCol.has(sig)) continue;
seenCol.add(sig);
const engs = c.engineers.filter(cleanEng);
if (!engs.length) continue;
out.push({
id: `col:${c.id}`,
at: c.detectedAt,
kind: 'collision',
text: `${c.severity === 'critical' ? 'Critical overlap' : 'Overlap'} on ${shortLabel(
file,
)} · ${engs.join(' + ')}`,
});
}
// warns — interventions PodMan raised.
for (const iv of interventions) {
if (!iv.message) continue;
const msg = iv.message.length > 64 ? `${iv.message.slice(0, 61)}` : iv.message;
out.push({
id: `warn:${iv.id}`,
at: iv.createdAt,
kind: 'warns',
text: `PodMan: "${msg}" → card sent`,
});
}
// outcome + learned_from — the supervised learning beat.
const colById = new Map(collisions.map((c) => [c.id, c]));
const ivById = new Map(interventions.map((i) => [i.id, i]));
for (const o of outcomes) {
if (!o.accepted) continue;
out.push({
id: `out:${o.interventionId}`,
at: o.recordedAt,
kind: 'outcome',
text: 'Intervention accepted by the pod',
});
if (!o.wasRealCollision) continue;
const iv = ivById.get(o.interventionId);
const col = iv ? colById.get(iv.collisionId) : colById.get(o.collisionId);
if (!col) continue;
const file = normalizeFile(col.file);
if (!isFilePath(file)) continue;
const owner =
(o as { learnedOwner?: string }).learnedOwner ??
ownership[file] ??
col.engineers.find(cleanEng) ??
col.engineers[0];
if (!owner) continue;
out.push({
id: `learn:${o.interventionId}`,
at: o.recordedAt,
kind: 'learned_from',
text: `Memory updated: ${owner} owns ${shortLabel(file)} (confidence ↑)`,
});
}
out.sort((a, b) => ms(b.at) - ms(a.at));
return out.slice(0, 8);
}
export async function materializePodGraph(podId: string): Promise<PodGraph | null> {
const c = await collections();
const db = await getDb();
@@ -390,11 +576,46 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
},
];
// Stored vectors for the STORE stage: prefer a real memory_vectors count,
// fall back to collisions carrying an embedding, then to collision count.
let vectorCount = 0;
try {
vectorCount = await db.collection('memory_vectors').countDocuments({ podId });
} catch {
/* memory_vectors is optional */
}
if (!vectorCount)
vectorCount = collisionDocs.filter(
(c) => (c as { embedding?: number[] }).embedding?.length,
).length;
if (!vectorCount) vectorCount = collisionDocs.length;
const learnedOwners = Object.keys(ownership).length || acceptedReal;
const loop = buildLoop({
now,
observations,
collisions: collisionDocs,
outcomes: outcomeDocs,
riskPaths,
vectorCount,
learnedOwners,
});
const activity = buildActivity({
observations,
collisions: collisionDocs,
interventions: interventionDocs,
outcomes: outcomeDocs,
ownership,
});
return {
podId,
generatedAt: new Date().toISOString(),
nodes,
edges: [...b.edges.values()],
metrics,
loop,
activity,
};
}