397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
8.3 KiB
JavaScript
185 lines
8.3 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
disciplinePercentByClassification,
|
|
routerStepReached,
|
|
deriveRouterStep,
|
|
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('deriveRouterStep', () => {
|
|
// Маппинг наблюдаемых признаков primary_rationale → шаг router-procedure.md
|
|
// (1 hard-floor → 2 классификация → 3 триггеры → 4 цепочка → 5 исполнение узла).
|
|
// Берётся МАКСИМУМ достигнутой стадии. Хранимое pr.step игнорируется.
|
|
it('returns 1 for a bare direct episode (hard-floor only, no signals)', () => {
|
|
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: [], node_chosen: 'direct' })).toBe(1);
|
|
});
|
|
|
|
it('returns 2 when a real task_classification was produced', () => {
|
|
expect(deriveRouterStep({ task_classification: 'feature', triggers_matched: [], chain_ref: [], node_chosen: 'direct' })).toBe(2);
|
|
});
|
|
|
|
it("treats 'other' classification as not reaching step 2", () => {
|
|
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: null, node_chosen: 'direct' })).toBe(1);
|
|
});
|
|
|
|
it('returns 3 when triggers matched', () => {
|
|
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [{ keyword: 'x' }], chain_ref: [], node_chosen: 'direct' })).toBe(3);
|
|
});
|
|
|
|
it('returns 4 when a chain was referenced (array or non-empty string)', () => {
|
|
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: ['routing-off-phase L1'], node_chosen: 'direct' })).toBe(4);
|
|
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: 'L1', node_chosen: 'direct' })).toBe(4);
|
|
});
|
|
|
|
it('returns 5 when a node was actually chosen (execution)', () => {
|
|
expect(deriveRouterStep({ task_classification: 'other', triggers_matched: [], chain_ref: [], node_chosen: '#19' })).toBe(5);
|
|
});
|
|
|
|
it('takes the furthest stage reached (max), not the first', () => {
|
|
expect(deriveRouterStep({ task_classification: 'feature', triggers_matched: [{ k: 1 }], chain_ref: [], node_chosen: '#19' })).toBe(5);
|
|
});
|
|
|
|
it('handles a missing/empty primary_rationale → 1', () => {
|
|
expect(deriveRouterStep(undefined)).toBe(1);
|
|
expect(deriveRouterStep({})).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('routerStepReached (derived from observable signals)', () => {
|
|
// Признаковые шаблоны (хранимый step специально проставлен 1/99 — должен игнорироваться).
|
|
const at = {
|
|
1: { task_classification: 'other', triggers_matched: [], chain_ref: [], node_chosen: 'direct' },
|
|
3: { task_classification: 'other', triggers_matched: [{ k: 1 }], chain_ref: [], node_chosen: 'direct' },
|
|
5: { task_classification: 'feature', triggers_matched: [], chain_ref: [], node_chosen: '#19' },
|
|
};
|
|
|
|
it('counts episodes by derived step, ignoring any stored pr.step value', () => {
|
|
const eps = [
|
|
ep({ primary_rationale: { ...at[1], step: 1 } }),
|
|
ep({ primary_rationale: { ...at[1], step: 99 } }),
|
|
ep({ primary_rationale: { ...at[3], step: 1 } }),
|
|
ep({ primary_rationale: { ...at[5], step: 1 } }),
|
|
];
|
|
const res = routerStepReached(eps);
|
|
expect(res.distribution['1']).toBe(2);
|
|
expect(res.distribution['3']).toBe(1);
|
|
expect(res.distribution['5']).toBe(1);
|
|
expect(res.total).toBe(4);
|
|
});
|
|
|
|
it('flags suspicious=true when >90% эпизодов выводятся в step 1', () => {
|
|
const eps = Array.from({ length: 11 }, (_, i) =>
|
|
ep({ primary_rationale: i === 10 ? { ...at[3], step: 1 } : { ...at[1], step: 1 } })
|
|
);
|
|
expect(routerStepReached(eps).suspicious).toBe(true);
|
|
});
|
|
|
|
it('suspicious=false when distribution более равномерное', () => {
|
|
const eps = [
|
|
ep({ primary_rationale: { ...at[1], step: 1 } }),
|
|
ep({ primary_rationale: { ...at[3], step: 1 } }),
|
|
ep({ primary_rationale: { ...at[5], step: 1 } }),
|
|
];
|
|
expect(routerStepReached(eps).suspicious).toBe(false);
|
|
});
|
|
|
|
it('ignores v1 episodes and observer_error markers', () => {
|
|
const eps = [
|
|
{ schema_version: 1, primary_rationale: { ...at[5] } },
|
|
{ observer_error: true },
|
|
ep({ primary_rationale: { ...at[3], step: 1 } }),
|
|
];
|
|
const res = routerStepReached(eps);
|
|
expect(res.distribution).toEqual({ '3': 1 });
|
|
expect(res.total).toBe(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: {},
|
|
});
|
|
});
|
|
});
|