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:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user