Files
brain/tools/decomposition-detector.test.mjs

142 lines
5.8 KiB
JavaScript

// tools/decomposition-detector.test.mjs
import { describe, it, expect } from 'vitest';
import {
V4_1_DECOMP_THRESHOLD, keywordIntersection, appendHistory,
detectDecompositionCandidate, decideDecomposition, isResetMarker,
} from './decomposition-detector.mjs';
function entry(idx, kws, skill = false) {
return {
prompt_idx: idx, ts: '2026-05-29T00:00:00Z', task_type: 'bugfix',
primary_keywords: kws, task_summary: `t${idx}`, skill_invoked_this_prompt: skill,
};
}
// ── Step 1 initial batch ──────────────────────────────────────────────────────
describe('keywordIntersection', () => {
it('counts shared keywords', () => {
expect(keywordIntersection(['a', 'b', 'c'], ['b', 'c', 'd'])).toBe(2);
});
});
describe('detectDecompositionCandidate — v4.1 3+ threshold', () => {
it('flags candidate at 3 overlapping prompts (>=3 keyword intersection) no skill', () => {
const hist = [
entry(1, ['router', 'gate', 'hook']),
entry(2, ['router', 'gate', 'hook']),
entry(3, ['router', 'gate', 'hook']),
];
const cur = entry(4, ['router', 'gate', 'hook']);
const r = detectDecompositionCandidate(hist, cur);
expect(r.candidate).toBe(true);
expect(r.overlappingPrompts.length).toBe(3);
});
it('does NOT flag with only 2 overlapping', () => {
const hist = [entry(1, ['router', 'gate', 'hook']), entry(2, ['router', 'gate', 'hook'])];
const cur = entry(3, ['router', 'gate', 'hook']);
expect(detectDecompositionCandidate(hist, cur).candidate).toBe(false);
});
it('does NOT flag when a skill was invoked among them', () => {
const hist = [
entry(1, ['router', 'gate', 'hook']),
entry(2, ['router', 'gate', 'hook'], true), // skill invoked
entry(3, ['router', 'gate', 'hook']),
];
const cur = entry(4, ['router', 'gate', 'hook']);
expect(detectDecompositionCandidate(hist, cur).candidate).toBe(false);
});
it('does NOT flag when keyword intersection <3', () => {
const hist = [entry(1, ['router', 'gate']), entry(2, ['router', 'gate']), entry(3, ['router', 'gate'])];
const cur = entry(4, ['router', 'gate']); // only 2 shared
expect(detectDecompositionCandidate(hist, cur).candidate).toBe(false);
});
});
// ── Step 5 remaining cases ────────────────────────────────────────────────────
describe('appendHistory', () => {
it('appends an entry and returns a new array; original unmutated', () => {
const orig = [];
const next = appendHistory(orig, entry(1, ['a']));
expect(next.length).toBe(1);
expect(orig.length).toBe(0); // immutable
});
});
describe('detectDecompositionCandidate — window', () => {
it('slices to last 10 when history is 15 entries, overlappingPrompts.length === 10', () => {
const hist = Array.from({ length: 15 }, (_, i) => entry(i + 1, ['router', 'gate', 'hook']));
const cur = entry(16, ['router', 'gate', 'hook']);
const r = detectDecompositionCandidate(hist, cur);
expect(r.candidate).toBe(true);
expect(r.overlappingPrompts.length).toBe(10);
});
it('finds the 3 overlapping among mixed history, ignores unrelated', () => {
const hist = [
entry(1, ['x', 'y', 'z']),
entry(2, ['x', 'y', 'z']),
entry(3, ['a', 'b', 'c']),
entry(4, ['x', 'y', 'z']),
entry(5, ['a', 'b', 'c']),
];
const cur = entry(6, ['x', 'y', 'z']);
const r = detectDecompositionCandidate(hist, cur);
expect(r.candidate).toBe(true);
expect(r.overlappingPrompts).toEqual([1, 2, 4]);
});
it('overlappingKeywords correctness: keywords in current present in EVERY overlapping entry', () => {
const hist = [
entry(1, ['x', 'y', 'z', 'q']),
entry(2, ['x', 'y', 'z', 'q']),
entry(3, ['x', 'y', 'z', 'q']),
];
const cur = entry(4, ['x', 'y', 'z']); // 'q' not in cur — only x,y,z
const r = detectDecompositionCandidate(hist, cur);
expect(r.candidate).toBe(true);
expect(r.overlappingKeywords.sort()).toEqual(['x', 'y', 'z']);
});
});
describe('decideDecomposition', () => {
it('returns allow when candidate is false', () => {
expect(decideDecomposition({ candidate: false }, 'YES').action).toBe('allow');
});
it('returns hard_block_mutating when candidate true and LLM verdict YES', () => {
expect(decideDecomposition({ candidate: true, reason: 'r' }, 'YES').action).toBe('hard_block_mutating');
});
it('returns soft_flag when candidate true and LLM verdict NO', () => {
expect(decideDecomposition({ candidate: true, reason: 'r' }, 'NO').action).toBe('soft_flag');
});
it('accepts object verdict {verdict:"YES"} and returns hard_block_mutating', () => {
expect(decideDecomposition({ candidate: true, reason: 'r' }, { verdict: 'YES' }).action).toBe('hard_block_mutating');
});
it('returns soft_flag when hard_block_mutating:false in threshold even with YES verdict', () => {
const threshold = { ...V4_1_DECOMP_THRESHOLD, hard_block_mutating: false };
expect(decideDecomposition({ candidate: true, reason: 'r' }, 'YES', threshold).action).toBe('soft_flag');
});
});
describe('isResetMarker re-export', () => {
it('isResetMarker("новая задача") is true (re-exported from safe-baseline)', () => {
expect(isResetMarker('новая задача')).toBe(true);
});
});
describe('detectDecompositionCandidate — skill in current only', () => {
it('does NOT flag when skill invoked in the current entry only', () => {
const hist = [entry(1, ['router', 'gate', 'hook']), entry(2, ['router', 'gate', 'hook']), entry(3, ['router', 'gate', 'hook'])];
const cur = entry(4, ['router', 'gate', 'hook'], true); // skill in current
expect(detectDecompositionCandidate(hist, cur).candidate).toBe(false);
});
});