From ff8f14d8d2a163cd113fa4e7e98d98cc9096ad89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 9 Jun 2026 06:24:33 +0300 Subject: [PATCH] feat(shadow-replay): M4 adapter and M3 divergence --- tools/shadow-replay.mjs | 22 ++++++++++++++++++++++ tools/shadow-replay.test.mjs | 32 +++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/tools/shadow-replay.mjs b/tools/shadow-replay.mjs index 3a48aa35..84f58f3a 100644 --- a/tools/shadow-replay.mjs +++ b/tools/shadow-replay.mjs @@ -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 }; +} diff --git a/tools/shadow-replay.test.mjs b/tools/shadow-replay.test.mjs index 54435e2b..953e9207 100644 --- a/tools/shadow-replay.test.mjs +++ b/tools/shadow-replay.test.mjs @@ -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); + }); +});