From 5e9929f8dad041f015856ad5dcdba404f16c936e Mon Sep 17 00:00:00 2001 From: sb-iam <59984144+sb-iam@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:47:14 -0700 Subject: [PATCH] fix(graph): honest graph-consistent metrics + flow narrative on node click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/graph/demo.ts | 18 ++-- backend/src/graph/live.ts | 46 ++++++--- frontend/src/components/GraphView.tsx | 5 +- .../components/graph/SelectedNodePanel.tsx | 32 +++++-- frontend/src/components/graph/encoding.ts | 93 +++++++++++++++++++ 5 files changed, 159 insertions(+), 35 deletions(-) diff --git a/backend/src/graph/demo.ts b/backend/src/graph/demo.ts index a214547..f1a1fb3 100644 --- a/backend/src/graph/demo.ts +++ b/backend/src/graph/demo.ts @@ -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: [ diff --git a/backend/src/graph/live.ts b/backend/src/graph/live.ts index 1b99d0d..098b7d6 100644 --- a/backend/src/graph/live.ts +++ b/backend/src/graph/live.ts @@ -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(); + 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(); + 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 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
- +
diff --git a/frontend/src/components/graph/SelectedNodePanel.tsx b/frontend/src/components/graph/SelectedNodePanel.tsx index 49c73f3..d73d918 100644 --- a/frontend/src/components/graph/SelectedNodePanel.tsx +++ b/frontend/src/components/graph/SelectedNodePanel.tsx @@ -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 (

- Continual learning + {mode === 'learn' ? 'Learning edges' : mode === 'all' ? 'Whole graph' : 'Risk path'}

-

It learned

-

- The violet{' '} +

What you're looking at

+

{modeBlurb(mode)}

+

+ Click any node to trace its{' '} - learned_from + flow {' '} - 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.

); @@ -43,8 +47,16 @@ export function SelectedNodePanel({ Relationships {relCount} - {node.summary && ( -

{node.summary}

+ {flow && ( + <> +

+ Flow +

+

{flow}

+ + )} + {node.summary && node.summary !== flow && ( +

{node.summary}

)} ); diff --git a/frontend/src/components/graph/encoding.ts b/frontend/src/components/graph/encoding.ts index 37f96bb..9cbac39 100644 --- a/frontend/src/components/graph/encoding.ts +++ b/frontend/src/components/graph/encoding.ts @@ -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 }, ];