Files
brain/tools/judge-engine.test.mjs
T

151 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
});
});
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');
});
});