feat(shadow-replay): M4 adapter and M3 divergence

This commit is contained in:
Дмитрий
2026-06-09 06:24:33 +03:00
parent a7f3ebd971
commit ff8f14d8d2
2 changed files with 53 additions and 1 deletions
+22
View File
@@ -11,6 +11,7 @@
import { floorDecide } from './floor-decide.mjs';
import { snapshotNeeded } from './snapshot-decide.mjs';
import { decide as supremeDecide } from './enforce-supreme-gate.mjs';
import { decide as judgeDecide } from './enforce-judge-gate.mjs';
import { freezePlan } from './plan-lock.mjs';
// ── Фикстуры (данные) ──────────────────────────────────────────────────────
@@ -91,3 +92,24 @@ export function decideM2(toolUse, frozenPlan, stepPtr) {
const r = supremeDecide({ toolUse, frozenPlan, frozenArtifact: null, stepPtr, key: REPLAY_KEY });
return { blocked: r.decision === 'block', reason: r.reason };
}
/** М4: gate-логика судьи. {mode,verdict,floorBlocked} → {blocked,reason}. */
export function decideM4({ mode, verdict, floorBlocked }) {
const r = judgeDecide({ mode, verdict, floorBlocked });
return { blocked: !!r.block, reason: r.reason || r.message };
}
/** М3: расхождение «советовал ↔ взяли» по записям эпизодов (рекомендатель, не блокатор). */
export function m3Divergence(records) {
let diverged = 0, followed = 0, noRec = 0;
for (const r of records || []) {
const co = (r && r.classifier_output) || {};
const rec = Array.isArray(co.recommended_chain) ? co.recommended_chain : [];
const taken = (r && r.primary_rationale && r.primary_rationale.node_chosen) || 'direct';
const hasRec = rec.length > 0 && !co.no_skill_found;
if (!hasRec) { noRec++; continue; }
if (taken === 'direct') diverged++;
else followed++;
}
return { total: (records || []).length, diverged, followed, noRec };
}
+31 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
BENIGN_FIXTURE, RISKY_FIXTURE, M2_PLAN_STEPS, M2_EVENTS, M4_FIXTURE,
classifyOutcome, decideM5, decideM6, decideM2, freezeReplayPlan,
classifyOutcome, decideM5, decideM6, decideM2, freezeReplayPlan, decideM4, m3Divergence,
} from './shadow-replay.mjs';
describe('shadow-replay fixtures', () => {
@@ -86,3 +86,33 @@ describe('М2 стена адаптер', () => {
expect(decideM2({ name: 'Skill', input: { skill: 'superpowers:writing-plans' } }, plan, 0).blocked).toBe(false);
});
});
describe('М4 судья адаптер', () => {
it('shadow → allow (логирует, не блокирует)', () => {
expect(decideM4({ mode: 'shadow', verdict: { decision: 'NO-GO' }, floorBlocked: false }).blocked).toBe(false);
});
it('live-block + GO + пол чист → allow', () => {
expect(decideM4({ mode: 'live-block', verdict: { decision: 'GO' }, floorBlocked: false }).blocked).toBe(false);
});
it('live-block + NO-GO → block', () => {
expect(decideM4({ mode: 'live-block', verdict: { decision: 'NO-GO' }, floorBlocked: false }).blocked).toBe(true);
});
it('live-block + битый вердикт → block', () => {
expect(decideM4({ mode: 'live-block', verdict: null, floorBlocked: false }).blocked).toBe(true);
});
});
describe('М3 расхождение', () => {
const recs = [
{ classifier_output: { recommended_chain: ['#19'], no_skill_found: false }, primary_rationale: { node_chosen: 'direct' } },
{ classifier_output: { recommended_chain: ['#19'], no_skill_found: false }, primary_rationale: { node_chosen: '#19' } },
{ classifier_output: { recommended_chain: [], no_skill_found: true }, primary_rationale: { node_chosen: 'direct' } },
];
it('считает diverged/followed/no-rec', () => {
const r = m3Divergence(recs);
expect(r.diverged).toBe(1);
expect(r.followed).toBe(1);
expect(r.noRec).toBe(1);
expect(r.total).toBe(3);
});
});