397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
5.2 KiB
JavaScript
115 lines
5.2 KiB
JavaScript
#!/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 };
|
||
}
|