diff --git a/tools/discipline-metrics.mjs b/tools/discipline-metrics.mjs new file mode 100644 index 00000000..a0c8b7f0 --- /dev/null +++ b/tools/discipline-metrics.mjs @@ -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 }; +} diff --git a/tools/discipline-metrics.test.mjs b/tools/discipline-metrics.test.mjs new file mode 100644 index 00000000..295ea2b1 --- /dev/null +++ b/tools/discipline-metrics.test.mjs @@ -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: {}, + }); + }); +});