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