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
// activity stream shows the learning instead of nothing.
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,
prior.priorOutcome.interventionId,
prior.priorOutcome.recordedAt,
).catch((err) =>
console.error(`[memory] suppression record failed: ${(err as Error).message}`),
);
}
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
.find({ podId })
.sort({ suppressedAt: -1 })
.limit(20)
.limit(50)
.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) {
const sFile = normalizeFile(s.file);
if (!isFilePath(sFile)) continue;
const fileKey = sFile.toLowerCase();
if (seenSuppressedFiles.has(fileKey)) continue;
seenSuppressedFiles.add(fileKey);
const sEngs = (s.engineers ?? []).join(' + ') || 'teammates';
pushActivity(
activity,
{
id: `suppressed:${s.id}`,
id: `suppressed:${fileKey}`,
at: s.suppressedAt,
kind: 'suppressed',
title: `Suppressed — ${shortLabel(sFile)} repeat silenced`,
@@ -520,7 +527,11 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
const nodes = [...b.nodes.values()];
// 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;
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. */
export async function memoryStats(): Promise<Record<string, number>> {
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.collisions.estimatedDocumentCount(),
c.interventions.estimatedDocumentCount(),
c.outcomes.estimatedDocumentCount(),
c.suppressions.estimatedDocumentCount(),
]);
return { observations, collisions, interventions, outcomes };
return { observations, collisions, interventions, outcomes, suppressions };
}