diff --git a/backend/src/graph/demo.ts b/backend/src/graph/demo.ts index 57a0e32..f1a1fb3 100644 --- a/backend/src/graph/demo.ts +++ b/backend/src/graph/demo.ts @@ -8,24 +8,62 @@ 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: '1', detail: 'open risk path', active: true }, + { key: 'outcome', title: 'OUTCOME', value: '1/0', detail: 'accepted · dismissed', active: false }, + { key: 'adapt', title: 'ADAPT', value: '3', 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', + }, + ], + // 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 6cbb74d..098b7d6 100644 --- a/backend/src/graph/live.ts +++ b/backend/src/graph/live.ts @@ -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 = { 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> = [ + { 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: `open risk path${riskPaths === 1 ? '' : 's'}`, + }, + { 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; +}): 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(); + 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(); + 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 { const c = await collections(); const db = await getDb(); @@ -363,38 +549,89 @@ 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.', }, ]; + // 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 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, }; } diff --git a/docs/agent-learning/plan.md b/docs/agent-learning/plan.md new file mode 100644 index 0000000..e4b8e1d --- /dev/null +++ b/docs/agent-learning/plan.md @@ -0,0 +1,89 @@ +# Agent Learning Plan + +Status: draft +Goal: ship a visible recursive self-improvement loop without overbuilding + +## Must-Have + +1. Store agent runs. +2. Store trace summaries. +3. Store active and candidate strategy versions. +4. Attach verifier or outcome evidence. +5. Show one strategy improvement in the demo narrative. + +## Build Order + +### R1: Trace the run + +Write one `agent_runs` record for an important coordination decision and append +trace events for: + +- observation +- recall +- prediction +- intervention +- outcome +- adaptation + +### R2: Version the strategy + +Create an active strategy version for one of: + +- collision detector threshold +- intervention routing +- graph discovery filter +- card wording prompt + +### R3: Score the outcome + +Use the simplest verifier: + +- accepted real collision = useful +- dismissed = noisy +- no response after cooldown = uncertain + +### R4: Propose a narrow change + +Examples: + +- "For this exact signature, prefer sync PR card." +- "For dismissed docs-only overlaps, suppress voice escalation." +- "For repeated auth.ts collisions, raise severity." + +### R5: Promote or reject + +Promote only when evidence is strong enough. Otherwise keep the candidate as +rejected or open. + +## Demo Path + +1. Show baseline strategy. +2. Trigger a collision. +3. Accept or dismiss the intervention. +4. Store outcome. +5. Show a candidate strategy update. +6. Promote it. +7. Trigger a similar event. +8. Show changed behavior. + +## Nice-to-Have + +- Strategy comparison panel. +- Model-generated prompt patch with verifier. +- Vector recall over strategy history. +- Rollback UI. + +## Cut + +- Full autonomous code rewriting. +- Multi-agent strategy debates. +- Long-term benchmark suite. +- Training a model. + +## Acceptance Criteria + +- The demo can point to a MongoDB record proving the agent changed behavior. +- The changed behavior is visible. +- The strategy has a parent and evidence. +- Rejected or failed changes are not deleted. + diff --git a/docs/agent-learning/policy.md b/docs/agent-learning/policy.md new file mode 100644 index 0000000..ce633fc --- /dev/null +++ b/docs/agent-learning/policy.md @@ -0,0 +1,84 @@ +# Agent Learning Policy + +Status: draft +Scope: guardrails for recursive self-improvement + +## Prime Rule + +PodMan may improve its agent behavior only when the improvement is narrow, +evidence-backed, versioned, and reversible. + +## Allowed Learning + +PodMan may learn: + +- Which prompt version produces clearer interventions. +- Which detector threshold reduces false positives. +- Which routing channel gets accepted without being intrusive. +- Which verifier best predicts user acceptance. +- Which graph-discovery rule produces cleaner risk paths. + +## Disallowed Learning + +PodMan must not: + +- Promote a strategy because the model says it is better. +- Rewrite broad system behavior from one example. +- Hide failures, dismissals, or rejected candidates. +- Learn from raw screenshots, secrets, or private terminal content. +- Turn voice into the default route. +- Create irreversible actions without human approval. + +## Promotion Rules + +A candidate strategy can become active only when all are true: + +1. It has a parent strategy version. +2. It describes one concrete behavior change. +3. It has a verifier plan. +4. It has evidence from a run, outcome, or test. +5. It improves or fixes the target metric. +6. It does not increase user interruption without payoff. + +## Rejection Rules + +Reject and retain the candidate when: + +- The verifier regresses. +- The change is too broad. +- The evidence is missing. +- The candidate conflicts with privacy rules. +- The candidate makes the demo less stable. + +## Evidence Strength + +| Evidence | Strength | Use | +| --- | --- | --- | +| Model opinion | Weak | Proposal only | +| Trace observation | Medium | Candidate rationale | +| Human accepted outcome | Strong | Promotion candidate | +| Human dismissed outcome | Strong | Suppression or rejection | +| Automated verifier | Strong | Promotion or rejection | +| Repeated accepted exact signature | Strong | Policy confidence increase | + +## Versioning Rules + +- Strategy versions are immutable after promotion or rejection. +- There is one active version per `podId + kind`. +- A rollback activates the previous version; it does not edit history. +- Parent-child lineage must be preserved. + +## Safety Rules + +- Store summaries, not raw sensitive content. +- Prefer deterministic checks over model judgment. +- Use exact MongoDB recall before vector recall. +- Ask for approval before changing code or data with external effects. +- Treat hackathon demo stability as a hard constraint. + +## Demo Honesty + +Seeded strategy versions are acceptable when labeled as demo-backed. Do not claim +a strategy was learned live unless a run and outcome actually created the +promotion evidence. + diff --git a/docs/agent-learning/prompt.md b/docs/agent-learning/prompt.md new file mode 100644 index 0000000..b5ff301 --- /dev/null +++ b/docs/agent-learning/prompt.md @@ -0,0 +1,74 @@ +# Agent Learning Prompt + +Use this prompt for an agent responsible for improving PodMan's own behavior. + +## Prompt + +You are PodMan's agent-learning evaluator. + +Your job is to inspect a completed agent run, identify one narrow improvement, +define how to verify it, and decide whether to propose, promote, or reject a +strategy change. + +You must not claim improvement without evidence. You must not propose broad +rewrites. Keep every change small, reversible, and tied to a run or outcome. + +## Inputs + +- Current active strategy version. +- Agent run summary. +- Trace events. +- Intervention outcome. +- Verifier result. +- Recent false positives or accepted events. +- Current demo constraints. + +## Procedure + +1. Identify the target behavior. +2. Identify the failure or success evidence. +3. Decide whether a strategy change is warranted. +4. Propose one narrow change. +5. Define the verifier. +6. Decide status: no change, candidate, promote, reject. +7. Write a short explanation suitable for the Team memory activity stream. + +## Output Format + +```text +Target +- Strategy kind: +- Active version: +- Behavior under review: + +Evidence +- Run: +- Outcome: +- Verifier: +- Confidence: + +Decision +- Status: +- Proposed change: +- Why this is narrow: +- Risk: + +Verifier +- Metric: +- Passing condition: +- Failing condition: + +Memory Write +- Collection: +- Record summary: +- Graph/activity summary: +``` + +## Hard Rules + +- Exact outcomes beat model opinion. +- Rejected candidates stay in memory. +- No raw screenshots or secrets. +- No broad policy change from one weak signal. +- No voice-first behavior. + diff --git a/docs/agent-learning/spec.md b/docs/agent-learning/spec.md new file mode 100644 index 0000000..c4a32af --- /dev/null +++ b/docs/agent-learning/spec.md @@ -0,0 +1,185 @@ +# Agent Learning Spec + +Status: draft +Scope: how PodMan agents improve their own prompts, policies, detectors, and routing behavior +Owner: agent learning / recursive self-improvement + +## Purpose + +Agent learning is the recursive self-improvement layer. It is not the same as +team memory. Team memory learns about engineers and work. Agent learning learns +which agent strategies produce better outcomes. + +The demo claim: + +1. PodMan tries a coordination strategy. +2. The run is traced in MongoDB. +3. A verifier or human outcome scores it. +4. Gemini or another agent proposes a narrow strategy change. +5. The new strategy is versioned. +6. A later run uses the improved strategy and shows a better result. + +## Core Objects + +### Agent run + +One attempt to execute a goal. + +```text +agent_runs + runId + podId + goal + trigger + strategyVersionId + status + startedAt + completedAt + score + verifierSummary + inputRefs + outputRefs +``` + +Allowed `status` values: + +```text +running, succeeded, failed, improved, regressed, abandoned +``` + +### Trace event + +Append-only event log for a run. + +```text +agent_trace_events + runId + podId + step + phase + eventType + inputSummary + outputSummary + toolName + error + metrics + createdAt +``` + +### Strategy version + +Versioned prompt, detector rule, policy, verifier, or routing strategy. + +```text +strategy_versions + strategyVersionId + podId + kind + name + parentVersionId + status + summary + promptText + policy + verifier + metrics + createdAt + promotedAt +``` + +Allowed `kind` values: + +```text +prompt, policy, detector, verifier, routing +``` + +Allowed `status` values: + +```text +candidate, active, retired, rejected +``` + +### Learning proposal + +A candidate change before promotion. + +```text +learning_proposals + proposalId + podId + sourceRunId + targetKind + parentVersionId + proposedChange + rationale + verifierPlan + status + createdAt + resolvedAt +``` + +Allowed `status` values: + +```text +open, accepted, rejected, superseded +``` + +## MongoDB Indexes + +| Collection | Index | Purpose | +| --- | --- | --- | +| `agent_runs` | `{ podId: 1, startedAt: -1 }` | Recent run history | +| `agent_runs` | `{ podId: 1, strategyVersionId: 1 }` | Compare strategy performance | +| `agent_trace_events` | `{ runId: 1, step: 1 }` | Reconstruct run | +| `strategy_versions` | `{ podId: 1, kind: 1, status: 1 }` | Find active strategy | +| `strategy_versions` | `{ podId: 1, createdAt: -1 }` | Version history | +| `learning_proposals` | `{ podId: 1, status: 1 }` | Open candidate changes | + +## Learning Loop + +```text +observe run -> score run -> propose change -> test candidate -> promote or reject +``` + +Agent learning must always connect these records: + +```text +agent_run -> trace_events -> verifier result -> learning_proposal -> strategy_version +``` + +## Verifier Contract + +Every promoted strategy needs a verifier signal. + +Allowed verifier types: + +- Human accepted or dismissed outcome. +- Test pass or fail result. +- Reduced false positive rate. +- Reduced intervention count with same or better accepted outcomes. +- Faster successful run. +- Better graph discovery precision. +- Explicit demo operator approval. + +Self-evaluation alone is not enough to promote a strategy. + +## Relationship to Team Graph + +Agent learning can appear in the Team memory graph as activity and loop status, +but it should not clutter the main risk graph by default. + +Graph discovery may show: + +- `agent_run` activity in the stream. +- `strategy_versions` count in the learning loop. +- A selected-node detail saying a policy changed because a prior outcome was + dismissed or accepted. + +## Acceptance Criteria + +- Every strategy change has a parent. +- Every promoted strategy cites evidence. +- Rejected strategies are retained with a reason. +- Agent traces are append-only. +- The system can answer: "What changed, why, and did it help?" + diff --git a/docs/continual-learning/plan.md b/docs/continual-learning/plan.md new file mode 100644 index 0000000..6fea09a --- /dev/null +++ b/docs/continual-learning/plan.md @@ -0,0 +1,69 @@ +# Continual Learning Plan + +Status: draft +Goal: prove PodMan learns from outcomes in the hackathon demo + +## Must-Have Demo Loop + +1. Observe two engineers touching the same file. +2. Store the observation and git state in MongoDB. +3. Predict a collision. +4. Send a card or Hermes message. +5. Record accept or dismiss outcome. +6. Adapt `team_model`. +7. Show the learned graph edge or changed future behavior. + +## Build Order + +### R1: Make exact recall reliable + +- Normalize file paths. +- Build stable memory signatures. +- Look up prior accepted and dismissed outcomes. +- Prefer exact recall over vector recall. + +### R2: Make outcomes update memory + +- Accepted real collision creates or strengthens ownership. +- Accepted real collision creates `learned_from`. +- Dismissed outcome lowers confidence or suppresses route. + +### R3: Expose loop data to the graph + +- Add optional loop snapshot. +- Add optional activity stream. +- Keep existing `PodGraph` fields stable. + +### R4: Show the observatory + +- Render observe/store/predict/outcome/adapt. +- Show recent activity. +- Make selected-node detail explain why memory changed. + +### R5: Prepare a clean demo chain + +- Ensure one collision -> intervention -> accepted outcome exists. +- Ensure repeated signature recalls prior memory. +- Verify graph shows learned ownership. + +## Nice-to-Have + +- Atlas Vector Search over memory summaries. +- Confidence scoring per ownership edge. +- Per-file memory timeline. +- Strategy promotion tied to outcomes. + +## Cut + +- Raw screenshot storage. +- Full autonomous training. +- Broad dashboard metrics. +- Multi-pod learning generalization. + +## Acceptance Criteria + +- A judge can see what changed in memory. +- The second similar event behaves differently. +- Exact MongoDB records prove the loop. +- The graph remains legible with real data. + diff --git a/docs/continual-learning/policy.md b/docs/continual-learning/policy.md new file mode 100644 index 0000000..b4a72c2 --- /dev/null +++ b/docs/continual-learning/policy.md @@ -0,0 +1,97 @@ +# Continual Learning Policy + +Status: draft +Scope: what PodMan may learn about a team + +## Prime Rule + +PodMan learns coordination patterns, not personal surveillance profiles. + +## Allowed Memory + +PodMan may store: + +- File and symbol ownership. +- Active file overlap. +- Repeated collision signatures. +- Intervention history. +- Accepted and dismissed outcomes. +- Routing preferences by event type and severity. +- Summaries of decisions relevant to future coordination. + +## Forbidden Memory + +PodMan must not store: + +- Raw screenshots. +- Screen recordings. +- Secrets or credentials. +- Full terminal logs. +- Personal performance judgments. +- Private content unrelated to the coding task. + +## Evidence Policy + +| Evidence | Can predict? | Can adapt memory? | +| --- | --- | --- | +| Vision only | Yes, low confidence | No | +| Git watcher | Yes | No, unless repeated | +| GitHub state | Yes | No, unless verified | +| Accepted real outcome | Yes | Yes | +| Dismissed outcome | Yes, for suppression | Yes, as negative signal | +| Verifier result | Yes | Yes | + +## Intervention Policy + +Use the least intrusive channel: + +1. Watch quietly. +2. Card. +3. Hermes message. +4. Voice. + +Voice is only for urgent, high-confidence, time-sensitive risks. + +## Adaptation Policy + +Allowed adaptations: + +- Add learned ownership after accepted real outcome. +- Raise confidence for repeated accepted signatures. +- Lower confidence for dismissed signatures. +- Prefer the previously accepted intervention kind. +- Suppress repeated low-value warnings. + +Disallowed adaptations: + +- Broad threshold changes from one example. +- Treating vector similarity as proof. +- Hiding dismissals. +- Making interruption more aggressive without evidence. + +## Retention Policy + +Keep: + +- Outcomes. +- Signatures. +- Team model memory. +- Strategy metrics. + +Summarize or expire: + +- Old observations. +- Low-confidence vision-only events. +- Detailed trace text. + +Delete immediately: + +- Secrets. +- Accidental raw sensitive captures. + +## Demo Policy + +Seeded data is acceptable only if the demo script is honest about it. Live +learning requires a live or staged outcome write that visibly updates the graph +or future decision. + diff --git a/docs/continual-learning/prompt.md b/docs/continual-learning/prompt.md new file mode 100644 index 0000000..bd4d9de --- /dev/null +++ b/docs/continual-learning/prompt.md @@ -0,0 +1,87 @@ +# Continual Learning Prompt + +Use this prompt for the agent that decides what PodMan should remember from a +coordination event. + +## Prompt + +You are PodMan's continual-learning memory agent. + +Your job is to inspect observations, collisions, interventions, and outcomes, +then decide what team memory should be updated. You must separate observed +facts, inferred risks, human outcomes, and durable learned memory. + +Do not claim something was learned unless an accepted real outcome, verifier, or +human label supports it. + +## Inputs + +- Pod id. +- Recent engineer states. +- Recent observations. +- Candidate collision. +- Prior exact-signature memory. +- Intervention record. +- Outcome record. +- Current team model. + +## Procedure + +1. Normalize file and symbol. +2. Build exact signature. +3. Check prior accepted and dismissed outcomes. +4. Classify the current event. +5. Decide whether memory should change. +6. Emit the graph impact. +7. Write a short explanation. + +## Output Format + +```text +Event +- Signature: +- Engineers: +- File: +- Symbol: +- Evidence: + +Prior Memory +- Accepted matches: +- Dismissed matches: +- Ownership: + +Decision +- Memory action: +- Confidence: +- Reason: + +Graph Impact +- Nodes: +- Edges: +- Activity text: + +Safety +- Sensitive data present: +- Redaction needed: +``` + +## Memory Actions + +Allowed actions: + +- no_change +- strengthen_signature +- weaken_signature +- create_learned_owner +- update_route_preference +- suppress_signature +- request_human_label + +## Hard Rules + +- Exact recall before vector recall. +- Dismissals are learning signals. +- `learned_from` requires accepted real outcome. +- Store summaries, not raw screen content. +- Prefer less intrusive future behavior when uncertain. + diff --git a/docs/continual-learning/spec.md b/docs/continual-learning/spec.md new file mode 100644 index 0000000..e7d0672 --- /dev/null +++ b/docs/continual-learning/spec.md @@ -0,0 +1,217 @@ +# Continual Learning Spec + +Status: draft +Scope: how PodMan learns team memory from live work and outcomes +Owner: continual learning / Team memory + +## Purpose + +Continual learning is the product proof that PodMan gets more useful from use. +It learns team-level coordination memory: ownership, repeated collisions, +accepted interventions, dismissed noise, and preferred routing. + +The visible loop: + +```text +observe -> store -> predict -> outcome -> adapt +``` + +## Source Collections + +### `engineer_states` + +Latest per-engineer state from vision and local git. + +Key fields: + +- `podId` +- `name` +- `currentFile` +- `changedFiles` +- `branch` +- `confidence` +- `visionUpdatedAt` +- `gitUpdatedAt` +- `updatedAt` + +### `observations` + +Structured perception events. + +Key fields: + +- `podId` +- `engineerId` +- `currentFile` +- `symbol` +- `activity` +- `confidence` +- `observedAt` + +### `collisions` + +Predicted risk events. + +Key fields: + +- `id` +- `podId` +- `file` +- `symbol` +- `engineers` +- `severity` +- `status` +- `memorySignature` +- `detectedAt` + +### `interventions` + +Actions PodMan sent or suggested. + +Key fields: + +- `id` +- `podId` +- `collisionId` +- `kind` +- `channel` +- `message` +- `suggestedAction` +- `createdAt` + +### `outcomes` + +Human or verifier supervision. + +Key fields: + +- `id` +- `podId` +- `interventionId` +- `collisionId` +- `accepted` +- `wasRealCollision` +- `learnedOwner` +- `recordedAt` + +### `team_model` + +Durable pod memory. + +Key fields: + +- `podId` +- `graph` +- `ownership` +- `collisionSignatures` +- `interventionPolicy` +- `updatedAt` + +### `memory_vectors` + +Optional semantic recall. Exact recall comes first. + +Key fields: + +- `podId` +- `sourceKind` +- `sourceId` +- `text` +- `embedding` +- `embeddingModel` +- `tags` + +## Learning Rules + +### Observe + +Write structured evidence from vision, git, GitHub, and agent traces. + +### Store + +Persist source records and materialized summaries. Do not store raw screenshots +or recordings. + +### Predict + +Create a collision when multiple engineers converge on the same normalized file +or symbol and at least one signal shows active or unpushed work. + +### Outcome + +Record whether the intervention was accepted, dismissed, real, or false. + +### Adapt + +Only accepted real outcomes can create `learned_from` graph edges. Dismissals +adapt suppression, routing, or confidence. + +## Exact Signature + +Use deterministic signatures: + +```text +podId:eventType:normalizedFile:symbol:sortedEngineers +``` + +Rules: + +- Sort engineer names. +- Normalize file paths. +- Use `*` for missing symbol. +- Never include timestamps. + +## UI-Facing Loop Snapshot + +The graph response may include: + +```text +loop + activeStep + steps[] + key + label + value + detail + status +``` + +Step mapping: + +| Step | Source | +| --- | --- | +| Observe | recent observations and git updates | +| Store | team model, graph records, memory vectors | +| Predict | open collisions | +| Outcome | accepted and dismissed outcomes | +| Adapt | learned owners, learned edges, strategy changes | + +## Activity Stream + +The graph response may include: + +```text +activity[] + id + at + kind + title + detail + nodeId + edgeId +``` + +Allowed `kind` values: + +```text +editing, collision, intervention, outcome, learned, agent +``` + +## Acceptance Criteria + +- The system can show one accepted outcome changing future memory. +- Exact recall works without vector search. +- The Team memory graph can explain the learning loop. +- Dismissals and false positives are retained. +- The demo does not rely on raw screenshots or hidden state. + diff --git a/docs/graph-discovery/plan.md b/docs/graph-discovery/plan.md new file mode 100644 index 0000000..dc61836 --- /dev/null +++ b/docs/graph-discovery/plan.md @@ -0,0 +1,69 @@ +# Graph Discovery Plan + +Status: draft +Goal: make MongoDB graph discovery visible as a dynamic learning observatory + +## Must-Have + +1. Keep live materializer as source of graph truth. +2. Add optional loop and activity fields. +3. Build a dynamic graph layout. +4. Default to risk path. +5. Make selected-node detail explain the story. + +## Build Order + +### R1: Stabilize discovered graph + +- Keep file and engineer noise filters. +- Keep collision collapse. +- Keep priority for accepted-outcome paths. +- Keep graph size capped. + +### R2: Add observatory data + +- Compute learning-loop snapshot. +- Compute activity stream. +- Preserve current graph contract. + +### R3: Improve path selection + +- Pick one primary risk path. +- Include learned path when present. +- Dim unrelated collisions and repeated interventions. + +### R4: Render dynamically + +- Use `d3-force` or animated layered layout. +- Make nodes draggable. +- Curve or bundle edges. +- Animate `learned_from`. + +### R5: Verify with real data + +- Fetch live `demo-pod` graph. +- Confirm labels do not collide badly. +- Confirm red edges do not dominate. +- Confirm activity and loop explain the graph. + +## Nice-to-Have + +- Reachability panel using `$graphLookup`. +- Hover path previews. +- Edge bundling by file or collision. +- Time scrubber for graph snapshots. + +## Cut + +- Generic analytics dashboard. +- Large graph database migration. +- Rendering every historical event. +- Static fixed-column final layout. + +## Acceptance Criteria + +- Risk path is obvious in 10 seconds. +- Learned path is visible when data exists. +- Whole graph mode exists but is not the default. +- The graph remains backed by MongoDB, not hardcoded mock data. + diff --git a/docs/graph-discovery/policy.md b/docs/graph-discovery/policy.md new file mode 100644 index 0000000..ed16e4d --- /dev/null +++ b/docs/graph-discovery/policy.md @@ -0,0 +1,83 @@ +# Graph Discovery Policy + +Status: draft +Scope: graph hygiene, evidence thresholds, and UI truthfulness + +## Prime Rule + +The graph must be sparse enough to explain the learning loop and truthful enough +to audit from MongoDB. + +## Node Policy + +Create nodes only when they add explanation value. + +Allowed: + +- Current engineers. +- Real files. +- Current or recent collisions. +- Interventions tied to surviving collisions. +- Learned ownership paths. + +Avoid: + +- Test engineers. +- Scratch files. +- URLs or environment values misread as files. +- Repeated identical intervention diamonds. +- Orphan nodes with no story value. + +## Edge Policy + +Edges need evidence. + +| Edge | Required evidence | +| --- | --- | +| `editing` | observation or git state | +| `touches` | file involved in collision | +| `collides` | collision prediction | +| `warns` | intervention record | +| `learned_from` | accepted real outcome | +| `owns` | learned or configured ownership | + +## De-Hairball Policy + +Default mode must not show every relationship equally. + +Rules: + +- Default to risk path. +- Collapse repeated collision signatures. +- Cap files and collisions. +- Dim non-risk edges. +- Bundle or curve dense edges. +- Hide low-priority labels until hover or select. +- Prefer selected-node explanation over labels everywhere. + +## Truthfulness Policy + +- Do not show `learned_from` for orphaned or dismissed outcomes. +- Do not label vector similarity as learned memory. +- Do not show demo seed as live learning unless labeled. +- Do not hide false positives from activity or memory. + +## Privacy Policy + +Graph labels should not expose secrets, raw terminal output, or sensitive file +contents. File paths are acceptable when they are repo paths and not secret +values. + +## Visual Policy + +Semantic colors stay stable: + +- Engineer: blue. +- File: slate. +- Feature: amber. +- Collision: red. +- Intervention: violet. +- Learned: violet dashed edge. + +Chrome should use the app's light shadcn tokens. + diff --git a/docs/graph-discovery/prompt.md b/docs/graph-discovery/prompt.md new file mode 100644 index 0000000..4700a28 --- /dev/null +++ b/docs/graph-discovery/prompt.md @@ -0,0 +1,81 @@ +# Graph Discovery Prompt + +Use this prompt for an agent that materializes or reviews PodMan's Team memory +graph. + +## Prompt + +You are PodMan's graph discovery agent. + +Your job is to turn MongoDB records into a sparse, truthful graph that explains +the continual-learning loop. Do not maximize node count. Maximize legibility and +evidence. + +The default output should show the risk path and learned path, not every +possible edge. + +## Inputs + +- Pod id. +- Pod roster. +- Recent engineer states. +- Recent observations. +- Collisions. +- Interventions. +- Outcomes. +- Team model. +- Existing graph nodes and edges. + +## Procedure + +1. Normalize file paths. +2. Remove noise. +3. Create engineer and file nodes. +4. Collapse repeated collisions by signature. +5. Preserve accepted-outcome paths. +6. Create intervention nodes for surviving collisions. +7. Create learned edges only from accepted real outcomes. +8. Select the primary risk path. +9. Build activity and loop summaries. +10. Explain selected-node stories. + +## Output Format + +```text +Graph Summary +- Pod: +- Nodes: +- Edges: +- Primary risk path: +- Learned path: + +Discovery Decisions +- Collapsed: +- Dropped as noise: +- Preserved because learned: + +Loop +- Observe: +- Store: +- Predict: +- Outcome: +- Adapt: + +Activity +- Recent events: + +Risks +- Missing evidence: +- Potential hairball: +- Demo caveat: +``` + +## Hard Rules + +- No `learned_from` without accepted real outcome. +- No raw screenshots or secrets in labels. +- Do not rewrite the backend materializer unless explicitly asked. +- Prefer additive graph fields. +- Default to risk path. +- Keep whole graph optional. + diff --git a/docs/graph-discovery/spec.md b/docs/graph-discovery/spec.md new file mode 100644 index 0000000..ff81422 --- /dev/null +++ b/docs/graph-discovery/spec.md @@ -0,0 +1,146 @@ +# Graph Discovery Spec + +Status: draft +Scope: how PodMan discovers graph nodes, edges, risk paths, and learning paths from MongoDB +Owner: graph discovery / Team memory observatory + +## Purpose + +Graph discovery turns MongoDB memory into a legible Team memory graph. It is not +only layout. It decides which relationships matter, which path is highlighted, +and which evidence explains the graph. + +The graph must answer: + +1. Who is working? +2. Which files or symbols overlap? +3. Where is the risk? +4. What did PodMan do? +5. What outcome changed memory? + +## Source Data + +Graph discovery reads: + +- `pods` +- `engineer_states` +- `observations` +- `collisions` +- `interventions` +- `outcomes` +- `team_model` +- `graph_nodes` +- `graph_edges` +- optional `memory_vectors` +- optional `agent_runs` +- optional `strategy_versions` + +## UI Graph Contract + +```text +PodGraph + podId + generatedAt + nodes + edges + metrics + loop? + activity? +``` + +Node kinds: + +```text +engineer, feature, file, collision, intervention +``` + +Edge kinds: + +```text +owns, editing, touches, collides, warns, learned_from +``` + +## Discovery Rules + +### Engineer nodes + +Create from pod roster, recent observations, git state, or collision membership. + +### File nodes + +Create only from normalized real file paths. Reject noise such as URLs, env +values, scratch names, and non-file strings. + +### Collision nodes + +Create from distinct collision signatures. Collapse repeats. Prioritize +collisions referenced by accepted outcomes. + +### Intervention nodes + +Create one visible intervention per surviving collision unless whole-graph mode +explicitly expands history. + +### Learned paths + +Create `learned_from` only when an accepted real outcome links an intervention +to a durable memory update. + +## Path Modes + +### Risk path + +Default mode. Highlight the clearest current chain: + +```text +engineer -> file -> collision -> intervention -> learned owner +``` + +Dim unrelated graph material. + +### Learning edges + +Highlight `learned_from`, `owns`, and the outcomes that produced them. + +### Whole graph + +Show all materialized nodes and edges with de-emphasized non-critical edges. + +## MongoDB Traversal + +Use `graph_edges` for reachability: + +```text +source -> target -> next target +``` + +Primary traversal questions: + +- What risks does this engineer reach? +- Which files feed this collision? +- Which intervention came from this collision? +- Which learned owner came from this intervention? + +## Metrics + +Minimum metrics: + +- Learned owners. +- Open risk paths. +- Accept rate. + +Optional metrics: + +- Observations. +- Interventions. +- Memory vectors. +- Strategy versions. + +## Acceptance Criteria + +- Default graph is not a hairball. +- Every visible learned edge has outcome evidence. +- Every selected node can explain why it matters. +- Activity stream matches graph events. +- Graph can be rebuilt from MongoDB source records. + diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx index 7c13635..b58afbc 100644 --- a/frontend/src/components/GraphView.tsx +++ b/frontend/src/components/GraphView.tsx @@ -1,111 +1,36 @@ -import { useEffect, useMemo, useState, type CSSProperties } from 'react'; -import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared'; -import { fetchPodGraph } from '../lib/graph.js'; +import { useEffect, useMemo, useState } from 'react'; +import type { PodGraph } from '@podman/shared'; +import { fetchPodGraph, backendEventsUrl } from '../lib/graph.js'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { GraphCanvas } from './graph/GraphCanvas.js'; +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, flowNarrative, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js'; -type Mode = 'risk' | 'learn' | 'all'; +const POLL_MS = 5000; -// Fixed, light-readable hues for the node/edge encoding (kept stable across -// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens). -const BLUE = '#2563eb'; -const SLATE = '#475569'; -const SLATE_EDGE = '#94a3b8'; -const SLATE_FAINT = '#cbd5e1'; -const AMBER = '#d97706'; -const RED = '#dc2626'; -const VIOLET = '#7c3aed'; - -const KIND_COLOR: Record = { - engineer: BLUE, - file: SLATE, - feature: AMBER, - collision: RED, - intervention: VIOLET, -}; - -const EDGE: Record = { - owns: { c: BLUE, w: 2.6 }, - editing: { c: SLATE_EDGE, w: 2 }, - touches: { c: SLATE_FAINT, w: 1.6 }, - collides: { c: RED, w: 3.2 }, - warns: { c: AMBER, w: 3.2 }, - learned_from: { c: VIOLET, w: 2.4, dash: true }, -}; - -function NodeShape({ node }: { node: PodGraphNode }) { - const c = KIND_COLOR[node.kind]; - const { x, y } = node; - switch (node.kind) { - case 'engineer': - return ; - case 'file': - return ( - - ); - case 'feature': - return ; - case 'collision': - return ; - case 'intervention': - return ( - - ); - default: - return null; +// Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final +// keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming. +const GRAPH_CSS = ` + .pm-node{cursor:grab;transition:opacity .25s ease} + .pm-node:active{cursor:grabbing} + .pm-edge{transition:opacity .25s ease} + .pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none; + paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round} + .pm-dim{opacity:.14} + .pm-enter{animation:pm-fade .45s ease} + .pm-dash{animation:pm-flow 1s linear infinite} + .pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite} + @keyframes pm-fade{from{opacity:0}to{opacity:1}} + @keyframes pm-flow{to{stroke-dashoffset:-26}} + @keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}} + @media (prefers-reduced-motion:reduce){ + .pm-enter,.pm-dash,.pm-pulse{animation:none} } -} - -interface Highlight { - nodes: Set; - edges: Set; -} - -function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null { - if (selected) { - const es = graph.edges.filter((e) => e.source === selected || e.target === selected); - return { - nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]), - edges: new Set(es.map((e) => e.id)), - }; - } - if (mode === 'all') return null; - const kinds: PodGraphEdge['kind'][] = - mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns']; - const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id)); - const es = graph.edges.filter( - (e) => - kinds.includes(e.kind) || - (mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))), - ); - return { - nodes: new Set(es.flatMap((e) => [e.source, e.target])), - edges: new Set(es.map((e) => e.id)), - }; -} - -const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [ - { label: 'engineer', swatch: { background: BLUE } }, - { label: 'file', swatch: { border: `2px solid ${SLATE}` } }, - { label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } }, - { label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } }, - { label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } }, -]; - -function statusColor(status: string): string { - if (status === 'risk') return RED; - if (status === 'learned') return VIOLET; - return 'var(--foreground)'; -} +`; export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) { const [graph, setGraph] = useState(null); @@ -115,198 +40,174 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo useEffect(() => { let alive = true; + let nudge: number | null = null; setGraph(null); setError(null); - fetchPodGraph(podId) - .then((g) => alive && setGraph(g)) - .catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e))); + setSelected(null); + + const load = () => + fetchPodGraph(podId) + .then((g) => { + if (alive) { + setGraph(g); + setError(null); + } + }) + .catch((e: unknown) => { + if (alive) setError(e instanceof Error ? e.message : String(e)); + }); + + void load(); + const poll = window.setInterval(() => void load(), POLL_MS); + + // Best-effort realtime nudge: refetch (debounced) when the agent broadcasts. + let ws: WebSocket | null = null; + try { + ws = new WebSocket(backendEventsUrl()); + ws.onmessage = () => { + if (nudge != null) return; + nudge = window.setTimeout(() => { + nudge = null; + void load(); + }, 800); + }; + } catch { + /* event bus is optional */ + } + return () => { alive = false; + window.clearInterval(poll); + if (nudge != null) window.clearTimeout(nudge); + ws?.close(); }; }, [podId]); - const hi = useMemo( - () => (graph ? highlightFor(graph, mode, selected) : null), - [graph, mode, selected], - ); const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]); - const sel = selected ? nodeById.get(selected) : undefined; - const relCount = selected - ? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length - : 0; + // A selected node can vanish across a poll/WS refresh. Ignore a stale id so the + // graph doesn't dim entirely (highlightFor would otherwise light only a dead id). + const liveSelected = selected && nodeById.has(selected) ? selected : null; + useEffect(() => { + if (selected && graph && !nodeById.has(selected)) setSelected(null); + }, [graph, nodeById, selected]); - const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false); - const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false); - const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false); + const highlight = useMemo( + () => (graph ? highlightFor(graph, mode, liveSelected) : null), + [graph, mode, liveSelected], + ); + const sel = liveSelected ? nodeById.get(liveSelected) : undefined; + const relCount = liveSelected + ? (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); setSelected(null); } - const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline'); - return (
+
- - -
-
-
-

Team memory

-

What PodMan learned · {podId}

+
+ {/* Header */} +
+
+
+

Team memory

+ + + Live + +
+

+ What PodMan learned · {podId} +

-
- - - + {/* Toggles */} +
+ v && pick(v as Mode)} + variant="outline" + size="sm" + > + Risk path + Learning edges + Whole graph + + + Drag to rearrange · double-click to release · click to inspect +
{error &&

Graph error: {error}

} {!graph && !error && ( -

Loading graph…

+

Loading graph…

)} {graph && ( <> -
-
-

- Workflow metrics -

- {graph.metrics.map((m) => ( -
-

{m.value}

-

- {m.label} -

-

{m.detail}

-
- ))} + {/* Metrics · graph · learning loop */} +
+ + +
+
-
- - {graph.edges.map((e) => { - const a = nodeById.get(e.source); - const b = nodeById.get(e.target); - if (!a || !b) return null; - const s = EDGE[e.kind]; - return ( - - ); - })} - {graph.nodes.map((n) => ( - setSelected((cur) => (cur === n.id ? null : n.id))} - onKeyDown={(ev) => { - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - setSelected((cur) => (cur === n.id ? null : n.id)); - } - }} - > - - - {n.label} - - - ))} - -
+ {graph.loop?.length ? ( + + ) : ( +
+ )} +
-
- {sel ? ( - <> -

- {sel.kind} -

-

{sel.label}

-
- Status - - {sel.status} - -
-
- Relationships - {relCount} -
-

- {sel.summary} -

- - ) : ( - <> -

- Continual learning -

-

It learned

-

- The violet{' '} - - learned_from - {' '} - edges are ownership PodMan retained from accepted interventions — the graph - gets sharper every session. Click any node to trace its relationships. -

- - )} + {/* Activity stream · selected node */} +
+
+ +
+
+
-
- {LEGEND.map((l) => ( + {/* Legend */} +
+ {NODE_LEGEND.map((l) => ( {l.label} ))} - - - collides - - - - learned_from - + + {EDGE_LEGEND.map((l) => ( + + + {l.label} + + ))}
)} diff --git a/frontend/src/components/graph/ActivityStream.tsx b/frontend/src/components/graph/ActivityStream.tsx new file mode 100644 index 0000000..931ce04 --- /dev/null +++ b/frontend/src/components/graph/ActivityStream.tsx @@ -0,0 +1,45 @@ +import type { ActivityEvent } from '@podman/shared'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { ACTIVITY_TAG } from './encoding.js'; + +const fmtTime = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit', hour12: false }); + +function timeOf(at: string): string { + const t = new Date(at).getTime(); + return Number.isFinite(t) ? fmtTime.format(t) : '--:--'; +} + +export function ActivityStream({ events }: { events: ActivityEvent[] }) { + return ( +
+

+ Activity stream +

+ {events.length === 0 ? ( +

No activity yet.

+ ) : ( + +
    + {events.map((e) => { + const tag = ACTIVITY_TAG[e.kind]; + return ( +
  • + + {timeOf(e.at)} + + + {tag.label} + + {e.text} +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/graph/GraphCanvas.tsx b/frontend/src/components/graph/GraphCanvas.tsx new file mode 100644 index 0000000..b87ed79 --- /dev/null +++ b/frontend/src/components/graph/GraphCanvas.tsx @@ -0,0 +1,333 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ReactElement, + type PointerEvent, +} from 'react'; +import type { PodGraph, PodGraphNode, PodGraphNodeKind } from '@podman/shared'; +import { ForceSim } from './forceSim.js'; +import { EDGE, KIND_COLOR, nodeRadius, type Highlight } from './encoding.js'; + +const W = 760; +const H = 480; +const MARGIN = 48; + +/** Map the server's 0..720×0..472 layout into the canvas as a seed position. */ +function mapX(x: number): number { + return MARGIN + (Math.max(0, Math.min(720, x)) / 720) * (W - 2 * MARGIN); +} +function mapY(y: number): number { + return MARGIN + (Math.max(0, Math.min(472, y)) / 472) * (H - 2 * MARGIN); +} + +function linkDistance(kind: string): number { + if (kind === 'collides') return 122; + if (kind === 'owns') return 104; + if (kind === 'learned_from') return 150; + return 134; +} +function linkStrength(strength: number): number { + return Math.max(0.18, Math.min(0.9, strength)); +} + +/** Stable +/- so parallel edges between the same pair fan to opposite sides. */ +function curveSign(id: string): number { + let h = 0; + for (let i = 0; i < id.length; i++) h = (h + id.charCodeAt(i)) % 2; + return h === 0 ? 1 : -1; +} + +function edgePath(ax: number, ay: number, bx: number, by: number, id: string): string { + const dx = bx - ax; + const dy = by - ay; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + const off = curveSign(id) * len * 0.13; + const cx = (ax + bx) / 2 + nx * off; + const cy = (ay + by) / 2 + ny * off; + return `M${ax.toFixed(1)},${ay.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${bx.toFixed(1)},${by.toFixed(1)}`; +} + +function nodeShape( + kind: PodGraphNodeKind, + color: string, + cx: number, + cy: number, + r: number, +): ReactElement | null { + switch (kind) { + case 'engineer': + return ; + case 'file': + return ( + + ); + case 'feature': + return ; + case 'collision': + return ( + + ); + case 'intervention': + return ( + + ); + default: + return null; + } +} + +function showLabel( + node: PodGraphNode, + dimmed: boolean, + hovered: boolean, + selected: boolean, +): boolean { + if (hovered || selected) return true; + if (dimmed) return false; + // Collisions cluster and often share a filename — reveal on hover/select only. + if (node.kind === 'collision') return false; + return true; +} + +interface DragState { + id: string; + pointerId: number; + moved: boolean; +} + +export function GraphCanvas({ + graph, + highlight, + selected, + onSelect, +}: { + graph: PodGraph; + highlight: Highlight | null; + selected: string | null; + onSelect: (id: string | null) => void; +}) { + const svgRef = useRef(null); + const simRef = useRef(null); + if (!simRef.current) simRef.current = new ForceSim(W, H); + const rafRef = useRef(null); + const dragRef = useRef(null); + const sigRef = useRef(''); + const [, setFrame] = useState(0); + const [hovered, setHovered] = useState(null); + + const loop = useCallback(() => { + const sim = simRef.current; + if (!sim) return; + const working = sim.tick(); + setFrame((f) => (f + 1) % 1_000_000); + if (working || dragRef.current) { + rafRef.current = requestAnimationFrame(loop); + } else { + rafRef.current = null; + } + }, []); + + const ensureRaf = useCallback(() => { + if (rafRef.current == null) rafRef.current = requestAnimationFrame(loop); + }, [loop]); + + // Rebuild the simulation when the graph data changes, preserving positions. + useEffect(() => { + const sim = simRef.current; + if (!sim) return; + const nodeInputs = graph.nodes.map((n) => ({ + id: n.id, + radius: nodeRadius(n), + seedX: mapX(n.x), + seedY: mapY(n.y), + })); + const linkInputs = graph.edges.map((e) => ({ + source: e.source, + target: e.target, + distance: linkDistance(e.kind), + strength: linkStrength(e.strength), + })); + const sig = + nodeInputs + .map((n) => n.id) + .sort() + .join(',') + + '|' + + graph.edges + .map((e) => e.id) + .sort() + .join(','); + const first = sigRef.current === ''; + const changed = sig !== sigRef.current; + sim.setData(nodeInputs, linkInputs); + if (changed) { + sigRef.current = sig; + sim.reheat(first ? 1 : 0.5); + } + // Always (re)arm the loop — ensureRaf is idempotent via the rafRef==null + // guard. This must NOT be gated on `changed`: under React StrictMode the + // dev double-invoke cancels the frame between effect passes, and pass 2 sees + // an unchanged sig, so a `changed`-gated start would leave the sim frozen. + if (sim.nodes.length) ensureRaf(); + }, [graph, ensureRaf]); + + // Clean up the animation frame on unmount. + useEffect(() => { + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + }; + }, []); + + function toSvg(evt: PointerEvent): { x: number; y: number } { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const ctm = svg.getScreenCTM(); + if (!ctm) return { x: 0, y: 0 }; + const p = new DOMPoint(evt.clientX, evt.clientY).matrixTransform(ctm.inverse()); + return { x: p.x, y: p.y }; + } + + function onNodePointerDown(evt: PointerEvent, id: string) { + evt.stopPropagation(); + const sim = simRef.current; + if (!sim) return; + (evt.currentTarget as Element).setPointerCapture(evt.pointerId); + dragRef.current = { id, pointerId: evt.pointerId, moved: false }; + const { x, y } = toSvg(evt); + sim.pin(id, x, y); + sim.setActive(true); + ensureRaf(); + } + + function onNodePointerMove(evt: PointerEvent) { + const drag = dragRef.current; + const sim = simRef.current; + if (!drag || !sim || drag.pointerId !== evt.pointerId) return; + drag.moved = true; + const { x, y } = toSvg(evt); + sim.pin(drag.id, x, y); + ensureRaf(); + } + + function onNodePointerUp(evt: PointerEvent, id: string) { + const drag = dragRef.current; + const sim = simRef.current; + if (!drag || !sim || drag.pointerId !== evt.pointerId) return; + (evt.currentTarget as Element).releasePointerCapture?.(evt.pointerId); + sim.setActive(false); + // A press that never moved is a click — toggle selection (node stays pinned). + if (!drag.moved) onSelect(selected === id ? null : id); + dragRef.current = null; + ensureRaf(); + } + + function onNodeDoubleClick(id: string) { + const sim = simRef.current; + if (!sim) return; + sim.unpin(id); + sim.reheat(0.5); + ensureRaf(); + } + + const sim = simRef.current; + const dimNode = (id: string) => (highlight ? !highlight.nodes.has(id) : false); + const dimEdge = (id: string) => (highlight ? !highlight.edges.has(id) : false); + const hotEdge = (id: string) => (highlight ? highlight.edges.has(id) : false); + + return ( + onSelect(null)} + > + + {graph.edges.map((e) => { + const a = sim?.get(e.source); + const b = sim?.get(e.target); + if (!a || !b) return null; + const style = EDGE[e.kind]; + const hot = hotEdge(e.id); + return ( + + ); + })} + + + {graph.nodes.map((n) => { + const p = sim?.get(n.id); + if (!p) return null; + const r = p.radius; + const dimmed = dimNode(n.id); + const isHover = hovered === n.id; + const isSel = selected === n.id; + const color = KIND_COLOR[n.kind]; + const pinned = p.fx != null; + return ( + onNodePointerDown(ev, n.id)} + onPointerMove={onNodePointerMove} + onPointerUp={(ev) => onNodePointerUp(ev, n.id)} + onDoubleClick={() => onNodeDoubleClick(n.id)} + onMouseEnter={() => setHovered(n.id)} + onMouseLeave={() => setHovered((cur) => (cur === n.id ? null : cur))} + onKeyDown={(ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + onSelect(selected === n.id ? null : n.id); + } + }} + > + {(isSel || isHover) && ( + + )} + {pinned && !isSel && !isHover && ( + + )} + {nodeShape(n.kind, color, p.x, p.y, r)} + {showLabel(n, dimmed, isHover, isSel) && ( + + {n.label} + + )} + + ); + })} + + + ); +} diff --git a/frontend/src/components/graph/LearningLoop.tsx b/frontend/src/components/graph/LearningLoop.tsx new file mode 100644 index 0000000..88eb77e --- /dev/null +++ b/frontend/src/components/graph/LearningLoop.tsx @@ -0,0 +1,43 @@ +import type { LearningStage } from '@podman/shared'; +import { BLUE } from './encoding.js'; + +/** + * The continual-learning loop rail: observe → store → predict → outcome → adapt. + * The active stage (most-recent activity) gets a pulsing accent bar + ring. + */ +export function LearningLoop({ stages }: { stages: LearningStage[] }) { + return ( +
+

+ Learning loop +

+ {stages.map((s, i) => ( +
+
+ +
+

+ {String(i + 1).padStart(2, '0')} {s.title} +

+

{s.value}

+
+

{s.detail}

+
+ {i < stages.length - 1 && ( +

+ ↓ +

+ )} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/graph/MetricsRail.tsx b/frontend/src/components/graph/MetricsRail.tsx new file mode 100644 index 0000000..23f9d7e --- /dev/null +++ b/frontend/src/components/graph/MetricsRail.tsx @@ -0,0 +1,37 @@ +import type { PodGraphMetric } from '@podman/shared'; +import { BLUE, RED, VIOLET, GREEN, AMBER } from './encoding.js'; + +const ACCENTS: Array<{ test: RegExp; color: string }> = [ + { test: /risk|collision|open/i, color: RED }, + { test: /accept/i, color: GREEN }, + { test: /learn|owner|adapt/i, color: VIOLET }, + { test: /vector|memory|store/i, color: AMBER }, +]; + +function accentFor(label: string, i: number): string { + for (const a of ACCENTS) if (a.test.test(label)) return a.color; + return [BLUE, RED, VIOLET, GREEN, AMBER][i % 5] ?? BLUE; +} + +export function MetricsRail({ metrics }: { metrics: PodGraphMetric[] }) { + return ( +
+

+ Workflow metrics +

+ {metrics.map((m, i) => ( +
+

{m.value}

+

+ {m.label} +

+

{m.detail}

+
+ ))} +
+ ); +} diff --git a/frontend/src/components/graph/SelectedNodePanel.tsx b/frontend/src/components/graph/SelectedNodePanel.tsx new file mode 100644 index 0000000..d73d918 --- /dev/null +++ b/frontend/src/components/graph/SelectedNodePanel.tsx @@ -0,0 +1,63 @@ +import type { PodGraphNode } from '@podman/shared'; +import { Badge } from '@/components/ui/badge'; +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 ( +
+

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

+

What you're looking at

+

{modeBlurb(mode)}

+

+ Click any node to trace its{' '} + + flow + {' '} + — what PodMan saw, flagged, and learned. Drag to rearrange. +

+
+ ); + } + return ( +
+

+ {node.kind} +

+

{node.label}

+
+ Status + + {node.status} + +
+
+ Relationships + {relCount} +
+ {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 new file mode 100644 index 0000000..9cbac39 --- /dev/null +++ b/frontend/src/components/graph/encoding.ts @@ -0,0 +1,209 @@ +import type { CSSProperties } from 'react'; +import type { + PodGraph, + PodGraphNode, + PodGraphEdge, + PodGraphNodeKind, + ActivityKind, +} from '@podman/shared'; + +/** + * Fixed, light-readable hues for the node/edge encoding. Kept stable across + * light/dark so kinds stay distinguishable; only the chrome uses shadcn tokens. + */ +export const BLUE = '#2563eb'; +export const SLATE = '#475569'; +export const SLATE_EDGE = '#94a3b8'; +export const SLATE_FAINT = '#cbd5e1'; +export const AMBER = '#d97706'; +export const RED = '#dc2626'; +export const VIOLET = '#7c3aed'; +export const GREEN = '#16a34a'; + +/** Tag color + short label per activity-stream kind. */ +export const ACTIVITY_TAG: Record = { + editing: { color: SLATE, label: 'EDITING' }, + collision: { color: RED, label: 'COLLISION' }, + warns: { color: AMBER, label: 'WARNS' }, + outcome: { color: GREEN, label: 'OUTCOME' }, + learned_from: { color: VIOLET, label: 'LEARNED' }, +}; + +export const KIND_COLOR: Record = { + engineer: BLUE, + file: SLATE, + feature: AMBER, + collision: RED, + intervention: VIOLET, +}; + +export interface EdgeStyle { + c: string; + w: number; + dash?: boolean; +} + +export const EDGE: Record = { + owns: { c: BLUE, w: 2.4 }, + editing: { c: SLATE_EDGE, w: 1.9 }, + touches: { c: SLATE_FAINT, w: 1.5 }, + collides: { c: RED, w: 2.8 }, + warns: { c: AMBER, w: 2.8 }, + learned_from: { c: VIOLET, w: 2.4, dash: true }, +}; + +/** Collision/drawing radius for a node — scaled by its 0..1 weight. */ +export function nodeRadius(node: PodGraphNode): number { + const base = node.kind === 'collision' || node.kind === 'intervention' ? 14 : 13; + return base + Math.max(0, Math.min(1, node.weight)) * 7; +} + +export function statusColor(status: string): string { + if (status === 'risk') return RED; + if (status === 'learned') return VIOLET; + if (status === 'active') return BLUE; + return 'var(--muted-foreground)'; +} + +export type Mode = 'risk' | 'learn' | 'all'; + +export interface Highlight { + nodes: Set; + edges: Set; +} + +/** + * The lit set for the current mode/selection. A selected node lights its + * incident edges + neighbors; otherwise the mode lights the risk or learning + * chain (collision → intervention → learned_from). `all` lights everything. + */ +export function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null { + if (selected) { + const es = graph.edges.filter((e) => e.source === selected || e.target === selected); + return { + nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]), + edges: new Set(es.map((e) => e.id)), + }; + } + if (mode === 'all') return null; + const kinds: PodGraphEdge['kind'][] = + mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns']; + const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id)); + const es = graph.edges.filter( + (e) => + kinds.includes(e.kind) || + (mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))), + ); + return { + nodes: new Set(es.flatMap((e) => [e.source, e.target])), + edges: new Set(es.map((e) => e.id)), + }; +} + +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}` } }, + { label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } }, + { label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } }, + { label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } }, +]; + +export const EDGE_LEGEND: Array<{ label: string; color: string; dash?: boolean }> = [ + { label: 'collides', color: RED }, + { 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 }, +]; diff --git a/frontend/src/components/graph/forceSim.ts b/frontend/src/components/graph/forceSim.ts new file mode 100644 index 0000000..b2432eb --- /dev/null +++ b/frontend/src/components/graph/forceSim.ts @@ -0,0 +1,284 @@ +/** + * A tiny dependency-free force-directed layout — the same family of forces as + * d3-force (charge repulsion, link springs, centering, collision) integrated + * with velocity-Verlet and an annealing `alpha`. Kept in-house so the dynamic + * graph adds no new package / lockfile churn to a fast-moving shared `main`. + * + * Usage: `setData()` (diff-preserving — existing nodes keep their position), + * then drive `tick()` from a requestAnimationFrame loop until `settled()`. + */ + +export interface SimNodeInput { + id: string; + /** Drawing/collision radius. */ + radius: number; + /** Initial position hint (e.g. the server layout), used only for new nodes. */ + seedX: number; + seedY: number; +} + +export interface SimLinkInput { + source: string; + target: string; + /** Preferred rest length of the spring. */ + distance: number; + /** 0..1 spring strength. */ + strength: number; +} + +export interface SimNode { + id: string; + x: number; + y: number; + vx: number; + vy: number; + /** When non-null the node is pinned (dragged) and forces don't move it. */ + fx: number | null; + fy: number | null; + radius: number; +} + +const ALPHA_MIN = 0.001; +const ALPHA_DECAY = 1 - Math.pow(ALPHA_MIN, 1 / 300); // settle in ~300 ticks +const FRICTION = 0.62; // velocity retained per tick +const REPEL = 4400; // charge repulsion strength — must dominate centering or the graph collapses +const LINK_K = 0.45; // spring stiffness multiplier +const CENTER_STRENGTH = 0.014; // gentle positional pull — only keeps the cloud roughly centered +const RECENTER = 0.5; // per-tick centroid recentering (no compression, keeps graph framed) +const COLLIDE_PAD = 12; +const COLLIDE_STRENGTH = 1; // hard separation so linked nodes never stack +const COLLIDE_ITERS = 2; +const BOUND_PAD = 30; // keep nodes this far inside the canvas edges + +export class ForceSim { + nodes: SimNode[] = []; + links: SimLinkInput[] = []; + alpha = 1; + private byId = new Map(); + private alphaTarget = 0; + private center: { x: number; y: number }; + private width: number; + private height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + this.center = { x: width / 2, y: height / 2 }; + } + + settled(): boolean { + return this.alpha < ALPHA_MIN && this.alphaTarget === 0; + } + + reheat(a = 0.7): void { + this.alpha = Math.max(this.alpha, a); + } + + /** Hold the simulation warm while dragging, then release. */ + setActive(active: boolean): void { + this.alphaTarget = active ? 0.18 : 0; + if (active) this.reheat(0.25); + } + + get(id: string): SimNode | undefined { + return this.byId.get(id); + } + + pin(id: string, x: number, y: number): void { + const n = this.byId.get(id); + if (n) { + n.fx = x; + n.fy = y; + } + } + + unpin(id: string): void { + const n = this.byId.get(id); + if (n) { + n.fx = null; + n.fy = null; + } + } + + /** Replace the graph, preserving the positions/pins of nodes that persist. */ + setData(nodeInputs: SimNodeInput[], linkInputs: SimLinkInput[]): { added: string[] } { + const prev = this.byId; + const next = new Map(); + const added: string[] = []; + for (const inp of nodeInputs) { + const old = prev.get(inp.id); + if (old) { + old.radius = inp.radius; + next.set(inp.id, old); + } else { + next.set(inp.id, { + id: inp.id, + x: inp.seedX + (Math.random() - 0.5) * 14, + y: inp.seedY + (Math.random() - 0.5) * 14, + vx: 0, + vy: 0, + fx: null, + fy: null, + radius: inp.radius, + }); + added.push(inp.id); + } + } + this.byId = next; + this.nodes = [...next.values()]; + this.links = linkInputs.filter((l) => next.has(l.source) && next.has(l.target)); + return { added }; + } + + /** Advance one step. Returns false when already settled (no work done). */ + tick(): boolean { + if (this.settled()) return false; + this.alpha += (this.alphaTarget - this.alpha) * ALPHA_DECAY; + const a = this.alpha; + this.applyCharge(a); + this.applyLinks(a); + this.applyCenter(a); + for (let k = 0; k < COLLIDE_ITERS; k++) this.applyCollide(); + const maxX = this.width - BOUND_PAD; + const maxY = this.height - BOUND_PAD; + for (const n of this.nodes) { + if (n.fx != null) { + n.x = n.fx; + n.vx = 0; + } else { + n.vx *= FRICTION; + n.x += n.vx; + if (n.x < BOUND_PAD) { + n.x = BOUND_PAD; + n.vx = 0; + } else if (n.x > maxX) { + n.x = maxX; + n.vx = 0; + } + } + if (n.fy != null) { + n.y = n.fy; + n.vy = 0; + } else { + n.vy *= FRICTION; + n.y += n.vy; + if (n.y < BOUND_PAD) { + n.y = BOUND_PAD; + n.vy = 0; + } else if (n.y > maxY) { + n.y = maxY; + n.vy = 0; + } + } + } + return true; + } + + private applyCharge(alpha: number): void { + const ns = this.nodes; + for (let i = 0; i < ns.length; i++) { + const a = ns[i]; + if (!a) continue; + for (let j = i + 1; j < ns.length; j++) { + const b = ns[j]; + if (!b) continue; + let dx = b.x - a.x; + let dy = b.y - a.y; + let d2 = dx * dx + dy * dy; + if (d2 === 0) { + dx = (j - i) * 0.5; + dy = (i + 1) * 0.4; + d2 = dx * dx + dy * dy; + } + const dist = Math.sqrt(d2); + const force = (REPEL * alpha) / d2; + const ux = dx / dist; + const uy = dy / dist; + a.vx -= ux * force; + a.vy -= uy * force; + b.vx += ux * force; + b.vy += uy * force; + } + } + } + + private applyLinks(alpha: number): void { + for (const link of this.links) { + const s = this.byId.get(link.source); + const t = this.byId.get(link.target); + if (!s || !t) continue; + let dx = t.x - s.x; + let dy = t.y - s.y; + let d2 = dx * dx + dy * dy; + if (d2 === 0) { + dx = 0.5; + dy = 0.5; + d2 = 0.5; + } + const dist = Math.sqrt(d2); + const k = ((dist - link.distance) / dist) * alpha * link.strength * LINK_K; + const mx = dx * k * 0.5; + const my = dy * k * 0.5; + s.vx += mx; + s.vy += my; + t.vx -= mx; + t.vy -= my; + } + } + + private applyCenter(alpha: number): void { + const n = this.nodes.length; + if (!n) return; + // Recenter the whole cloud so its centroid sits at canvas center (this does + // NOT compress the layout — repulsion/links set the spread), plus a gentle + // positional pull so stray/isolated nodes don't park against the edge. + let cx = 0; + let cy = 0; + for (const nd of this.nodes) { + cx += nd.x; + cy += nd.y; + } + cx = (this.center.x - cx / n) * RECENTER; + cy = (this.center.y - cy / n) * RECENTER; + for (const nd of this.nodes) { + if (nd.fx == null) { + nd.x += cx; + nd.vx += (this.center.x - nd.x) * CENTER_STRENGTH * alpha; + } + if (nd.fy == null) { + nd.y += cy; + nd.vy += (this.center.y - nd.y) * CENTER_STRENGTH * alpha; + } + } + } + + private applyCollide(): void { + const ns = this.nodes; + for (let i = 0; i < ns.length; i++) { + const a = ns[i]; + if (!a) continue; + for (let j = i + 1; j < ns.length; j++) { + const b = ns[j]; + if (!b) continue; + let dx = b.x - a.x; + let dy = b.y - a.y; + const d2 = dx * dx + dy * dy; + const min = a.radius + b.radius + COLLIDE_PAD; + if (d2 >= min * min) continue; + let dist = Math.sqrt(d2); + if (dist === 0) { + dx = j - i; + dy = i + 1; + dist = Math.sqrt(dx * dx + dy * dy) || 1; + } + const push = ((min - dist) / dist) * 0.5 * COLLIDE_STRENGTH; + const ox = dx * push; + const oy = dy * push; + if (a.fx == null) a.x -= ox; + if (a.fy == null) a.y -= oy; + if (b.fx == null) b.x += ox; + if (b.fy == null) b.y += oy; + } + } + } +} diff --git a/frontend/src/lib/graph.ts b/frontend/src/lib/graph.ts index b3a57c7..9d746de 100644 --- a/frontend/src/lib/graph.ts +++ b/frontend/src/lib/graph.ts @@ -8,3 +8,8 @@ export async function fetchPodGraph(podId: string): Promise { if (!res.ok) throw new Error(`graph request failed: ${res.status}`); return res.json() as Promise; } + +/** WebSocket URL for the live event bus — used to nudge the graph to refetch. */ +export function backendEventsUrl(): string { + return `${BACKEND_URL.replace(/^http/, 'ws')}/api/events`; +} diff --git a/shared/src/graph.ts b/shared/src/graph.ts index c8175fb..df08e5f 100644 --- a/shared/src/graph.ts +++ b/shared/src/graph.ts @@ -48,6 +48,36 @@ export interface PodGraphMetric { detail: string; } +/** The five stages of PodMan's continual-learning loop, in order. */ +export type LearningStageKey = 'observe' | 'store' | 'predict' | 'outcome' | 'adapt'; + +/** One stage of the learning-loop rail (observe→store→predict→outcome→adapt). */ +export interface LearningStage { + key: LearningStageKey; + /** UPPERCASE display title, e.g. "OBSERVE". */ + title: string; + /** Headline figure for the stage, e.g. "5/s" or "124". */ + value: string; + /** One-line detail under the title. */ + detail: string; + /** True for the single most-recently-active stage (pulses in the UI). */ + active: boolean; +} + +/** Kind of an activity-stream entry (drives the colored tag). */ +export type ActivityKind = 'editing' | 'collision' | 'warns' | 'outcome' | 'learned_from'; + +/** One time-tagged entry in the activity stream. */ +export interface ActivityEvent { + /** Stable id (source doc id + kind) so the UI can animate diffs. */ + id: string; + /** ISO timestamp the event happened. */ + at: string; + kind: ActivityKind; + /** Human-readable line, e.g. "Yahya opened auth.ts — unpushed changes". */ + text: string; +} + /** A point-in-time render of a pod's team_model. */ export interface PodGraph { podId: string; @@ -56,6 +86,10 @@ export interface PodGraph { nodes: PodGraphNode[]; edges: PodGraphEdge[]; metrics: PodGraphMetric[]; + /** Continual-learning loop counts (observe→…→adapt). Additive/optional. */ + loop?: LearningStage[]; + /** Recent activity feed, most-recent first, capped ~8. Additive/optional. */ + activity?: ActivityEvent[]; } /** One node as a standalone document in the `graph_nodes` collection. */ diff --git a/shared/src/index.ts b/shared/src/index.ts index b175dc3..dc861df 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -18,6 +18,10 @@ export type { PodGraphNodeKind, PodGraphEdgeKind, PodGraphNodeStatus, + LearningStage, + LearningStageKey, + ActivityEvent, + ActivityKind, GraphNodeDoc, GraphEdgeDoc, } from './graph.js';