5d7035875c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
151 lines
8.6 KiB
JavaScript
151 lines
8.6 KiB
JavaScript
// 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');
|
||
});
|
||
});
|