Files
portal/tools/discipline-metrics.mjs
T
Дмитрий bec69aa565 fix(brain): derive routerStep from observable signals (was hardcoded constant)
Root cause: primary_rationale.step было жёстко прописано как литерал `1` в обоих
episode-builder'ах (observer-transcript-parser.mjs:813, observer-stop-hook.mjs:153).
Поэтому routerStepReached видел { '1': N } и suspicious=true для ВСЕХ данных —
показатель измерял константу, а не дисциплину роутера.

Фикс: новая чистая функция deriveRouterStep(primary_rationale) — берёт максимум
наблюдаемой стадии router-procedure.md из реальных признаков
(task_classification ≠ 'other' → 2; triggers_matched → 3; chain_ref → 4;
node_chosen ≠ 'direct' → 5). routerStepReached теперь вызывает её при чтении,
игнорируя хранимое pr.step. Это делает метрику честной для ВСЕХ существующих
эпизодов (включая исторические 136 за май) — без миграции данных.

Boost для baseline'а CHECKPOINT B этапа 3: на боевых данных
(131 schema-v2+ эпизод) distribution теперь = { 1: 55, 2: 46, 3: 12, 5: 18 },
suspicious=false. Видно реальную картину: ~42% эпизодов остановились на hard-floor,
только ~14% реально дошли до исполнения навыка.

Follow-up: episode-builder'ы продолжают писать step:1 (теперь это безвредно —
метрика игнорирует). Отдельно можно прибрать запись в builder'ах для
self-describing эпизодов.

Test changes:
- tools/discipline-metrics.test.mjs: +describe('deriveRouterStep') (9 cases),
  routerStepReached describe переписан под сигналы-источник.
- tools/brain-retro-analyzer.test.mjs: 'returns routerStepReached distribution'
  обновлён — эпизоды конструируются с сигналами (triggers vs bare),
  не хранимым step.

Full tools/ vitest run: 520/520 GREEN. 4 pre-existing empty test files
(ruflo-*, subagent-prompt-prefix) — не моя регрессия.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:25:05 +03:00

115 lines
5.2 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.
#!/usr/bin/env node
/**
* Discipline metrics — pure aggregation over observer episodes.
* Stage 2 of router discipline overhaul (spec 2026-05-23): baseline measurement
* перед enforcement в этапе 3.
*
* Pure / read-only. No exec, no fs.
*/
/** Filter helper: only schema v2+ non-error episodes. */
function valid(episodes) {
return (episodes || []).filter(
(e) => e && !e.observer_error && typeof e.schema_version === 'number' && e.schema_version >= 2
);
}
/**
* % эпизодов с матченным триггером, разбивка по task_classification.
* Только классификации, присутствующие в classificationMap (т.е. известные/имеющие узлы).
*
* @param {object[]} episodes
* @param {object} classificationMap { [classification]: string[] }
* @returns {{ [classification]: { episodes: number, withTriggerMatch: number, viaSkill: number, pctTriggerMatch: number, pctViaSkill: number } }}
*/
export function disciplinePercentByClassification(episodes, classificationMap) {
const out = {};
for (const e of valid(episodes)) {
const pr = e.primary_rationale || {};
const cls = pr.task_classification;
if (!cls || !classificationMap[cls]) continue;
if (!out[cls]) out[cls] = { episodes: 0, withTriggerMatch: 0, viaSkill: 0, pctTriggerMatch: 0, pctViaSkill: 0 };
const b = out[cls];
b.episodes += 1;
if (Array.isArray(pr.triggers_matched) && pr.triggers_matched.length > 0) b.withTriggerMatch += 1;
if (pr.node_chosen && pr.node_chosen !== 'direct') b.viaSkill += 1;
}
for (const b of Object.values(out)) {
b.pctTriggerMatch = b.episodes ? b.withTriggerMatch / b.episodes : 0;
b.pctViaSkill = b.episodes ? b.viaSkill / b.episodes : 0;
}
return out;
}
/**
* Вывести шаг router-procedure.md, которого реально достиг эпизод, из
* НАБЛЮДАЕМЫХ признаков primary_rationale (хранимое поле `step` исторически —
* жёсткая константа 1 в обоих episode-builder'ах, поэтому ему не доверяем).
*
* Стадии (берётся максимум достигнутой):
* 1 — hard-floor checkpoint (всегда пройден),
* 2 — классификация дала реальный класс (task_classification ≠ 'other'),
* 3 — подобраны триггеры (triggers_matched непуст),
* 4 — найдена каноническая цепочка (chain_ref непуст),
* 5 — выбран и исполнен узел (node_chosen ≠ 'direct').
*
* @param {object|undefined} pr primary_rationale
* @returns {1|2|3|4|5}
*/
export function deriveRouterStep(pr) {
if (!pr || typeof pr !== 'object') return 1;
let step = 1;
if (pr.task_classification && pr.task_classification !== 'other') step = 2;
if (Array.isArray(pr.triggers_matched) && pr.triggers_matched.length > 0) step = Math.max(step, 3);
const chain = pr.chain_ref;
const hasChain = Array.isArray(chain) ? chain.length > 0 : Boolean(chain);
if (hasChain) step = Math.max(step, 4);
if (pr.node_chosen && pr.node_chosen !== 'direct') step = Math.max(step, 5);
return step;
}
/**
* Распределение по шагу роутера, ВЫВЕДЕННОМУ из наблюдаемых признаков
* (deriveRouterStep) — а не из хранимого pr.step (он был константой 1).
* suspicious=true если total >= 5 && >90% эпизодов выводятся в step 1
* (Pravila §16.4 sanity-check — теперь это реальный сигнал «дисциплина
* проваливается / признаки не пишутся», а не гарантированный артефакт).
*
* @param {object[]} episodes
* @returns {{ distribution: { [step: string]: number }, total: number, suspicious: boolean }}
*/
export function routerStepReached(episodes) {
const distribution = {};
let total = 0;
for (const e of valid(episodes)) {
const key = String(deriveRouterStep(e.primary_rationale));
distribution[key] = (distribution[key] || 0) + 1;
total += 1;
}
const stuckAt1 = (distribution['1'] || 0) / Math.max(total, 1);
return { distribution, total, suspicious: total >= 5 && stuckAt1 > 0.9 };
}
/**
* Доля эпизодов с непустыми applied boundaries, разбивка по path_type.
*
* @param {object[]} episodes
* @returns {{ total: number, withBoundaries: number, rate: number, byPathType: object }}
*/
export function boundariesAppliedRate(episodes) {
let total = 0, withBoundaries = 0;
const byPathType = {};
for (const e of valid(episodes)) {
const pr = e.primary_rationale || {};
const pt = e.path_type || 'null';
const has = Array.isArray(pr.boundaries_applied) && pr.boundaries_applied.length > 0;
total += 1;
if (has) withBoundaries += 1;
if (!byPathType[pt]) byPathType[pt] = { total: 0, withBoundaries: 0, rate: 0 };
byPathType[pt].total += 1;
if (has) byPathType[pt].withBoundaries += 1;
}
for (const b of Object.values(byPathType)) b.rate = b.total ? b.withBoundaries / b.total : 0;
return { total, withBoundaries, rate: total ? withBoundaries / total : 0, byPathType };
}