fix(graph): harden suppressed-repeat activity (Codex deep review) (#44)

Four demo-safety fixes on top of #43, none touching demo-pod data:

- De-dup at source: the suppression path returned before activeConflicts.add(),
  so the same unresolved dismissed collision wrote a SuppressionDoc every frame
  (~194 spam rows observed on prod). Now mark the conflict handled first, so it
  records ONCE per recurrence and re-arms via the onScreenFrame resolution sweep.
- Await the write: recordSuppression is the visible learning proof, so await it
  (like recordCollision/recordIntervention) instead of void ...catch().
- Display de-dup: collapse suppressed beats to one per file (most recent) with a
  stable per-file id, so any pre-fix duplicate rows never render as spam.
- Live-graph gate: suppression beats now satisfy the "has activity" check in
  materializePodGraph, so a clean pod with preserved suppressions (but no
  collision/file nodes) no longer falls back to the demo graph and hides the proof.
- Ops: /api/memory/stats counts `suppressions`; docs/mongodb.md documents the
  collection and flags it "preserve in DB cleanup" (visible learning evidence).

Verified end-to-end on a throwaway pod (not demo-pod): suppression-only pod
materializes; 2 dupe rows render as 1 beat. backend+frontend typecheck + eslint pass.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb-iam
2026-06-28 10:18:10 -07:00
committed by GitHub
parent 06098f3c4e
commit 9658d700fa
4 changed files with 45 additions and 8 deletions
+6 -3
View File
@@ -151,12 +151,15 @@ export class PodMan {
// durable suppressed-repeat event (timestamped now, at the repeat) so the // durable suppressed-repeat event (timestamped now, at the repeat) so the
// activity stream shows the learning instead of nothing. // activity stream shows the learning instead of nothing.
if (prior?.priorOutcome && !prior.priorOutcome.accepted) { if (prior?.priorOutcome && !prior.priorOutcome.accepted) {
void recordSuppression( // Mark handled first — like the alert path below — so we record ONE
// suppressed-repeat per recurrence, not once per frame; it re-arms via
// the resolution sweep in onScreenFrame. Awaited like recordCollision so
// the durable learning proof is reliably written.
this.activeConflicts.add(key);
await recordSuppression(
collision, collision,
prior.priorOutcome.interventionId, prior.priorOutcome.interventionId,
prior.priorOutcome.recordedAt, prior.priorOutcome.recordedAt,
).catch((err) =>
console.error(`[memory] suppression record failed: ${(err as Error).message}`),
); );
} }
return; // Loop B: policy gate return; // Loop B: policy gate
+14 -3
View File
@@ -463,16 +463,23 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
const suppressionDocs = await c.suppressions const suppressionDocs = await c.suppressions
.find({ podId }) .find({ podId })
.sort({ suppressedAt: -1 }) .sort({ suppressedAt: -1 })
.limit(20) .limit(50)
.toArray(); .toArray();
// Collapse to one beat per file (keep the most recent — docs are sorted desc)
// so pre-fix duplicate rows never render as spam. The stable per-file id also
// dedupes through pushActivity's `seen` set.
const seenSuppressedFiles = new Set<string>();
for (const s of suppressionDocs) { for (const s of suppressionDocs) {
const sFile = normalizeFile(s.file); const sFile = normalizeFile(s.file);
if (!isFilePath(sFile)) continue; if (!isFilePath(sFile)) continue;
const fileKey = sFile.toLowerCase();
if (seenSuppressedFiles.has(fileKey)) continue;
seenSuppressedFiles.add(fileKey);
const sEngs = (s.engineers ?? []).join(' + ') || 'teammates'; const sEngs = (s.engineers ?? []).join(' + ') || 'teammates';
pushActivity( pushActivity(
activity, activity,
{ {
id: `suppressed:${s.id}`, id: `suppressed:${fileKey}`,
at: s.suppressedAt, at: s.suppressedAt,
kind: 'suppressed', kind: 'suppressed',
title: `Suppressed — ${shortLabel(sFile)} repeat silenced`, title: `Suppressed — ${shortLabel(sFile)} repeat silenced`,
@@ -520,7 +527,11 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
const nodes = [...b.nodes.values()]; const nodes = [...b.nodes.values()];
// No real activity beyond the bare roster -> let the caller fall back to demo. // No real activity beyond the bare roster -> let the caller fall back to demo.
const hasActivity = nodes.some((n) => n.kind !== 'engineer'); // Suppression beats are real negative-feedback proof even when they add no
// nodes/edges, so they satisfy the gate too — a clean pod with preserved
// suppressions must not fall back to the demo graph and hide the proof.
const hasSuppressed = activity.some((a) => a.kind === 'suppressed');
const hasActivity = hasSuppressed || nodes.some((n) => n.kind !== 'engineer');
if (!hasActivity) return null; if (!hasActivity) return null;
layout(nodes); layout(nodes);
+3 -2
View File
@@ -163,11 +163,12 @@ export async function recordOutcome(outcome: InterventionOutcome): Promise<void>
/** Document counts per collection — used by the /api/memory/stats endpoint. */ /** Document counts per collection — used by the /api/memory/stats endpoint. */
export async function memoryStats(): Promise<Record<string, number>> { export async function memoryStats(): Promise<Record<string, number>> {
const c = await collections(); const c = await collections();
const [observations, collisions, interventions, outcomes] = await Promise.all([ const [observations, collisions, interventions, outcomes, suppressions] = await Promise.all([
c.observations.estimatedDocumentCount(), c.observations.estimatedDocumentCount(),
c.collisions.estimatedDocumentCount(), c.collisions.estimatedDocumentCount(),
c.interventions.estimatedDocumentCount(), c.interventions.estimatedDocumentCount(),
c.outcomes.estimatedDocumentCount(), c.outcomes.estimatedDocumentCount(),
c.suppressions.estimatedDocumentCount(),
]); ]);
return { observations, collisions, interventions, outcomes }; return { observations, collisions, interventions, outcomes, suppressions };
} }
+22
View File
@@ -121,6 +121,28 @@ Key fields:
Primary use: accepted and dismissed outcomes drive exact recall, suppression, Primary use: accepted and dismissed outcomes drive exact recall, suppression,
and learned graph paths. and learned graph paths.
### `suppressions`
Durable negative-feedback proof: one record per *suppressed repeat* — a
previously-dismissed collision signature recurred and PodMan stayed quiet.
Written at repeat time by `backend/src/agent/podman.ts` (once per recurrence,
re-armed on resolution), materialized as `suppressed` activity by
`backend/src/graph/live.ts`.
Key fields:
- `id`
- `podId`
- `collisionId`
- `file`
- `engineers`
- `priorInterventionId` — the dismissed intervention this repeat matched
- `priorDismissedAt`
- `suppressedAt` — the repeat time (drives recency in the activity stream)
Index `{ podId: 1, suppressedAt: -1 }`. Counted in `/api/memory/stats`.
**Preserve in any DB cleanup** — this is visible learning evidence, not noise.
### `team_model` ### `team_model`
Durable per-pod summary memory. Durable per-pod summary memory.