feat(brain): aggregator — node heat, distributions, redirect rate (+4 tests)

This commit is contained in:
Дмитрий
2026-05-19 16:10:09 +03:00
parent c1b690edd3
commit 774763c21c
2 changed files with 66 additions and 1 deletions
+35
View File
@@ -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;
+31 -1
View File
@@ -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);
});
});