// 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); }); });