Files
brain/tools/discipline-metrics.mjs
T

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 };
}