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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user