// tools/judge-engine.test.mjs import { describe, it, expect } from 'vitest'; import { requiredLensesFor, buildJudgePrompt, classifyByReversibility, runJudge, consensusDecision, PREMORTEM_CLASSES, VOTE_LAYOUTS, } from './judge-engine.mjs'; describe('requiredLensesFor (§7 раскладка голосов по функциям)', () => { it('Гейт-1 несёт 4 ядра, включая премортем (линза K7)', () => { const l = requiredLensesFor('gate1'); expect(l).toContain('completeness'); expect(l).toContain('premortem'); // K7 expect(l).toContain('goal_advocate'); expect(l).toContain('correctness'); }); it('Гейт-2 несёт 5 голосов, включая проверяемость (K5)', () => { expect(requiredLensesFor('gate2')).toEqual(VOTE_LAYOUTS.gate2); expect(requiredLensesFor('gate2')).toContain('verifiability'); }); it('риск добавляет Атакующего и Деньги', () => { const l = requiredLensesFor('gate2', { risk: true }); expect(l).toContain('attacker'); expect(l).toContain('money'); }); it('gate3card несёт 3 линзы сверки карточки с продуктом', () => { const l = requiredLensesFor('gate3card'); expect(l).toEqual(VOTE_LAYOUTS.gate3card); expect(l).toContain('card_matches_product'); expect(l).toContain('no_overstatement'); expect(l).toContain('verify_steps_real'); }); }); describe('buildJudgePrompt (чистая, детерминированная; слепа к переписке)', () => { const args = { functionName: 'gate1', requiredLenses: ['completeness', 'premortem'], product: { spec: 'X' }, goal: 'лендинг', cards: ['frontend-design'] }; it('тот же вход → тот же промпт (детерминизм)', () => { expect(buildJudgePrompt(args)).toEqual(buildJudgePrompt({ ...args })); }); it('system содержит требуемые линзы и классы премортема и якорь цели', () => { const { system, user } = buildJudgePrompt(args); expect(system).toContain('completeness'); expect(system).toContain('premortem'); for (const c of PREMORTEM_CLASSES) expect(system).toContain(c); expect(user).toContain('лендинг'); }); it('круг 1 слеп: пустой roundMemory → нет блока; непустой → блок памяти в user', () => { expect(buildJudgePrompt(args).user).not.toContain('ПАМЯТЬ КРУГОВ'); const withMem = buildJudgePrompt({ ...args, roundMemory: { objections: ['моё прошлое замечание'] } }); expect(withMem.user).toContain('ПАМЯТЬ КРУГОВ'); expect(withMem.user).toContain('моё прошлое замечание'); }); }); describe('classifyByReversibility (G1: фатальное=тяжёлое И необратимое → блок)', () => { it('тяжёлое И необратимое → блок', () => { expect(classifyByReversibility({ severity: 'fatal', reversible: false })).toBe('block'); expect(classifyByReversibility({ severity: 'heavy', reversible: false })).toBe('block'); }); it('обратимое → совет (даже тяжёлое)', () => { expect(classifyByReversibility({ severity: 'heavy', reversible: true })).toBe('advice'); }); it('косметика → совет', () => { expect(classifyByReversibility({ severity: 'cosmetic', reversible: false })).toBe('advice'); }); it('тяжёлое БЕЗ явной пометки обратимости → блок (сомнение→блок, #8)', () => { expect(classifyByReversibility({ severity: 'fatal' })).toBe('block'); expect(classifyByReversibility({ severity: 'heavy', reversible: undefined })).toBe('block'); }); }); describe('runJudge (механические проверки вокруг мокнутой модели)', () => { const lenses = ['completeness', 'premortem']; const fullSlots = { completeness: 'все нужды §1 закрыты подробно', premortem: 'риск отступа не закрыт детально' }; const base = { functionName: 'gate1', requiredLenses: lenses, promptArgs: { product: {}, goal: 'g', cards: [] } }; it('полные слоты + GO + нет блокирующих → GO', () => { const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [] }); const r = runJudge({ ...base, llmCall }); expect(r.decision).toBe('GO'); expect(r.accepted).toBe(true); }); it('пустой требуемый слот → вердикт невалиден → НЕ GO', () => { const llmCall = () => ({ decision: 'GO', slots: { completeness: 'ок и подробно' }, objections: [] }); const r = runJudge({ ...base, llmCall }); expect(r.decision).toBe('NO-GO'); expect(r.accepted).toBe(false); }); it('якорное фатально-необратимое возражение → блок', () => { const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [ { verdict: 'NO', text: 'дыра', severity: 'fatal', reversible: false, anchor: { kind: 'spec_section', ref: '§2' } }, ] }); const r = runJudge({ ...base, llmCall }); expect(r.decision).toBe('NO-GO'); expect(r.blocking).toHaveLength(1); }); it('безъякорное «НЕТ» → демотится в совет, ворота не клинит → GO', () => { const llmCall = () => ({ decision: 'NO-GO', slots: fullSlots, objections: [ { verdict: 'NO', text: 'вообще не нравится', severity: 'fatal', reversible: false }, ] }); const r = runJudge({ ...base, llmCall }); expect(r.decision).toBe('GO'); expect(r.advice.length).toBeGreaterThan(0); }); it('обратимое якорное возражение → совет, не блок', () => { const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [ { verdict: 'NO', text: 'некрасиво', severity: 'heavy', reversible: true, anchor: { kind: 'observation', ref: 'L10' } }, ] }); const r = runJudge({ ...base, llmCall }); expect(r.decision).toBe('GO'); expect(r.advice).toHaveLength(1); }); it('требуемый под-прогон отсутствует → вердикт не принят (фейк прилежности)', () => { const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [] }); const r = runJudge({ ...base, llmCall, subRunsRequired: [{ lens: 'premortem' }], subRuns: [] }); expect(r.decision).toBe('NO-GO'); expect(r.accepted).toBe(false); }); // fix: tools/judge-engine.mjs (аудит M1-M4, свежий объектив) — битый objections от модели // не роняет runJudge (раньше (raw.objections||[]).filter гасил только falsy, не null-элемент / не-массив). it('objections с null-элементом → не падает, нет блокирующих → GO', () => { const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [null] }); const r = runJudge({ ...base, llmCall }); expect(r.decision).toBe('GO'); expect(r.accepted).toBe(true); }); it('objections не массив (модель вернула строку) → не падает → GO', () => { const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: 'возражений нет' }); const r = runJudge({ ...base, llmCall }); expect(r.decision).toBe('GO'); }); it('валидное якорное «НЕТ» рядом с null-элементом → блок (null отброшен, реальное возражение учтено)', () => { const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [ null, { verdict: 'NO', text: 'дыра', severity: 'fatal', reversible: false, anchor: { kind: 'spec_section', ref: '§2' } }, ] }); const r = runJudge({ ...base, llmCall }); expect(r.decision).toBe('NO-GO'); expect(r.blocking).toHaveLength(1); }); }); describe('consensusDecision (J3: один НЕТ → блок)', () => { it('любой NO-GO среди судей → общий NO-GO', () => { expect(consensusDecision([{ decision: 'GO' }, { decision: 'NO-GO' }, { decision: 'GO' }])).toBe('NO-GO'); }); it('все GO → GO', () => { expect(consensusDecision([{ decision: 'GO' }, { decision: 'GO' }])).toBe('GO'); }); // fix: tools/judge-engine.mjs (J, аудит M1-M4) — нет голосов / битый голос = НЕ согласие (fail-closed) it('пустой список судей → NO-GO (нет голосов ≠ согласие)', () => { expect(consensusDecision([])).toBe('NO-GO'); }); it('битый/неполный голос среди судей → NO-GO (не дрейфует к GO)', () => { expect(consensusDecision([{ decision: 'GO' }, { decision: undefined }])).toBe('NO-GO'); expect(consensusDecision([{ decision: 'GO' }, null])).toBe('NO-GO'); }); }); describe('delivery_honesty линза (gate2)', () => { it('gate2 несёт линзу delivery_honesty', () => { expect(requiredLensesFor('gate2')).toContain('delivery_honesty'); }); it('buildJudgePrompt с delivery рендерит пометку и правило честности', () => { const { system, user } = buildJudgePrompt({ functionName: 'gate2', requiredLenses: requiredLensesFor('gate2'), product: { plan: 'X' }, goal: 'довести фичу до пользователя', cards: [], delivery: 'internal', }); expect(user).toContain('ПОМЕТКА DELIVERY: internal'); expect(system).toContain('delivery_honesty'); expect(system).toContain('СОКРЫТИЕ'); }); it('buildJudgePrompt без delivery (gate1) → строки DELIVERY нет', () => { const { user } = buildJudgePrompt({ functionName: 'gate1', requiredLenses: ['completeness'], product: {}, goal: 'g', cards: [] }); expect(user).not.toContain('ПОМЕТКА DELIVERY'); }); it('runJudge gate2 честный internal → GO', () => { const lenses = requiredLensesFor('gate2'); const slots = Object.fromEntries(lenses.map((l) => [l, `слот линзы ${l} заполнен подробно`])); const r = runJudge({ functionName: 'gate2', requiredLenses: lenses, llmCall: () => ({ slots, objections: [] }), promptArgs: { product: {}, goal: 'g', cards: [], delivery: 'internal' } }); expect(r.decision).toBe('GO'); }); it('runJudge gate2 internal при пользе → якорное NO на delivery_honesty → NO-GO', () => { const lenses = requiredLensesFor('gate2'); const slots = Object.fromEntries(lenses.map((l) => [l, `слот линзы ${l} заполнен подробно`])); const r = runJudge({ functionName: 'gate2', requiredLenses: lenses, llmCall: () => ({ slots, objections: [ { verdict: 'NO', severity: 'heavy', reversible: false, anchor: { kind: 'failed_criterion', ref: 'delivery=internal при пользовательском результате' } }, ] }), promptArgs: { product: {}, goal: 'g', cards: [], delivery: 'internal' } }); expect(r.decision).toBe('NO-GO'); expect(r.blocking).toHaveLength(1); }); });