Files
portal/tools/brain-retro-analyzer.test.mjs
T
Дмитрий 7fe9f89574 fix(observer): exclude hot/normative files from causal chains (A-3)
Bug: findCausalChains flagged a chain whenever two episodes shared any
file. CLAUDE.md / MEMORY.md / STATUS.md / episodes-YYYY-MM.jsonl /
memory/*.md are touched by almost every turn (memory store, status
regeneration, normative-doc updates) — sharing them is not evidence of
causality, just baseline noise. Result: spurious chains on hot files
crowded out the genuine signal.

Fix: HOT_FILE_PATTERNS regex list + `isHotFile(path)` predicate. In
findCausalChains, filter hot files out of BOTH the errored-episode file
set AND the candidate-shared list. If only hot files were shared → no
chain. If a non-hot file is also shared → the chain stands and the
sharedFiles list contains only the non-hot ones.

Tests: 4 new cases — CLAUDE.md / memory/*.md / episodes/STATUS/MEMORY
sharing yields no chain; a turn sharing both CLAUDE.md AND /src/app.ts
yields a chain with sharedFiles=['/src/app.ts'] only. 33/33 analyzer
tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:04:59 +03:00

217 lines
11 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import {
dedupeEpisodes,
inferOutcome,
groupEpisodesToTasks,
findCausalChains,
buildFactorMatrix,
analyze,
} from './brain-retro-analyzer.mjs';
// Minimal v2 episode for tests.
const ep = (overrides = {}) => ({
schema_version: 2,
task_id: 's1',
task_ref: 's1',
timestamps: { started_at: '2026-05-19T10:00:00Z', ended_at: '2026-05-19T10:05:00Z' },
path_type: 'regulated',
outcome: 'unknown',
prompt_signal: 'neutral',
decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null },
environment: { economy_level: 0, model: 'claude-opus-4-7', post_compaction: false, session_turn: 1, parallel_session: false },
task_size: { tool_calls: 5, files_touched: 1, files: ['/a.js'] },
primary_rationale: { step: 1, node_chosen: 'direct', triggers_matched: [], candidates_considered: [], boundaries_applied: [], hard_floor: { invoked: false, rules: [] }, task_classification: 'feature' },
events: [],
...overrides,
});
describe('dedupeEpisodes', () => {
it('keeps the last of two episodes with the same task_id + started_at', () => {
const a = ep({ outcome: 'unknown' });
const b = ep({ outcome: 'partial' }); // same task_id + started_at — routing-gate double-write
const out = dedupeEpisodes([a, b]);
expect(out).toHaveLength(1);
expect(out[0].outcome).toBe('partial');
});
it('keeps all observer_error markers', () => {
const out = dedupeEpisodes([ep(), { observer_error: true, task_id: 'e' }, { observer_error: true, task_id: 'e2' }]);
expect(out.filter((e) => e.observer_error)).toHaveLength(2);
});
});
describe('inferOutcome', () => {
it('infers rework when the next episode opens with a correction', () => {
expect(inferOutcome(ep(), ep({ prompt_signal: 'correction' }))).toBe('rework');
});
it('infers success when the next episode opens with approval', () => {
expect(inferOutcome(ep(), ep({ prompt_signal: 'approval' }))).toBe('success');
});
it('infers partial when the episode has an interrupt event', () => {
expect(inferOutcome(ep({ events: [{ kind: 'interrupt' }] }), ep())).toBe('partial');
});
it('infers unknown when there is no next episode', () => {
expect(inferOutcome(ep(), null)).toBe('unknown');
});
it('infers blocked ONLY when an unrecovered_error event is present (turn ended on error)', () => {
const blocked = ep({ events: [{ kind: 'error' }, { kind: 'error' }, { kind: 'unrecovered_error' }] });
expect(inferOutcome(blocked, ep({ prompt_signal: 'approval' }))).toBe('blocked');
});
it('does NOT infer blocked from raw error/retry count (TDD failing-test-first is not a block)', () => {
// A turn with N errors + N retries that ends on a successful tool_result —
// e.g., TDD red→green, or git command that legitimately fails then recovers —
// must NOT count as blocked. The parser emits unrecovered_error iff the LAST
// tool_result was is_error, which is absent here.
const recovered = ep({ events: [{ kind: 'error' }, { kind: 'error' }, { kind: 'retry' }] });
expect(inferOutcome(recovered, ep({ prompt_signal: 'approval' }))).toBe('success');
});
it('does not infer blocked when every error was retried', () => {
const recovered = ep({ events: [{ kind: 'error' }, { kind: 'retry' }] });
expect(inferOutcome(recovered, ep({ prompt_signal: 'approval' }))).toBe('success');
});
});
describe('groupEpisodesToTasks', () => {
it('starts a new task after a success and on a new_task prompt', () => {
const eps = [
ep({ timestamps: { started_at: '2026-05-19T10:00:00Z', ended_at: '2026-05-19T10:01:00Z' }, prompt_signal: 'new_task' }),
ep({ timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' }, prompt_signal: 'approval' }),
ep({ timestamps: { started_at: '2026-05-19T10:04:00Z', ended_at: '2026-05-19T10:05:00Z' }, prompt_signal: 'new_task' }),
];
const tasks = groupEpisodesToTasks(eps);
expect(tasks.length).toBeGreaterThanOrEqual(2);
});
});
describe('findCausalChains', () => {
it('links an errored episode to a later episode that shares a file', () => {
const a = ep({ timestamps: { started_at: '2026-05-19T10:00:00Z', ended_at: '2026-05-19T10:01:00Z' }, events: [{ kind: 'error', message: 'x' }], task_size: { tool_calls: 1, files_touched: 1, files: ['/shared.js'] } });
const b = ep({ timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' }, task_size: { tool_calls: 1, files_touched: 1, files: ['/shared.js'] } });
const chains = findCausalChains([a, b]);
expect(chains).toHaveLength(1);
expect(chains[0].sharedFiles).toEqual(['/shared.js']);
});
it('returns no chain when no files are shared', () => {
const a = ep({ events: [{ kind: 'error', message: 'x' }], task_size: { tool_calls: 1, files_touched: 1, files: ['/a.js'] } });
const b = ep({ timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' }, task_size: { tool_calls: 1, files_touched: 1, files: ['/b.js'] } });
expect(findCausalChains([a, b])).toHaveLength(0);
});
it('excludes hot/normative files (CLAUDE.md) from the shared-file signal', () => {
const a = ep({
events: [{ kind: 'error', message: 'x' }],
task_size: { tool_calls: 1, files_touched: 1, files: ['c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md'] },
});
const b = ep({
timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' },
task_size: { tool_calls: 1, files_touched: 1, files: ['c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md'] },
});
expect(findCausalChains([a, b])).toHaveLength(0);
});
it('excludes memory store .md files from the shared-file signal', () => {
const a = ep({
events: [{ kind: 'error', message: 'x' }],
task_size: { tool_calls: 1, files_touched: 1, files: ['C:\\Users\\Administrator\\.claude\\projects\\proj\\memory\\reference_github.md'] },
});
const b = ep({
timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' },
task_size: { tool_calls: 1, files_touched: 1, files: ['C:\\Users\\Administrator\\.claude\\projects\\proj\\memory\\reference_github.md'] },
});
expect(findCausalChains([a, b])).toHaveLength(0);
});
it('excludes episodes JSONL + STATUS.md + MEMORY.md from chains', () => {
const mk = (path, evts = []) =>
ep({
timestamps: { started_at: '2026-05-19T10:00:00Z', ended_at: '2026-05-19T10:01:00Z' },
events: evts,
task_size: { tool_calls: 1, files_touched: 1, files: [path] },
});
const later = (path) =>
ep({
timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' },
task_size: { tool_calls: 1, files_touched: 1, files: [path] },
});
const errored = [{ kind: 'error', message: 'x' }];
expect(findCausalChains([mk('/docs/observer/episodes-2026-05.jsonl', errored), later('/docs/observer/episodes-2026-05.jsonl')])).toHaveLength(0);
expect(findCausalChains([mk('/docs/observer/STATUS.md', errored), later('/docs/observer/STATUS.md')])).toHaveLength(0);
expect(findCausalChains([mk('/some/dir/MEMORY.md', errored), later('/some/dir/MEMORY.md')])).toHaveLength(0);
});
it('still links chains via genuinely-shared source files', () => {
const a = ep({
events: [{ kind: 'error', message: 'x' }],
task_size: { tool_calls: 1, files_touched: 2, files: ['c:\\path\\CLAUDE.md', '/src/app.ts'] },
});
const b = ep({
timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' },
task_size: { tool_calls: 1, files_touched: 2, files: ['c:\\path\\CLAUDE.md', '/src/app.ts'] },
});
const chains = findCausalChains([a, b]);
expect(chains).toHaveLength(1);
expect(chains[0].sharedFiles).toEqual(['/src/app.ts']);
});
});
describe('buildFactorMatrix', () => {
it('tabulates outcome distribution per factor value', () => {
const eps = [
{ ...ep(), _inferredOutcome: 'rework', decision_provenance: { kind: 'user_directed_method' } },
{ ...ep(), _inferredOutcome: 'success', decision_provenance: { kind: 'autonomous' } },
];
const m = buildFactorMatrix(eps);
expect(m.decision_provenance.user_directed_method.rework).toBe(1);
expect(m.decision_provenance.autonomous.success).toBe(1);
});
it('counts the 3rd kind user_chose_from_options on the provenance axis', () => {
const eps = [
{ ...ep(), _inferredOutcome: 'success', decision_provenance: { kind: 'autonomous' } },
{ ...ep(), _inferredOutcome: 'rework', decision_provenance: { kind: 'user_directed_method' } },
{ ...ep(), _inferredOutcome: 'success', decision_provenance: { kind: 'user_chose_from_options' } },
{ ...ep(), _inferredOutcome: 'rework', decision_provenance: { kind: 'user_chose_from_options' } },
];
const m = buildFactorMatrix(eps);
expect(m.decision_provenance).toHaveProperty('autonomous');
expect(m.decision_provenance).toHaveProperty('user_directed_method');
expect(m.decision_provenance).toHaveProperty('user_chose_from_options');
expect(m.decision_provenance.user_chose_from_options.success).toBe(1);
expect(m.decision_provenance.user_chose_from_options.rework).toBe(1);
});
it('includes session_turn (bucketed) and parallel_session factors', () => {
const eps = [
{ ...ep(), _inferredOutcome: 'success', environment: { session_turn: 3, parallel_session: false } },
{ ...ep(), _inferredOutcome: 'rework', environment: { session_turn: 120, parallel_session: true } },
];
const m = buildFactorMatrix(eps);
expect(m.session_turn.early.success).toBe(1);
expect(m.session_turn.late.rework).toBe(1);
expect(m.parallel_session.false.success).toBe(1);
expect(m.parallel_session.true.rework).toBe(1);
});
});
describe('analyze', () => {
it('returns episodeCount, tasks, causalChains and factorMatrix', () => {
const result = analyze([ep(), ep({ timestamps: { started_at: '2026-05-19T11:00:00Z', ended_at: '2026-05-19T11:01:00Z' }, prompt_signal: 'correction' })]);
expect(result.episodeCount).toBe(2);
expect(result.factorMatrix).toBeDefined();
expect(Array.isArray(result.tasks)).toBe(true);
expect(Array.isArray(result.causalChains)).toBe(true);
});
it('skips v1 episodes (no schema_version 2) from the analysis', () => {
const v1 = { task_id: 's-old', timestamps: { started_at: '2026-05-19T09:00:00Z' }, outcome: 'success' };
const result = analyze([
v1,
ep(),
ep({ timestamps: { started_at: '2026-05-19T11:00:00Z', ended_at: '2026-05-19T11:01:00Z' } }),
]);
expect(result.episodeCount).toBe(2);
expect(result.v1SkippedCount).toBe(1);
});
});