fix(graph): honest graph-consistent metrics + flow narrative on node click

Address review feedback on the live view:

Metrics looked fake because they counted raw DB events (test churn) instead of
the de-noised entities actually drawn — e.g. "Open risk paths: 50" for 2 files,
"Learned owners: 16" with 2 ownership edges, and the caption ("Files with 2+
editors") contradicting the number. Now derived from the final graph:
- Open risk paths = distinct files carrying a surviving collision (50 -> 4 on live).
- Learned owners  = distinct engineers retained as owners via owns/learned_from
  edges (16 -> 1 on live).
- Accept rate     = accepted vs total real outcomes.
demo.ts metrics + loop counts realigned to its own graph (3 owners / 1 risk path
/ 100%) so nothing contradicts the picture; the PREDICT/ADAPT loop stages reuse
the same de-noised counts.

Right pane now explains the flow: clicking a node renders a plain-English walk of
its path (flowNarrative) — "Karti and Yahya are both editing auth.ts before
pushing ... PodMan suggested a sync PR", "PodMan offered a sync PR for the overlap
on auth.ts. The pod accepted it, so PodMan learned Karti owns auth.ts." With no
selection the panel gives a mode-aware explainer of what the lit path means. Edge
legend rounded out with editing/touches.

Verified: lint + -r typecheck + -r build pass; Playwright confirmed the flow text
per node kind and the demo metrics (3/1/100%); the metric formula re-checked
against the real live graph (4 risk files / 1 learned owner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb-iam
2026-06-27 23:47:14 -07:00
parent c97863e05e
commit 5e9929f8da
5 changed files with 159 additions and 35 deletions
+10 -8
View File
@@ -16,9 +16,9 @@ export function createDemoPodGraph(podId: string): PodGraph {
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: 'predict', title: 'PREDICT', value: '1', detail: 'open risk path', 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 },
{ key: 'adapt', title: 'ADAPT', value: '3', detail: 'learned owners', active: false },
],
activity: [
{
@@ -47,21 +47,23 @@ export function createDemoPodGraph(podId: string): PodGraph {
text: 'Yahya opened auth.ts — unpushed changes',
},
],
// Kept consistent with the graph below (3 owner engineers, 1 collision file,
// 1 of 1 interventions accepted) so the numbers never contradict the picture.
metrics: [
{
label: 'Learned owners',
value: '5',
detail: 'Ownership edges retained from accepted interventions.',
value: '3',
detail: 'Distinct owners retained from accepted interventions.',
},
{
label: 'Open risk paths',
value: '2',
detail: 'auth.ts and the memory API have converging editors.',
value: '1',
detail: 'File with two or more converging editors.',
},
{
label: 'Accept rate',
value: '86%',
detail: 'Interventions accepted this session (+14%).',
value: '100%',
detail: 'Interventions accepted vs total this session.',
},
],
nodes: [
+31 -15
View File
@@ -219,7 +219,7 @@ function buildLoop(opts: {
key: 'predict',
title: 'PREDICT',
value: String(riskPaths),
detail: `${riskPaths === 1 ? 'collision' : 'collisions'} flagged`,
detail: `open risk path${riskPaths === 1 ? '' : 's'}`,
},
{ key: 'outcome', title: 'OUTCOME', value: `${accepted}/${dismissed}`, detail: 'accepted · dismissed' },
{
@@ -549,30 +549,48 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
layout(nodes);
// Metrics are derived from the FINAL de-noised graph (not raw docs) so the
// numbers match what's actually on screen. Counting raw collision signatures /
// accepted-outcome rows inflates them with test churn (e.g. 50 "risk paths" for
// 2 files), which reads as fake — these count distinct visible entities instead.
const finalEdges = [...b.edges.values()];
// Open risk paths = distinct files carrying a surviving collision (the triangles).
const riskFiles = new Set<string>();
for (const e of finalEdges) {
if (e.kind === 'touches' && b.nodes.get(e.source)?.kind === 'file') riskFiles.add(e.source);
}
const collisionNodeCount = nodes.filter((n) => n.kind === 'collision').length;
const riskPaths = riskFiles.size || collisionNodeCount;
// Learned owners = distinct engineers PodMan retained as owners from accepted
// interventions (the owns / learned_from edges actually drawn).
const ownerSet = new Set<string>();
for (const e of finalEdges) {
if (e.kind === 'learned_from') ownerSet.add(e.target);
if (e.kind === 'owns') ownerSet.add(e.source);
}
const learnedOwners = [...ownerSet].filter((id) => b.nodes.get(id)?.kind === 'engineer').length;
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
const totalOutcomes = outcomeDocs.length;
const riskPaths = new Set(
collisionDocs.map(
(col) =>
(col as { memorySignature?: string }).memorySignature ??
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
),
).size;
const acceptRate = totalOutcomes ? Math.round((acceptedReal / totalOutcomes) * 100) : null;
const metrics: PodGraphMetric[] = [
{
label: 'Learned owners',
value: String(acceptedReal),
detail: 'Ownership retained from accepted interventions.',
value: String(learnedOwners),
detail: 'Distinct owners retained from accepted interventions.',
},
{
label: 'Open risk paths',
value: String(riskPaths),
detail: 'Files with two or more converging editors.',
detail: `${riskPaths === 1 ? 'File' : 'Files'} with two or more converging editors.`,
},
{
label: 'Accept rate',
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—',
detail: 'Interventions accepted this session.',
value: acceptRate == null ? '—' : `${acceptRate}%`,
detail: 'Interventions accepted vs total this session.',
},
];
@@ -590,8 +608,6 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
).length;
if (!vectorCount) vectorCount = collisionDocs.length;
const learnedOwners = Object.keys(ownership).length || acceptedReal;
const loop = buildLoop({
now,
observations,
+3 -2
View File
@@ -8,7 +8,7 @@ import { MetricsRail } from './graph/MetricsRail.js';
import { LearningLoop } from './graph/LearningLoop.js';
import { ActivityStream } from './graph/ActivityStream.js';
import { SelectedNodePanel } from './graph/SelectedNodePanel.js';
import { highlightFor, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js';
import { highlightFor, flowNarrative, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js';
const POLL_MS = 5000;
@@ -100,6 +100,7 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
? (graph?.edges ?? []).filter((e) => e.source === liveSelected || e.target === liveSelected)
.length
: 0;
const flow = graph && liveSelected ? flowNarrative(graph, liveSelected) : '';
function pick(next: Mode) {
setMode(next);
@@ -181,7 +182,7 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
<ActivityStream events={graph.activity ?? []} />
</div>
<div className="rounded-xl border bg-muted/40 p-4">
<SelectedNodePanel node={sel} relCount={relCount} />
<SelectedNodePanel node={sel} relCount={relCount} flow={flow} mode={mode} />
</div>
</div>
@@ -1,28 +1,32 @@
import type { PodGraphNode } from '@podman/shared';
import { Badge } from '@/components/ui/badge';
import { statusColor, VIOLET } from './encoding.js';
import { statusColor, modeBlurb, VIOLET, type Mode } from './encoding.js';
export function SelectedNodePanel({
node,
relCount,
flow,
mode,
}: {
node: PodGraphNode | undefined;
relCount: number;
flow: string;
mode: Mode;
}) {
if (!node) {
return (
<div className="flex h-full flex-col">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Continual learning
{mode === 'learn' ? 'Learning edges' : mode === 'all' ? 'Whole graph' : 'Risk path'}
</p>
<h3 className="mb-2 font-heading text-base font-medium">It learned</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
The violet{' '}
<h3 className="mb-2 font-heading text-base font-medium">What you're looking at</h3>
<p className="text-sm leading-relaxed text-muted-foreground">{modeBlurb(mode)}</p>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Click any node to trace its{' '}
<span className="font-medium" style={{ color: VIOLET }}>
learned_from
flow
</span>{' '}
edges are ownership PodMan retained from accepted interventions the graph gets sharper
every session. Click any node to trace its relationships, or drag to rearrange.
what PodMan saw, flagged, and learned. Drag to rearrange.
</p>
</div>
);
@@ -43,8 +47,16 @@ export function SelectedNodePanel({
<span>Relationships</span>
<span className="font-medium text-foreground">{relCount}</span>
</div>
{node.summary && (
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">{node.summary}</p>
{flow && (
<>
<p className="mt-2.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
Flow
</p>
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{flow}</p>
</>
)}
{node.summary && node.summary !== flow && (
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{node.summary}</p>
)}
</div>
);
+93
View File
@@ -100,6 +100,97 @@ export function highlightFor(graph: PodGraph, mode: Mode, selected: string | nul
};
}
function joinNames(ids: string[], label: (id: string) => string): string {
const u = [...new Set(ids)].map(label);
if (u.length <= 1) return u[0] ?? '';
if (u.length === 2) return `${u[0]} and ${u[1]}`;
return `${u.slice(0, -1).join(', ')} and ${u[u.length - 1]}`;
}
/**
* A plain-English walk of the flow through a node — what PodMan saw, flagged,
* suggested, and learned — so clicking a node explains the path, not just shows
* attributes. Built by traversing the node's incident edges.
*/
export function flowNarrative(graph: PodGraph, nodeId: string): string {
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
const node = byId.get(nodeId);
if (!node) return '';
const label = (id: string): string => byId.get(id)?.label ?? id;
const out = graph.edges.filter((e) => e.source === nodeId);
const inc = graph.edges.filter((e) => e.target === nodeId);
switch (node.kind) {
case 'engineer': {
const edits = out.filter((e) => e.kind === 'editing').map((e) => e.target);
const collisions = out.filter((e) => e.kind === 'collides');
const owns = out.filter((e) => e.kind === 'owns').map((e) => label(e.target));
const learned = inc.some((e) => e.kind === 'learned_from');
const parts: string[] = [];
if (edits.length) parts.push(`${node.label} is working in ${joinNames(edits, label)}.`);
if (collisions.length)
parts.push(
`PodMan flagged ${collisions.length} overlap${collisions.length === 1 ? '' : 's'} involving ${node.label}.`,
);
if (learned)
parts.push(
`From an accepted intervention PodMan learned ${node.label} owns ${owns[0] ?? 'this file'} — retained across sessions.`,
);
else if (owns.length) parts.push(`PodMan has ${node.label} owning ${joinNames(owns, (s) => s)}.`);
return parts.join(' ') || `${node.label} has no active flow right now.`;
}
case 'file': {
const editors = inc.filter((e) => e.kind === 'editing' || e.kind === 'owns').map((e) => e.source);
const hasCollision = out.some((e) => e.kind === 'touches');
const parts: string[] = [];
if (editors.length) parts.push(`${node.label} is being edited by ${joinNames(editors, label)}.`);
if (hasCollision)
parts.push('Two of those edits overlap before push, so PodMan opened a collision on it.');
return parts.join(' ') || node.summary || node.label;
}
case 'collision': {
const engineers = inc.filter((e) => e.kind === 'collides').map((e) => e.source);
const fileEdge = inc.find((e) => e.kind === 'touches');
const file = fileEdge ? label(fileEdge.source) : 'the same file';
const intervention = out.find((e) => e.kind === 'warns');
let s = `${joinNames(engineers, label) || 'Two engineers'} are both editing ${file} before pushing — the overlap git can't see.`;
if (intervention) s += ` PodMan stepped in and suggested a ${label(intervention.target)}.`;
return s;
}
case 'intervention': {
const colEdge = inc.find((e) => e.kind === 'warns');
const learned = out.find((e) => e.kind === 'learned_from');
// Resolve the collision's underlying file via its touches edge (file → collision).
let file = '';
if (colEdge) {
const fileEdge = graph.edges.find((e) => e.kind === 'touches' && e.target === colEdge.source);
file = fileEdge ? label(fileEdge.source) : '';
}
let s = `PodMan offered a ${node.label}${file ? ` for the overlap on ${file}` : ''}.`;
if (learned)
s += ` The pod accepted it, so PodMan learned ${label(learned.target)} owns ${file || 'the file'} — the graph got sharper.`;
return s;
}
case 'feature': {
const contributors = inc.filter((e) => e.kind === 'owns' || e.kind === 'touches').map((e) => e.source);
return contributors.length
? `${node.label} is built on work by ${joinNames(contributors, label)}.`
: node.summary || node.label;
}
default:
return node.summary ?? '';
}
}
/** Short explainer for the current view when nothing is selected. */
export function modeBlurb(mode: Mode): string {
if (mode === 'learn')
return 'The violet learned_from links are ownership PodMan kept from accepted interventions — the graph sharpens every session.';
if (mode === 'all')
return 'Everyone, every file, and every collision and intervention PodMan is tracking for this pod.';
return 'The lit path: files where two editors collide before push → the nudge PodMan sent → what it learned.';
}
export const NODE_LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
{ label: 'engineer', swatch: { background: BLUE } },
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
@@ -113,4 +204,6 @@ export const EDGE_LEGEND: Array<{ label: string; color: string; dash?: boolean }
{ label: 'warns', color: AMBER },
{ label: 'learned_from', color: VIOLET, dash: true },
{ label: 'owns', color: BLUE },
{ label: 'editing', color: SLATE_EDGE },
{ label: 'touches', color: SLATE_FAINT },
];