397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
5.8 KiB
JavaScript
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);
|
|
});
|
|
});
|