diff --git a/docs/observer/dashboard-core.js b/docs/observer/dashboard-core.js index 144bb622..e57d02f8 100644 --- a/docs/observer/dashboard-core.js +++ b/docs/observer/dashboard-core.js @@ -137,6 +137,41 @@ export function filterEpisodes(episodes, filter = {}) { }); } +// Aggregates a list of episodes into dashboard metrics. +export function aggregate(episodes) { + const nodeHeat = {}; + const pathType = {}; + const outcome = {}; + const classification = {}; + const economy = {}; + let totalErrors = 0; + let totalRetries = 0; + let redirects = 0; + for (const e of episodes) { + for (const id of attributeNodes(e).nodeIds) nodeHeat[id] = (nodeHeat[id] || 0) + 1; + if (e.pathType) pathType[e.pathType] = (pathType[e.pathType] || 0) + 1; + outcome[e.outcome] = (outcome[e.outcome] || 0) + 1; + if (e.taskClassification) classification[e.taskClassification] = (classification[e.taskClassification] || 0) + 1; + const lvl = e.environment ? e.environment.economy_level : null; + const key = lvl == null ? 'n/a' : String(lvl); + economy[key] = (economy[key] || 0) + 1; + totalErrors += e.errorCount; + totalRetries += e.retryCount; + if (e.decisionProvenance && e.decisionProvenance.kind === 'user_directed_method') redirects++; + } + return { + nodeHeat, + pathType, + outcome, + classification, + economy, + totalErrors, + totalRetries, + redirectRate: episodes.length ? redirects / episodes.length : 0, + count: episodes.length, + }; +} + export function parseEpisodes(text) { const episodes = []; let skipped = 0; diff --git a/tools/brain-dashboard-core.test.mjs b/tools/brain-dashboard-core.test.mjs index 1b9a2c8d..6469763b 100644 --- a/tools/brain-dashboard-core.test.mjs +++ b/tools/brain-dashboard-core.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseEpisodes, normalizeEpisode, attributeNodes, filterEpisodes, groupBySession } from '../docs/observer/dashboard-core.js'; +import { parseEpisodes, normalizeEpisode, attributeNodes, filterEpisodes, groupBySession, aggregate } from '../docs/observer/dashboard-core.js'; const v1 = { task_id: 'a', timestamps: { started_at: '2026-05-19T05:18:16.342Z', ended_at: '2026-05-19T06:05:55.439Z' }, @@ -138,3 +138,33 @@ describe('groupBySession', () => { expect(groups[0].taskRef).toBe('S'); }); }); + +describe('aggregate', () => { + const mk = (over) => normalizeEpisode({ ...v2, ...over }); + it('counts node heat from attributed nodes', () => { + const list = [ + mk({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }), + mk({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }), + ]; + expect(aggregate(list).nodeHeat.sk_wplans).toBe(2); + }); + it('computes redirect rate', () => { + const list = [ + mk({ decision_provenance: { kind: 'user_directed_method', claude_would_have_chosen: 'x' } }), + mk({ decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null } }), + ]; + expect(aggregate(list).redirectRate).toBe(0.5); + }); + it('tallies path_type and outcome distributions', () => { + const list = [mk({ path_type: 'improvised', outcome: 'unknown' }), mk({ path_type: 'regulated', outcome: 'success' })]; + const a = aggregate(list); + expect(a.pathType).toEqual({ improvised: 1, regulated: 1 }); + expect(a.outcome).toEqual({ unknown: 1, success: 1 }); + }); + it('reports total error and retry counts', () => { + const list = [mk({ events: [{ kind: 'error', message: 'e' }, { kind: 'retry' }] })]; + const a = aggregate(list); + expect(a.totalErrors).toBe(1); + expect(a.totalRetries).toBe(1); + }); +});