diff --git a/tools/brain-retro-analyzer.mjs b/tools/brain-retro-analyzer.mjs index dc2588a3..457933cb 100644 --- a/tools/brain-retro-analyzer.mjs +++ b/tools/brain-retro-analyzer.mjs @@ -219,6 +219,43 @@ export function analyze(episodes, options = {}) { const disciplineByClassification = disciplinePercentByClassification(normal, classificationMap); const routerStep = routerStepReached(normal); const boundariesRate = boundariesAppliedRate(normal); + + // Phase 3 Task 20 — v4 aggregation: inheritance count + reviewer outcome + // distribution + cost totals. Reads schema_version >=4 fields gracefully. + let inheritanceCount = 0; + const reviewQuality = { correct: 0, wrong_node: 0, overkill: 0, underkill: 0, disputable: 0 }; + const reviewerCoverage = { reviewed: 0, pending: 0, errored: 0 }; + let degradedCount = 0; + const costTotals = { + classifier_input_tokens: 0, + classifier_output_tokens: 0, + self_assessment_input_tokens: 0, + self_assessment_output_tokens: 0, + reviewer_input_tokens: 0, + reviewer_output_tokens: 0, + }; + for (const e of normal) { + if (e?.inheritance?.inherited_from_task_id) inheritanceCount += 1; + if (e?.degraded_mode === true) degradedCount += 1; + const r = e?.review; + if (r && typeof r === 'object') { + if (r.reviewer_error) reviewerCoverage.errored += 1; + else if (typeof r.node_quality === 'string') { + reviewerCoverage.reviewed += 1; + if (reviewQuality[r.node_quality] !== undefined) reviewQuality[r.node_quality] += 1; + } + } else if (e?.schema_version >= 4) { + reviewerCoverage.pending += 1; + } + const tc = e?.task_cost; + if (tc && typeof tc === 'object') { + for (const k of Object.keys(costTotals)) { + const v = tc[k]; + if (typeof v === 'number' && Number.isFinite(v)) costTotals[k] += v; + } + } + } + return { episodeCount: normal.length, v1SkippedCount, @@ -230,6 +267,11 @@ export function analyze(episodes, options = {}) { disciplineByClassification, routerStep, boundariesRate, + inheritanceCount, + reviewQuality, + reviewerCoverage, + degradedCount, + costTotals, }; } diff --git a/tools/brain-retro-analyzer.test.mjs b/tools/brain-retro-analyzer.test.mjs index 48f19f9c..ffd63b6c 100644 --- a/tools/brain-retro-analyzer.test.mjs +++ b/tools/brain-retro-analyzer.test.mjs @@ -357,3 +357,55 @@ describe('analyze — discipline metrics (stage 2)', () => { expect(res.boundariesRate.rate).toBeCloseTo(0.5); }); }); + +describe('analyze — v4 aggregations (Phase 3 Task 20)', () => { + it('aggregates inheritanceCount across v4 episodes', () => { + const eps = [ + ep({ schema_version: 4, inheritance: { inherited_from_task_id: 'x' } }), + ep({ schema_version: 4, timestamps: { started_at: '2026-05-19T10:01:00Z', ended_at: '2026-05-19T10:02:00Z' }, inheritance: { inherited_from_task_id: 'y' } }), + ep({ schema_version: 4, timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' } }), + ]; + expect(analyze(eps).inheritanceCount).toBe(2); + }); + + it('aggregates reviewQuality distribution from review.node_quality', () => { + const eps = [ + ep({ schema_version: 4, review: { node_quality: 'correct' } }), + ep({ schema_version: 4, timestamps: { started_at: '2026-05-19T10:01:00Z', ended_at: '2026-05-19T10:02:00Z' }, review: { node_quality: 'correct' } }), + ep({ schema_version: 4, timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' }, review: { node_quality: 'wrong_node' } }), + ]; + const res = analyze(eps); + expect(res.reviewQuality.correct).toBe(2); + expect(res.reviewQuality.wrong_node).toBe(1); + expect(res.reviewerCoverage.reviewed).toBe(3); + }); + + it('counts review pending for v4 episodes without a review block', () => { + const eps = [ep({ schema_version: 4 })]; + expect(analyze(eps).reviewerCoverage.pending).toBe(1); + }); + + it('counts reviewer_error escalations under reviewerCoverage.errored', () => { + const eps = [ep({ schema_version: 4, review: { reviewer_error: 'malformed episode' } })]; + expect(analyze(eps).reviewerCoverage.errored).toBe(1); + }); + + it('aggregates degradedCount on degraded_mode=true', () => { + const eps = [ + ep({ schema_version: 4, degraded_mode: true }), + ep({ schema_version: 4, timestamps: { started_at: '2026-05-19T10:01:00Z', ended_at: '2026-05-19T10:02:00Z' }, degraded_mode: false }), + ]; + expect(analyze(eps).degradedCount).toBe(1); + }); + + it('sums task_cost tokens into costTotals', () => { + const eps = [ + ep({ schema_version: 4, task_cost: { classifier_input_tokens: 100, classifier_output_tokens: 30 } }), + ep({ schema_version: 4, timestamps: { started_at: '2026-05-19T10:01:00Z', ended_at: '2026-05-19T10:02:00Z' }, task_cost: { classifier_input_tokens: 200, reviewer_input_tokens: 500 } }), + ]; + const ct = analyze(eps).costTotals; + expect(ct.classifier_input_tokens).toBe(300); + expect(ct.classifier_output_tokens).toBe(30); + expect(ct.reviewer_input_tokens).toBe(500); + }); +}); diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index 46911d4a..295fc8b5 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -192,7 +192,7 @@ export function buildExecutionTrace({ recommended_chain = [], invoked = [] } = { */ export function buildEpisode({ state = null, transcriptText = null, ctx = {} } = {}) { const base = buildEpisodeFromContext(ctx, transcriptText); - base.schema_minor = 2; // Task 17 bump (self_assessment surface added) + base.schema_minor = 3; // Task 20 bump (cost totals + reviewer distribution surface) if (state?.inheritance) { base.inheritance = { ...state.inheritance }; } diff --git a/tools/observer-stop-hook.test.mjs b/tools/observer-stop-hook.test.mjs index fa34904b..fbcc09dd 100644 --- a/tools/observer-stop-hook.test.mjs +++ b/tools/observer-stop-hook.test.mjs @@ -206,10 +206,10 @@ describe('buildExecutionTrace + buildEpisode — Phase 3 Task 16 (spec §5)', () expect(ep.inheritance).toBeUndefined(); }); - it('buildEpisode marks schema_minor=2 (Task 17 bump)', () => { + it('buildEpisode marks schema_minor=3 (Task 20 bump)', () => { const ep = buildEpisode({ state: {}, ctx: { session_id: 'sess-x' } }); expect(ep.schema_version).toBe(4); - expect(ep.schema_minor).toBe(2); + expect(ep.schema_minor).toBe(3); }); });