import { describe, it, expect } from 'vitest'; import { VERITY_SLOTS, DISCREPANCY_KINDS, validateVerityTrace, groundVerityTrace, buildVerityPrompt, runVerityCheck, } from './router-verity.mjs'; const OK_TRACE = { owner_asks: [ { ask: 'начать с входа роутера', source: 'prompt' }, { ask: 'без субагентов, inline', source: 0 }, ], discrepancies: [ { kind: 'missing', detail: 'inline-режим в плане не отражён', source: 0 }, ], }; describe('VERITY_SLOTS / DISCREPANCY_KINDS — закрытые наборы (§2.4 / §6.2)', () => { it('обязательные слоты анти-внушения', () => { expect(VERITY_SLOTS).toContain('owner_asks'); expect(VERITY_SLOTS).toContain('discrepancies'); }); it('виды расхождения — закрытый список', () => { expect(DISCREPANCY_KINDS).toEqual(['missing', 'distorted']); }); }); describe('validateVerityTrace (механический валидатор формы; пустой слот = флаг)', () => { it('полная корректная трасса → ok', () => { expect(validateVerityTrace(OK_TRACE)).toEqual({ ok: true, missingSlots: [], badItems: [] }); }); it('пустой owner_asks → невалидно (анти-внушение: независимый вывод обязателен)', () => { const r = validateVerityTrace({ ...OK_TRACE, owner_asks: [] }); expect(r.ok).toBe(false); expect(r.missingSlots).toContain('owner_asks'); }); it('discrepancies отсутствует (не массив) → невалидно', () => { const r = validateVerityTrace({ owner_asks: OK_TRACE.owner_asks }); expect(r.ok).toBe(false); expect(r.missingSlots).toContain('discrepancies'); }); it('пустой discrepancies (массив) → ВАЛИДНО (план верен — расхождений нет)', () => { expect(validateVerityTrace({ owner_asks: OK_TRACE.owner_asks, discrepancies: [] }).ok).toBe(true); }); it('пункт owner_asks без source → badItems', () => { const r = validateVerityTrace({ ...OK_TRACE, owner_asks: [{ ask: 'x' }] }); expect(r.ok).toBe(false); expect(r.badItems).toContain('owner_asks[0]'); }); it('пункт discrepancies со свободным kind → badItems (только закрытый список)', () => { const r = validateVerityTrace({ ...OK_TRACE, discrepancies: [{ kind: 'maybe', detail: 'd', source: 0 }] }); expect(r.ok).toBe(false); expect(r.badItems).toContain('discrepancies[0]'); }); it('не объект → невалидно, все слоты missing', () => { expect(validateVerityTrace(null).ok).toBe(false); expect(validateVerityTrace('x').missingSlots).toEqual([...VERITY_SLOTS]); }); }); describe('groundVerityTrace (§2.2 — source резолвится: prompt | индекс ответа в диапазоне)', () => { const trace = { owner_asks: [{ ask: 'a', source: 'prompt' }, { ask: 'b', source: 1 }], discrepancies: [{ kind: 'missing', detail: 'd', source: 0 }], }; it('все источники в диапазоне → grounded', () => { expect(groundVerityTrace(trace, { answersCount: 2 })).toEqual({ grounded: true, dangling: [] }); }); it('ссылка на несуществующий ответ → dangling (роутер сослался мимо истории)', () => { const r = groundVerityTrace(trace, { answersCount: 1 }); // индекс 1 вне [0,1) expect(r.grounded).toBe(false); expect(r.dangling.some((d) => d.includes('owner_asks[1]'))).toBe(true); }); it('источник из discrepancies вне диапазона → dangling', () => { const t = { owner_asks: [{ ask: 'a', source: 'prompt' }], discrepancies: [{ kind: 'missing', detail: 'd', source: 5 }] }; expect(groundVerityTrace(t, { answersCount: 2 }).grounded).toBe(false); }); it('prompt всегда валиден даже при нулевой истории', () => { const t = { owner_asks: [{ ask: 'a', source: 'prompt' }], discrepancies: [] }; expect(groundVerityTrace(t, { answersCount: 0 }).grounded).toBe(true); }); }); describe('buildVerityPrompt (чистая {system,user}; анти-внушение §2.4 зашит порядком+физикой)', () => { const base = { rawPrompt: 'начни с входа роутера', answers: ['inline без субагентов', 'граф решений отдельно'], plan: 'план: правлю router-engine' }; it('детерминирована: тот же вход → тот же текст', () => { const a = buildVerityPrompt(base); const b = buildVerityPrompt(base); expect(a.system).toBe(b.system); expect(a.user).toBe(b.user); }); it('system: инструкция порядка — сперва owner_asks из сырых слов, ПОТОМ discrepancies', () => { const { system } = buildVerityPrompt(base); const iAsks = system.indexOf('owner_asks'); const iDisc = system.indexOf('discrepancies'); expect(iAsks).toBeGreaterThanOrEqual(0); expect(iAsks).toBeLessThan(iDisc); // порядок инструкций expect(system).toMatch(/анти-внушени|не поддакивай/i); }); it('user: сырые слова владельца ФИЗИЧЕСКИ перед планом', () => { const { user } = buildVerityPrompt(base); const iRaw = user.indexOf('СЫРОЙ ПРОМПТ'); const iPlan = user.indexOf('ПЛАН КОНТРОЛЛЕРА'); expect(iRaw).toBeGreaterThanOrEqual(0); expect(iRaw).toBeLessThan(iPlan); }); it('user: ВСЯ история ответов нумеруется по порядку (0..N-1)', () => { const { user } = buildVerityPrompt(base); expect(user).toMatch(/Ответ 0: inline без субагентов/); expect(user).toMatch(/Ответ 1: граф решений отдельно/); }); it('пустая история → «(ответов пока нет)», план может отсутствовать', () => { const { user } = buildVerityPrompt({ rawPrompt: 'старт', answers: [], plan: null }); expect(user).toMatch(/ответов пока нет/); expect(user).toMatch(/плана пока нет/); }); it('закрытый список kind в инструкции вывода', () => { expect(buildVerityPrompt(base).system).toMatch(/missing.*distorted|distorted.*missing/); }); }); describe('runVerityCheck (оркестратор; llmCall мокается; faithful — кодом из discrepancies)', () => { const okTrace = { owner_asks: [{ ask: 'вход роутера первым', source: 'prompt' }], discrepancies: [], }; const base = { rawPrompt: 'вход роутера первым', answers: [], plan: 'план' }; it('валидная заземлённая трасса без расхождений → ok + faithful=true', async () => { const r = await runVerityCheck({ ...base, llmCall: async () => okTrace }); expect(r.ok).toBe(true); expect(r.faithful).toBe(true); expect(r.discrepancies).toEqual([]); }); it('есть расхождение → ok + faithful=false (код считает, не роутер)', async () => { const trace = { owner_asks: okTrace.owner_asks, discrepancies: [{ kind: 'missing', detail: 'inline не отражён', source: 0 }] }; const r = await runVerityCheck({ rawPrompt: 'x', answers: ['inline'], plan: 'p', llmCall: async () => trace }); expect(r.ok).toBe(true); expect(r.faithful).toBe(false); expect(r.discrepancies.length).toBe(1); }); it('пустой owner_asks (поддакнул не выводя) → ok=false, не результат', async () => { const r = await runVerityCheck({ ...base, llmCall: async () => ({ owner_asks: [], discrepancies: [] }) }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/слот|owner_asks|невалид/i); }); it('ссылка на несуществующий ответ → ok=false (заземление)', async () => { const trace = { owner_asks: [{ ask: 'a', source: 3 }], discrepancies: [] }; const r = await runVerityCheck({ rawPrompt: 'x', answers: ['one'], plan: 'p', llmCall: async () => trace }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/висящ|источник|dangling/i); }); it('llmCall вернул строку с JSON-fence → парсится (DRY parseRouterResponse)', async () => { const r = await runVerityCheck({ ...base, llmCall: async () => '```json\n{"owner_asks":[{"ask":"a","source":"prompt"}],"discrepancies":[]}\n```' }); expect(r.ok).toBe(true); expect(r.faithful).toBe(true); }); it('llmCall вернул мусор → ok=false (не падает)', async () => { const r = await runVerityCheck({ ...base, llmCall: async () => null }); expect(r.ok).toBe(false); }); it('llmCall кинул исключение → ok=false, reason про сбой', async () => { const r = await runVerityCheck({ ...base, llmCall: async () => { throw new Error('network'); } }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/сбой|модел/i); }); });