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:
@@ -16,9 +16,9 @@ export function createDemoPodGraph(podId: string): PodGraph {
|
|||||||
loop: [
|
loop: [
|
||||||
{ key: 'observe', title: 'OBSERVE', value: '5', detail: '~5/s vision contexts', active: false },
|
{ 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: '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: '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: [
|
activity: [
|
||||||
{
|
{
|
||||||
@@ -47,21 +47,23 @@ export function createDemoPodGraph(podId: string): PodGraph {
|
|||||||
text: 'Yahya opened auth.ts — unpushed changes',
|
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: [
|
metrics: [
|
||||||
{
|
{
|
||||||
label: 'Learned owners',
|
label: 'Learned owners',
|
||||||
value: '5',
|
value: '3',
|
||||||
detail: 'Ownership edges retained from accepted interventions.',
|
detail: 'Distinct owners retained from accepted interventions.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Open risk paths',
|
label: 'Open risk paths',
|
||||||
value: '2',
|
value: '1',
|
||||||
detail: 'auth.ts and the memory API have converging editors.',
|
detail: 'File with two or more converging editors.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Accept rate',
|
label: 'Accept rate',
|
||||||
value: '86%',
|
value: '100%',
|
||||||
detail: 'Interventions accepted this session (+14%).',
|
detail: 'Interventions accepted vs total this session.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
nodes: [
|
nodes: [
|
||||||
|
|||||||
+31
-15
@@ -219,7 +219,7 @@ function buildLoop(opts: {
|
|||||||
key: 'predict',
|
key: 'predict',
|
||||||
title: 'PREDICT',
|
title: 'PREDICT',
|
||||||
value: String(riskPaths),
|
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' },
|
{ 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);
|
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 acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
|
||||||
const totalOutcomes = outcomeDocs.length;
|
const totalOutcomes = outcomeDocs.length;
|
||||||
const riskPaths = new Set(
|
const acceptRate = totalOutcomes ? Math.round((acceptedReal / totalOutcomes) * 100) : null;
|
||||||
collisionDocs.map(
|
|
||||||
(col) =>
|
|
||||||
(col as { memorySignature?: string }).memorySignature ??
|
|
||||||
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
|
|
||||||
),
|
|
||||||
).size;
|
|
||||||
const metrics: PodGraphMetric[] = [
|
const metrics: PodGraphMetric[] = [
|
||||||
{
|
{
|
||||||
label: 'Learned owners',
|
label: 'Learned owners',
|
||||||
value: String(acceptedReal),
|
value: String(learnedOwners),
|
||||||
detail: 'Ownership retained from accepted interventions.',
|
detail: 'Distinct owners retained from accepted interventions.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Open risk paths',
|
label: 'Open risk paths',
|
||||||
value: String(riskPaths),
|
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',
|
label: 'Accept rate',
|
||||||
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—',
|
value: acceptRate == null ? '—' : `${acceptRate}%`,
|
||||||
detail: 'Interventions accepted this session.',
|
detail: 'Interventions accepted vs total this session.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -590,8 +608,6 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
).length;
|
).length;
|
||||||
if (!vectorCount) vectorCount = collisionDocs.length;
|
if (!vectorCount) vectorCount = collisionDocs.length;
|
||||||
|
|
||||||
const learnedOwners = Object.keys(ownership).length || acceptedReal;
|
|
||||||
|
|
||||||
const loop = buildLoop({
|
const loop = buildLoop({
|
||||||
now,
|
now,
|
||||||
observations,
|
observations,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { MetricsRail } from './graph/MetricsRail.js';
|
|||||||
import { LearningLoop } from './graph/LearningLoop.js';
|
import { LearningLoop } from './graph/LearningLoop.js';
|
||||||
import { ActivityStream } from './graph/ActivityStream.js';
|
import { ActivityStream } from './graph/ActivityStream.js';
|
||||||
import { SelectedNodePanel } from './graph/SelectedNodePanel.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;
|
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)
|
? (graph?.edges ?? []).filter((e) => e.source === liveSelected || e.target === liveSelected)
|
||||||
.length
|
.length
|
||||||
: 0;
|
: 0;
|
||||||
|
const flow = graph && liveSelected ? flowNarrative(graph, liveSelected) : '';
|
||||||
|
|
||||||
function pick(next: Mode) {
|
function pick(next: Mode) {
|
||||||
setMode(next);
|
setMode(next);
|
||||||
@@ -181,7 +182,7 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
|||||||
<ActivityStream events={graph.activity ?? []} />
|
<ActivityStream events={graph.activity ?? []} />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border bg-muted/40 p-4">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
import type { PodGraphNode } from '@podman/shared';
|
import type { PodGraphNode } from '@podman/shared';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { statusColor, VIOLET } from './encoding.js';
|
import { statusColor, modeBlurb, VIOLET, type Mode } from './encoding.js';
|
||||||
|
|
||||||
export function SelectedNodePanel({
|
export function SelectedNodePanel({
|
||||||
node,
|
node,
|
||||||
relCount,
|
relCount,
|
||||||
|
flow,
|
||||||
|
mode,
|
||||||
}: {
|
}: {
|
||||||
node: PodGraphNode | undefined;
|
node: PodGraphNode | undefined;
|
||||||
relCount: number;
|
relCount: number;
|
||||||
|
flow: string;
|
||||||
|
mode: Mode;
|
||||||
}) {
|
}) {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<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>
|
</p>
|
||||||
<h3 className="mb-2 font-heading text-base font-medium">It learned</h3>
|
<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">
|
<p className="text-sm leading-relaxed text-muted-foreground">{modeBlurb(mode)}</p>
|
||||||
The violet{' '}
|
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Click any node to trace its{' '}
|
||||||
<span className="font-medium" style={{ color: VIOLET }}>
|
<span className="font-medium" style={{ color: VIOLET }}>
|
||||||
learned_from
|
flow
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
edges are ownership PodMan retained from accepted interventions — the graph gets sharper
|
— what PodMan saw, flagged, and learned. Drag to rearrange.
|
||||||
every session. Click any node to trace its relationships, or drag to rearrange.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -43,8 +47,16 @@ export function SelectedNodePanel({
|
|||||||
<span>Relationships</span>
|
<span>Relationships</span>
|
||||||
<span className="font-medium text-foreground">{relCount}</span>
|
<span className="font-medium text-foreground">{relCount}</span>
|
||||||
</div>
|
</div>
|
||||||
{node.summary && (
|
{flow && (
|
||||||
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">{node.summary}</p>
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }> = [
|
export const NODE_LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||||
{ label: 'engineer', swatch: { background: BLUE } },
|
{ label: 'engineer', swatch: { background: BLUE } },
|
||||||
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
|
{ 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: 'warns', color: AMBER },
|
||||||
{ label: 'learned_from', color: VIOLET, dash: true },
|
{ label: 'learned_from', color: VIOLET, dash: true },
|
||||||
{ label: 'owns', color: BLUE },
|
{ label: 'owns', color: BLUE },
|
||||||
|
{ label: 'editing', color: SLATE_EDGE },
|
||||||
|
{ label: 'touches', color: SLATE_FAINT },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user