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:
@@ -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 };
|
||||
}
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user