3 Commits

Author SHA1 Message Date
sb-iam 1901010996 fix(continual-learning): harden wasRealCollision verifier (Codex review)
Addresses the P1 brittleness in the Step 3 verifier so learned_from edges
(graph/live.ts:413) can't be silently zeroed on stage.

- Capture overlap AT detection time as Collision.gitOverlap (podman.ts), while
  engineer_states are still fresh, instead of re-deriving from possibly-stale
  state when the user clicks. deriveWasRealCollision() now prefers this stored
  evidence and only falls back to a live re-derivation for older collisions.
- Canonicalize engineer names (trim + lowercase) on both the capture and the
  fallback path, so "Karti" vs "karti" no longer misses the git state.
- conflictKey now reuses the shared comparableBasename() helper (dedupe).

shared/src/collision.ts gains optional `gitOverlap?: boolean` (additive).
shared rebuilt; backend+frontend typecheck + eslint pass. PLAN.md rung 3 updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 07:57:38 -05:00
sb-iam d9776452b2 feat(continual-learning): derive wasRealCollision from git overlap (RSI step 3)
Backend becomes authoritative for the verifier signal. recordOutcome now
overrides the client-supplied wasRealCollision with deriveWasRealCollision():
a flagged collision counts as REAL only if BOTH named engineers currently have
the collided file in their git changedFiles (getGitStates, 120s freshness TTL).
Conservative false when the collision is orphaned/missing or git state is
stale. Restores the (accepted x wasReal) 2x2 the spec assumes instead of the
hardcoded 107/107 true.

frontend/useInterventions.ts stops sending a hardcoded `true` (now a
backend-overridden placeholder). Spec: continual-learning/spec.md:98-108,
policy.md:35-42. backend+frontend typecheck + eslint pass. PLAN.md P0.5 rung 3
added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 07:47:53 -05:00
sb-iam 0d69f82139 feat(continual-learning): activate negative-feedback loop (RSI steps 1-2)
Step 1 — policy.ts shouldIntervene: suppress on a prior dismissal alone.
The former `&& !priorOutcome.wasRealCollision` term was dead code (outcomes
record wasRealCollision hardcoded true), so the 85 real dismissals in Atlas
were never used. Now a prior accepted===false suppresses the next identical
nudge. Spec: continual-learning/policy.md:41, spec.md:163.

Step 2 — podman.ts handle: only escalate severity to 'critical' (the spoken
alert trigger) when the recalled prior was an accepted *real* collision,
instead of blanket-escalating every recall. Surfaces learned routing in
preferredAction; stops dismissed/false priors over-escalating to voice.
Spec: continual-learning/policy.md:62-63, plan.md:66.

Documentation-first: adds PLAN.md section 8 "P0.5 - RSI negative-feedback
activation" with both rungs + follow-ups. No schema change. backend typecheck
passes. Independent of the MongoDB-cleanup handoff (Codex).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 07:42:42 -05:00
6 changed files with 154 additions and 16 deletions
+47 -10
View File
@@ -9,11 +9,42 @@ import {
recordIntervention, recordIntervention,
updateInterventionStatus, updateInterventionStatus,
} from '../memory/store.js'; } from '../memory/store.js';
import { getGitStates } from '../memory/db.js'; import { getGitStates, type GitState } from '../memory/db.js';
import { recallSimilar } from '../memory/vectors.js'; import { recallSimilar } from '../memory/vectors.js';
import { shouldIntervene, preferredAction } from '../memory/policy.js'; import { shouldIntervene, preferredAction } from '../memory/policy.js';
import { publishHermesIntervention } from '../action/hermes.js'; import { publishHermesIntervention } from '../action/hermes.js';
/** Strip a git-status prefix ("M ", "?? ") and reduce a path to its lowercased
* basename — matches comparableFile() in memory/store.ts so keys line up. */
function comparableBasename(raw?: string): string {
return (
(raw ?? '')
.trim()
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
.split(/[\\/]/)
.pop()
?.toLowerCase() ?? ''
);
}
/** Canonicalize an engineer name for case/whitespace-insensitive matching, so
* "Karti" and "karti" resolve to the same engineer's git state. */
function canonicalName(raw?: string): string {
return (raw ?? '').trim().toLowerCase();
}
/** Git ground truth: do ALL involved engineers currently have the collided file
* in their changedFiles? Computed at detection time while git state is fresh. */
function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, GitState>): boolean {
const target = comparableBasename(collision.file);
if (!target || collision.engineers.length < 2) return false;
const byCanon = new Map<string, string[]>();
for (const [name, st] of gitStates) byCanon.set(canonicalName(name), st.changedFiles);
return collision.engineers.every((e) =>
(byCanon.get(canonicalName(e)) ?? []).some((f) => comparableBasename(f) === target),
);
}
export class PodMan { export class PodMan {
private contexts = new Map<string, EngineerContext>(); private contexts = new Map<string, EngineerContext>();
/** /**
@@ -75,6 +106,12 @@ export class PodMan {
if (!current.has(key)) this.activeConflicts.delete(key); if (!current.has(key)) this.activeConflicts.delete(key);
} }
// Capture git ground-truth overlap now, while engineer_states are fresh, so
// the outcome-time verifier never depends on a stale sidecar or a late click.
for (const collision of collisions) {
collision.gitOverlap = engineersOverlapOnFile(collision, gitStates);
}
for (const collision of collisions) await this.handle(collision); for (const collision of collisions) await this.handle(collision);
} }
@@ -85,14 +122,7 @@ export class PodMan {
* basename. * basename.
*/ */
private conflictKey(collision: Collision): string { private conflictKey(collision: Collision): string {
return ( return comparableBasename(collision.file);
(collision.file ?? '')
.trim()
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
.split(/[\\/]/)
.pop()
?.toLowerCase() ?? ''
);
} }
private async handle(collision: Collision): Promise<void> { private async handle(collision: Collision): Promise<void> {
@@ -100,7 +130,14 @@ export class PodMan {
if (this.activeConflicts.has(key)) return; // single-shot: already voiced, still unresolved if (this.activeConflicts.has(key)) return; // single-shot: already voiced, still unresolved
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
if (prior) collision.severity = 'critical'; // Only escalate to critical (which triggers the spoken alert) when the
// recalled prior was an *accepted real* collision. Blanket-escalating every
// recall — including dismissed/false-positive priors — masked the learned
// routing in preferredAction and made recalled noise scream "CRITICAL".
// (RSI Step 2 — continual-learning/policy.md:62-63, plan.md:66)
if (prior?.priorOutcome?.accepted && prior?.priorOutcome?.wasRealCollision) {
collision.severity = 'critical';
}
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution
+6 -1
View File
@@ -12,7 +12,12 @@ export function shouldIntervene(collision: Collision, prior: RecalledCollision |
if (collision.severity === 'info') return false; if (collision.severity === 'info') return false;
const priorOutcome = prior?.priorOutcome; const priorOutcome = prior?.priorOutcome;
if (priorOutcome && !priorOutcome.accepted && !priorOutcome.wasRealCollision) return false; // Suppress when the identical prior was dismissed (accepted === false). The
// former `&& !priorOutcome.wasRealCollision` term was dead code: outcomes are
// recorded with wasRealCollision hardcoded true, so the gate never fired and
// the 85 real dismissals in Atlas were ignored. Dismissals are the negative
// signal per continual-learning/policy.md:41 + spec.md:163. (RSI Step 1)
if (priorOutcome && !priorOutcome.accepted) return false;
const cooldown = cooldownMs(); const cooldown = cooldownMs();
const last = lastNudgeByPod.get(collision.podId) ?? 0; const last = lastNudgeByPod.get(collision.podId) ?? 0;
+44 -4
View File
@@ -5,7 +5,7 @@ import type {
InterventionOutcome, InterventionOutcome,
InterventionStatus, InterventionStatus,
} from '@podman/shared'; } from '@podman/shared';
import { collections } from './db.js'; import { collections, getGitStates } from './db.js';
import { enrichCollisionMemory } from './vectors.js'; import { enrichCollisionMemory } from './vectors.js';
function comparableFile(raw?: string): string { function comparableFile(raw?: string): string {
@@ -84,13 +84,53 @@ export async function updateInterventionStatus(
); );
} }
/**
* Step 3 — derive whether a flagged collision was REAL from git ground truth,
* instead of trusting the client (which historically hardcoded `true`). A
* collision counts as real only if BOTH named engineers currently have the
* collided file in their git `changedFiles`. Conservative: returns false when
* the collision is orphaned/missing or git state is stale/unavailable.
* Verifier supervision per docs/continual-learning/spec.md:98-108, policy.md:35-42.
*/
export async function deriveWasRealCollision(outcome: InterventionOutcome): Promise<boolean> {
try {
const c = await collections();
const collision = await c.collisions.findOne({ id: outcome.collisionId });
if (!collision) return false;
// Prefer the overlap evidence captured at detection time (fresh git state):
// immune to late clicks, stale sidecars, and the engineer_states TTL.
if (typeof collision.gitOverlap === 'boolean') return collision.gitOverlap;
// Fallback for collisions detected before gitOverlap was captured: re-derive
// from latest git state, matching engineers on case/whitespace-canonical names.
if (!Array.isArray(collision.engineers) || collision.engineers.length < 2) return false;
const target = comparableFile(collision.file);
if (!target) return false;
const byCanon = new Map<string, string[]>();
for (const [name, st] of await getGitStates(outcome.podId)) {
byCanon.set(name.trim().toLowerCase(), st.changedFiles);
}
return collision.engineers.every((e) =>
(byCanon.get(e.trim().toLowerCase()) ?? []).some((f) => comparableFile(f) === target),
);
} catch (err) {
console.error(`[memory] wasRealCollision verifier failed: ${(err as Error).message}`);
return false;
}
}
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> { export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
// Backend is authoritative for wasRealCollision: derive it from git overlap
// rather than trusting the client-supplied value. (RSI Step 3)
const verified: InterventionOutcome = {
...outcome,
wasRealCollision: await deriveWasRealCollision(outcome),
};
await persist('outcome', async () => { await persist('outcome', async () => {
const c = await collections(); const c = await collections();
await c.outcomes.insertOne({ ...outcome }); await c.outcomes.insertOne({ ...verified });
await c.interventions.updateOne( await c.interventions.updateOne(
{ id: outcome.interventionId }, { id: verified.interventionId },
{ $set: { status: outcome.accepted ? 'accepted' : 'dismissed' } }, { $set: { status: verified.accepted ? 'accepted' : 'dismissed' } },
); );
}); });
} }
+46
View File
@@ -484,6 +484,52 @@ artifact.
recorded backup. recorded backup.
- Keep backup video on a separate device. - Keep backup video on a separate device.
### P0.5 - RSI negative-feedback activation (continual-learning)
The continual-learning loop records outcomes but never feeds the negative
signal back. Live Atlas (2026-06-28): `outcomes` = 22 accepted / 85 dismissed,
yet `wasRealCollision` is `true` in 107/107 (hardcoded), so the suppression
gate is dead and dismissals are unused. These two rungs activate the loop with
no schema change. Owner: RSI track. Independent of the MongoDB-cleanup handoff.
1. **Step 1 - suppress on prior dismissal alone**
- `backend/src/memory/policy.ts` `shouldIntervene`: remove the dead
`&& !priorOutcome.wasRealCollision` term so a prior `accepted === false`
suppresses the next identical-signature nudge.
- Spec: `docs/continual-learning/policy.md:41` (dismissed = negative signal),
`spec.md:163` (dismissals adapt suppression).
- Caveat: recall is single-shot most-recent (`memory/vectors.ts`), so this is
"last-outcome-wins" until Step 3 (derive `wasRealCollision`) lands.
2. **Step 2 - gate the recall severity escalation**
- `backend/src/agent/podman.ts` `handle`: only force `severity = 'critical'`
when the recalled prior was an accepted *real* collision, instead of
blanket-escalating every recall. Surfaces the learned routing in
`preferredAction`; stops dismissed/false priors over-escalating to voice.
- Spec: `docs/continual-learning/policy.md:62-63` (prefer prior accepted
kind), `plan.md:66` (second similar event behaves differently).
3. **Step 3 - derive `wasRealCollision` from git overlap (backend-authoritative)**
- Overlap is captured AT detection time as `Collision.gitOverlap`
(`backend/src/agent/podman.ts`), while `engineer_states` are still fresh —
true only if ALL involved engineers have the collided file in their git
`changedFiles`, matched on case/whitespace-canonical names.
- `backend/src/memory/store.ts` `recordOutcome` overrides the client value
with `deriveWasRealCollision()`, which prefers the stored `gitOverlap`
(immune to late clicks / stale sidecars / the 120s TTL) and only falls back
to a live canonical-name re-derivation for pre-existing collisions.
`frontend/.../useInterventions.ts` stops sending hardcoded `true`.
- Restores the (accepted × wasReal) 2×2 the spec assumes; keeps `learned_from`
edges (`graph/live.ts:413`) from being silently zeroed on stage.
- Spec: `docs/continual-learning/spec.md:98-108`, `policy.md:35-42`.
- Hardened per Codex review (name canonicalization + detection-time capture).
Follow-ups (separate rungs, not in this change): Step 4-5 `strategy_versions` +
Gemini-proposed `LearningProposal` slice; Step 6 durable `owns` write; seed a
clean demo pod with a repeated dismissed signature (the historic dismissals are
orphaned — `collisionId` resolves to no collision — so they cannot drive the
demo verifier).
### P1 - polish the money moment ### P1 - polish the money moment
- Add visible live inference captions in the PWA. - Add visible live inference captions in the PWA.
+3 -1
View File
@@ -66,7 +66,9 @@ export function useInterventions(room: Room | null) {
interventionId: active.id, interventionId: active.id,
collisionId: active.collisionId, collisionId: active.collisionId,
podId: active.podId, podId: active.podId,
wasRealCollision: true, // Placeholder only — the backend derives the authoritative value from
// git overlap at outcome time (the client cannot know). (RSI Step 3)
wasRealCollision: false,
accepted, accepted,
recordedAt: new Date().toISOString(), recordedAt: new Date().toISOString(),
}); });
+8
View File
@@ -16,6 +16,14 @@ export interface Collision {
severity: CollisionSeverity; severity: CollisionSeverity;
/** Snapshot of relevant GitHub state at detection time. */ /** Snapshot of relevant GitHub state at detection time. */
githubState?: GithubStateSnapshot; githubState?: GithubStateSnapshot;
/**
* Git ground-truth overlap captured AT detection time, while engineer_states
* are still fresh: true when every involved engineer had `file` in their git
* changedFiles. Read as the authoritative wasRealCollision evidence at outcome
* time, so a late click, a stale sidecar, or the engineer_states freshness TTL
* cannot retroactively zero it out.
*/
gitOverlap?: boolean;
detectedAt: string; detectedAt: string;
} }