Files
brain/tools/judge-engine.mjs
T

126 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
/**
* judge-engine (§7/§8/§10, K7, G1) — думающая часть судьи. Образец — router-classifier:
* построитель промпта ЧИСТЫЙ (детерминированно тестируется), сам вызов модели МОКАЕТСЯ,
* а ПРОВЕРКИ вокруг — тупо-механические (пол судьи 4-A). «Умный не баран».
* - VOTE_LAYOUTS: раскладка голосов по функциям (§7), голоса РАЗДЕЛЬНО.
* - PREMORTEM_CLASSES: фиксированные классы провала (§10) — общая карта, не растущий список.
* - K7: премортем-слот на Гейте-1 обязателен (входит в layout gate1).
* - G1 classifyByReversibility: фатальное = тяжёлое И необратимое → блок; иначе совет.
* - runJudge: один вердикт + механический пост-чек (слоты/под-прогоны/якорь/обратимость).
* - consensusDecision (J3): на высоком риске несколько судей, любой НЕТ → блок.
* Судья СЛЕП к переписке: на вход подаётся только продукт+цель+карточки (по конструкции).
*/
import { canonicalJson } from './receipt-sign.mjs';
import { validateVerdictSlots } from './judge-verdict-slots.mjs';
import { partitionObjections } from './judge-anchor.mjs';
import { renderRoundMemory } from './round-memory-render.mjs';
import { requiredSubRunsPresent } from './judge-subrun-journal.mjs';
export const VOTE_LAYOUTS = Object.freeze({
gate1: ['completeness', 'premortem', 'goal_advocate', 'correctness'], // премортем = K7
gate2: ['spec_fidelity', 'verifiability', 'plan_soundness', 'execution_risk', 'step_clarity'],
a2_divergence: ['intent_fidelity'],
a2_destructive: ['radius_reversibility', 'attacker'],
part_light: ['correctness', 'simplicity', 'footgun'],
part_risky: ['radius_tests', 'twins', 'attacker'],
gate3: ['goal_achieved', 'premortem_whole', 'behavior_vs_goal'],
});
export const PREMORTEM_CLASSES = Object.freeze([
'technique', 'seam', 'user', 'future_change', 'abuse', 'money_irreversible',
]);
/** Линзы функции (+ по риску добавляются attacker и money). */
export function requiredLensesFor(functionName, { risk = false } = {}) {
const base = VOTE_LAYOUTS[functionName] ? [...VOTE_LAYOUTS[functionName]] : [];
if (risk) {
if (!base.includes('attacker')) base.push('attacker');
if (!base.includes('money')) base.push('money');
}
return base;
}
/** Чистый построитель промпта судьи: {system, user}. Детерминирован. */
export function buildJudgePrompt({ functionName, requiredLenses = [], product = {}, goal = '', cards = [], roundMemory = {} }) {
const system = [
'Ты — судья (критик, не исполнитель). Выноси МОТИВИРОВАННЫЕ возражения с якорем.',
'Ты СЛЕП к переписке наставника со сторонами — судишь продукт против цели; учитываешь лишь СВОИ прошлые замечания и доводы контроллера лично тебе.',
'План — это БУДУЩЕЕ: слово «проверено»/«сделано» за ФАКТ не принимай (K5).',
`Функция: ${functionName}.`,
`Заполни слот на КАЖДУЮ линзу (пустой слот → вердикт невалиден): ${requiredLenses.join(', ')}.`,
`Премортем по классам провала (по каждому: что от решений НЕ закрыто): ${PREMORTEM_CLASSES.join(', ')}.`,
'Каждое возражение укажи с якорем {kind, ref}; без якоря оно станет советом, не блоком.',
].join('\n');
const user = [
`ЦЕЛЬ (неизменный якорь): ${goal}`,
`КАРТОЧКИ НАВЫКОВ: ${canonicalJson(cards)}`,
`ПРОДУКТ НА СУД: ${canonicalJson(product)}`,
renderRoundMemory(roundMemory),
].filter(Boolean).join('\n');
return { system, user };
}
/**
* G1: что блокирует (фатальное=тяжёлое И необратимое), что идёт советом.
* #8 (сомнение→блок): тяжёлое блокирует, ПОКА не доказано обратимое (reversible===true).
* Отсутствующая/неизвестная обратимость у тяжёлого возражения → блок (fail-closed).
*/
export function classifyByReversibility({ severity, reversible } = {}) {
const heavy = severity === 'fatal' || severity === 'heavy';
return (heavy && reversible !== true) ? 'block' : 'advice';
}
function isBlockingObjection(o) {
return classifyByReversibility(o) === 'block';
}
/**
* Один заход судьи + механический пост-чек. llmCall(prompt)→{decision, slots, objections}.
* Механика поверх модели: слоты валидны? под-прогоны на месте? какие возражения реально
* блокируют (якорь + необратимость)? Модель НЕ может проштамповать GO мимо механики.
*/
export function runJudge({ functionName, requiredLenses, subRunsRequired = [], subRuns = [], llmCall, promptArgs = {} }) {
const prompt = buildJudgePrompt({ functionName, requiredLenses, ...promptArgs });
const raw = llmCall(prompt) || {};
const slotCheck = validateVerdictSlots(raw, requiredLenses);
if (!slotCheck.valid) {
return { decision: 'NO-GO', accepted: false, reason: 'слоты вердикта невалидны (§9.2)', slotCheck };
}
const subRunCheck = requiredSubRunsPresent(subRunsRequired, subRuns);
if (!subRunCheck.ok) {
return { decision: 'NO-GO', accepted: false, reason: 'нет требуемого под-прогона (фейк прилежности, дисциплина #1)', subRunCheck };
}
// Аудит M1-M4 (свежий объектив): нормализуем выход модели — objections может прийти
// не массивом (строка/объект) или с null-элементом. Раньше `(raw.objections||[])` гасил
// только falsy → .filter падал на не-массиве, а `o.verdict` — на null-элементе (краш ломал
// вердикт; в инертной обёртке выброс уходил в catch→block:false = fail-open). Теперь не падает.
const rawObjs = Array.isArray(raw.objections) ? raw.objections : [];
const noObjs = rawObjs.filter((o) => o && o.verdict === 'NO');
const { blocking: anchored, advisory: anchorless } = partitionObjections(noObjs);
const trulyBlocking = anchored.filter(isBlockingObjection); // якорь + необратимость
const demotedReversible = anchored.filter((o) => !isBlockingObjection(o)); // якорь, но обратимо → совет
const advice = [...anchorless, ...demotedReversible];
return {
decision: trulyBlocking.length ? 'NO-GO' : 'GO',
accepted: true,
blocking: trulyBlocking,
advice,
slots: raw.slots,
};
}
/**
* J3-консенсус (высокий риск, несколько судей): GO только если ЕСТЬ голоса И КАЖДЫЙ
* из них чистый GO. Иначе NO-GO. (Аудит M1-M4: пустой список и битый/неполный голос
* раньше дрейфовали к GO — «нет согласия = не блок» неверно для hard-risk; теперь fail-closed.)
*/
export function consensusDecision(verdicts = []) {
const list = verdicts || [];
if (list.length === 0) return 'NO-GO';
return list.every((v) => v && v.decision === 'GO') ? 'GO' : 'NO-GO';
}