feat(brain): discipline-metrics — 3 среза для baseline (stage 2 task 3)

Pure-функции: disciplinePercentByClassification / routerStepReached /
boundariesAppliedRate. Read-only, без exec/fs. Sentinel-флаг suspicious
для router step=1 stuck-bug (Pravila §16.4 sanity-check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-24 06:49:47 +03:00
parent 0e0347b31b
commit c0d35fdce3
2 changed files with 223 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
#!/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;
}
/**
* Распределение по шагу роутера (primary_rationale.step).
* suspicious=true если total >= 5 && >90% эпизодов застряли на step=1
* (sentinel-bug парсера — 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 step = (e.primary_rationale || {}).step;
const key = step === null || step === undefined ? 'null' : String(step);
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 };
}
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import {
disciplinePercentByClassification,
routerStepReached,
boundariesAppliedRate,
} from './discipline-metrics.mjs';
const map = { feature: ['#19'], bugfix: ['#18'], refactor: ['#11', '#12'] };
function ep(overrides = {}) {
return {
schema_version: 2,
primary_rationale: {
task_classification: 'feature',
node_chosen: 'direct',
triggers_matched: [],
boundaries_applied: [],
step: 1,
},
path_type: 'regulated',
...overrides,
};
}
describe('disciplinePercentByClassification', () => {
it('counts episodes per classification', () => {
const eps = [
ep({ primary_rationale: { task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1 } }),
ep({ primary_rationale: { task_classification: 'feature', node_chosen: '#19', triggers_matched: [{node:'#19'}], boundaries_applied: [], step: 3 } }),
ep({ primary_rationale: { task_classification: 'bugfix', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1 } }),
];
const res = disciplinePercentByClassification(eps, map);
expect(res.feature.episodes).toBe(2);
expect(res.feature.withTriggerMatch).toBe(1);
expect(res.feature.viaSkill).toBe(1);
expect(res.feature.pctTriggerMatch).toBeCloseTo(0.5);
expect(res.feature.pctViaSkill).toBeCloseTo(0.5);
expect(res.bugfix.episodes).toBe(1);
expect(res.bugfix.pctTriggerMatch).toBe(0);
});
it('ignores classifications outside the map', () => {
const eps = [ep({ primary_rationale: { task_classification: 'unknown', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [], step: 1 } })];
const res = disciplinePercentByClassification(eps, map);
expect(res.unknown).toBeUndefined();
});
it('ignores v1 episodes and observer_error markers', () => {
const eps = [
{ schema_version: 1, primary_rationale: { task_classification: 'feature', node_chosen: 'direct' } },
{ observer_error: true },
ep(),
];
const res = disciplinePercentByClassification(eps, map);
expect(res.feature.episodes).toBe(1);
});
it('returns empty object on empty input', () => {
expect(disciplinePercentByClassification([], map)).toEqual({});
});
});
describe('routerStepReached', () => {
it('counts episodes by step', () => {
const eps = [
ep({ primary_rationale: { step: 1, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [] } }),
ep({ primary_rationale: { step: 1, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [] } }),
ep({ primary_rationale: { step: 3, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [] } }),
ep({ primary_rationale: { step: null, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [] } }),
];
const res = routerStepReached(eps);
expect(res.distribution['1']).toBe(2);
expect(res.distribution['3']).toBe(1);
expect(res.distribution['null']).toBe(1);
});
it('flags suspicious=true when >90% эпизодов остановились на step=1', () => {
const eps = Array.from({ length: 11 }, (_, i) =>
ep({ primary_rationale: { step: i === 10 ? 3 : 1, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [] } })
);
const res = routerStepReached(eps);
expect(res.suspicious).toBe(true);
});
it('suspicious=false when distribution более равномерное', () => {
const eps = [
ep({ primary_rationale: { step: 1, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [] } }),
ep({ primary_rationale: { step: 2, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [] } }),
ep({ primary_rationale: { step: 3, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [] } }),
];
const res = routerStepReached(eps);
expect(res.suspicious).toBe(false);
});
it('ignores v1 episodes and observer_error markers', () => {
const eps = [
{ schema_version: 1, primary_rationale: { step: 5 } },
{ observer_error: true },
ep({ primary_rationale: { step: 2, task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], boundaries_applied: [] } }),
];
const res = routerStepReached(eps);
expect(res.distribution).toEqual({ '2': 1 });
});
});
describe('boundariesAppliedRate', () => {
it('counts overall rate of boundaries applied', () => {
const eps = [
ep({ primary_rationale: { boundaries_applied: [{ adr: 'ADR-001' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
ep({ primary_rationale: { boundaries_applied: [], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
ep({ primary_rationale: { boundaries_applied: [{ adr: 'ADR-002' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
];
const res = boundariesAppliedRate(eps);
expect(res.total).toBe(3);
expect(res.withBoundaries).toBe(2);
expect(res.rate).toBeCloseTo(2 / 3);
});
it('splits by path_type', () => {
const eps = [
ep({ path_type: 'regulated', primary_rationale: { boundaries_applied: [{ adr: 'X' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
ep({ path_type: 'regulated', primary_rationale: { boundaries_applied: [], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
ep({ path_type: 'free', primary_rationale: { boundaries_applied: [{ adr: 'Y' }], task_classification: 'feature', node_chosen: 'direct', triggers_matched: [], step: 1 } }),
];
const res = boundariesAppliedRate(eps);
expect(res.byPathType.regulated.total).toBe(2);
expect(res.byPathType.regulated.withBoundaries).toBe(1);
expect(res.byPathType.free.total).toBe(1);
expect(res.byPathType.free.withBoundaries).toBe(1);
});
it('returns rate=0 on empty input', () => {
expect(boundariesAppliedRate([])).toEqual({
total: 0, withBoundaries: 0, rate: 0, byPathType: {},
});
});
});