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