Files
gbrain/test/auto-think-phase.test.ts
40d9b83d5c fix(cycle): wire drift detection into the dream cycle — report-only v1 (#2653) (#3317)
dream.drift.enabled has gated an unwired scaffold since v0.28: runPhaseDrift
had zero call sites, the resolved model + BudgetMeter were discarded
(void modelId; void meter), and no operator-readable output existed.

- Wire 'drift' as a CyclePhase (default OFF via dream.drift.enabled),
  ordered after the calibration trio and before embed so the report page
  gets embedded same-cycle. PHASE_SCOPE=global, cycle-lock coordinated,
  --once (--phase drift --once) bypasses the gate for one run.
- Implement the LLM judge: soft-band candidates (weight 0.3-0.85, active,
  unresolved, fresh timeline evidence) are judged against their page's
  recent timeline entries via gateway chat; BudgetMeter-gated
  (dream.drift.budget, default $1), capped by dream.drift.max_per_cycle
  (default 20). Judge model resolves models.drift -> reasoning tier ->
  sonnet fallback (unchanged from scaffold).
- Report-only v1: judged candidates land on a reports/drift-<date> page.
  dream.drift.auto_update mutates NOTHING; the flag state is recorded in
  the report for operators.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:28:47 -07:00

272 lines
12 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runPhaseAutoThink } from '../src/core/cycle/auto-think.ts';
import { runPhaseDrift, parseDriftOutput, __testing as driftTesting } from '../src/core/cycle/drift.ts';
import { _resetBudgetMeterWarningsForTest } from '../src/core/cycle/budget-meter.ts';
import type { ThinkLLMClient } from '../src/core/think/index.ts';
let engine: PGLiteEngine;
let alicePageId: number;
let tmpDir: string;
function makeStubClient(answer: string): ThinkLLMClient {
return {
create: async () => ({
id: 'msg_stub',
type: 'message',
role: 'assistant',
model: 'stub',
stop_reason: 'end_turn',
stop_sequence: null,
usage: { input_tokens: 10, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, server_tool_use: null, service_tier: null },
content: [{ type: 'text', text: JSON.stringify({ answer, citations: [], gaps: [] }) }],
}),
};
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
const alice = await engine.putPage('people/alice-example', {
title: 'Alice', type: 'person', compiled_truth: 'Alice content',
});
alicePageId = alice.id;
// Add takes spanning the soft band so drift candidates exist
await engine.addTakesBatch([
{ page_id: alicePageId, row_num: 1, claim: 'CEO of Acme', kind: 'fact', holder: 'world', weight: 1.0 },
{ page_id: alicePageId, row_num: 2, claim: 'Strong technical founder', kind: 'take', holder: 'garry', weight: 0.6 },
{ page_id: alicePageId, row_num: 3, claim: 'Will reach $50B', kind: 'bet', holder: 'garry', weight: 0.5 },
]);
// Add timeline entries to give drift candidates "recent evidence"
await engine.addTimelineEntriesBatch([
{ slug: 'people/alice-example', date: new Date().toISOString().slice(0, 10), source: 'crustdata', summary: 'Funding round closed' },
{ slug: 'people/alice-example', date: new Date().toISOString().slice(0, 10), source: 'meeting', summary: 'OH discussion' },
]);
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(() => {
_resetBudgetMeterWarningsForTest();
tmpDir = mkdtempSync(join(tmpdir(), 'auto-think-'));
});
describe('runPhaseAutoThink', () => {
test('skipped when not enabled', async () => {
const r = await runPhaseAutoThink(engine, { dryRun: false, auditPath: join(tmpDir, 'budget.jsonl') });
expect(r.status).toBe('skipped');
expect(r.detail).toContain('false');
});
test('skipped when enabled but no questions', async () => {
await engine.setConfig('dream.auto_think.enabled', 'true');
await engine.setConfig('dream.auto_think.questions', '[]');
const r = await runPhaseAutoThink(engine, { dryRun: false, auditPath: join(tmpDir, 'b1.jsonl') });
expect(r.status).toBe('skipped');
expect(r.detail).toContain('empty');
await engine.setConfig('dream.auto_think.enabled', 'false');
});
test('runs when enabled with questions, marks success on cooldown ts', async () => {
await engine.setConfig('dream.auto_think.enabled', 'true');
await engine.setConfig('dream.auto_think.questions', JSON.stringify(['What about technical founders?']));
await engine.setConfig('dream.auto_think.max_per_cycle', '1');
await engine.setConfig('dream.auto_think.budget', '10.0');
await engine.setConfig('dream.auto_think.auto_commit', 'false');
// Clear any prior cooldown
await engine.setConfig('dream.auto_think.last_completion_ts', '');
const r = await runPhaseAutoThink(engine, {
dryRun: false,
client: makeStubClient('Alice [people/alice-example#2] is a strong founder.'),
auditPath: join(tmpDir, 'b2.jsonl'),
});
expect(r.status).toBe('complete');
expect((r.totals as { synthesized?: number }).synthesized).toBe(1);
const ts = await engine.getConfig('dream.auto_think.last_completion_ts');
expect(ts).toBeTruthy();
expect(ts!.length).toBeGreaterThan(0);
await engine.setConfig('dream.auto_think.enabled', 'false');
});
// #1698 (codex #5): an empty synthesis must NOT count as complete or advance the
// cooldown — otherwise auto-think silently reports success and suppresses retry until
// the cooldown expires. An empty-answer stub drives runThink's synthesisOk=false path.
test('empty synthesis → partial, 0 synthesized, cooldown NOT advanced', async () => {
await engine.setConfig('dream.auto_think.enabled', 'true');
await engine.setConfig('dream.auto_think.questions', JSON.stringify(['Q-empty']));
await engine.setConfig('dream.auto_think.max_per_cycle', '1');
await engine.setConfig('dream.auto_think.budget', '10.0');
await engine.setConfig('dream.auto_think.auto_commit', 'false');
await engine.setConfig('dream.auto_think.cooldown_days', '30');
await engine.setConfig('dream.auto_think.last_completion_ts', '');
const r = await runPhaseAutoThink(engine, {
dryRun: false,
client: makeStubClient(''), // empty answer → synthesisOk=false
auditPath: join(tmpDir, 'b-empty.jsonl'),
});
expect(r.status).toBe('partial');
expect((r.totals as { synthesized?: number }).synthesized).toBe(0);
// Cooldown must stay empty so the next cycle retries (no silent success).
const ts = await engine.getConfig('dream.auto_think.last_completion_ts');
expect(ts ?? '').toBe('');
await engine.setConfig('dream.auto_think.enabled', 'false');
await engine.setConfig('dream.auto_think.cooldown_days', '0');
});
test('cooldown skips next run', async () => {
await engine.setConfig('dream.auto_think.enabled', 'true');
await engine.setConfig('dream.auto_think.questions', JSON.stringify(['Q1']));
await engine.setConfig('dream.auto_think.cooldown_days', '30');
// Set a recent completion ts
await engine.setConfig('dream.auto_think.last_completion_ts', new Date().toISOString());
const r = await runPhaseAutoThink(engine, { dryRun: false, auditPath: join(tmpDir, 'b3.jsonl') });
expect(r.status).toBe('skipped');
expect(r.detail).toContain('cooled down');
await engine.setConfig('dream.auto_think.enabled', 'false');
await engine.setConfig('dream.auto_think.last_completion_ts', '');
});
test('budget exhausted denies further submits, returns partial', async () => {
await engine.setConfig('dream.auto_think.enabled', 'true');
await engine.setConfig('dream.auto_think.questions', JSON.stringify(['Q1', 'Q2', 'Q3']));
await engine.setConfig('dream.auto_think.max_per_cycle', '3');
await engine.setConfig('dream.auto_think.budget', '0.001'); // tiny cap forces budget_exhausted on first submit
await engine.setConfig('dream.auto_think.cooldown_days', '0');
await engine.setConfig('dream.auto_think.last_completion_ts', '');
// Ensure a clean meter state (no warn-once leftover)
_resetBudgetMeterWarningsForTest();
const r = await runPhaseAutoThink(engine, {
dryRun: false,
client: makeStubClient('test'),
auditPath: join(tmpDir, 'b4.jsonl'),
});
// First submit denied → no syntheses → status 'partial' if any attempts, else 'skipped'.
// Our impl returns 'partial' when results.length > 0 and anyComplete=false.
expect(['partial', 'skipped']).toContain(r.status);
await engine.setConfig('dream.auto_think.enabled', 'false');
});
});
describe('parseDriftOutput', () => {
test('parses fenced JSON with clamped fields', () => {
const v = parseDriftOutput('```json\n{"drifted": true, "confidence": 1.7, "reasoning": "r", "suggested_weight": -0.2}\n```');
expect(v).toEqual({ drifted: true, confidence: 1, reasoning: 'r', suggested_weight: 0 });
});
test('returns null on garbage / missing drifted', () => {
expect(parseDriftOutput('not json at all')).toBeNull();
expect(parseDriftOutput('{"confidence": 0.5}')).toBeNull();
});
});
describe('runPhaseDrift', () => {
test('skipped when not enabled', async () => {
const r = await runPhaseDrift(engine, { dryRun: false, auditPath: join(tmpDir, 'd0.jsonl') });
expect(r.status).toBe('skipped');
});
test('findDriftCandidates returns soft-band takes with recent evidence', async () => {
const cands = await driftTesting.findDriftCandidates(engine, 30);
// Row 2 (weight 0.6) and row 3 (weight 0.5) qualify; row 1 (1.0) is filtered.
expect(cands.length).toBeGreaterThanOrEqual(1);
expect(cands.every(c => c.weight >= 0.3 && c.weight <= 0.85)).toBe(true);
});
test('judges candidates and writes a report-only drift report page (#2653)', async () => {
await engine.setConfig('dream.drift.enabled', 'true');
await engine.setConfig('dream.drift.lookback_days', '30');
await engine.setConfig('dream.drift.budget', '1.0');
const judgedRows: number[] = [];
const r = await runPhaseDrift(engine, {
dryRun: false,
auditPath: join(tmpDir, 'd1.jsonl'),
judge: async ({ candidate, evidence }) => {
judgedRows.push(candidate.rowNum);
expect(evidence).toContain('Funding round closed'); // real timeline evidence reached the judge
return candidate.rowNum === 2
? { drifted: true, confidence: 0.9, reasoning: 'evidence shifted', suggested_weight: 0.3 }
: { drifted: false, confidence: 0.7, reasoning: 'consistent' };
},
});
expect(r.status).toBe('complete');
const totals = r.totals as { candidates: number; judged: number; drifted: number };
expect(totals.judged).toBeGreaterThanOrEqual(2);
expect(totals.drifted).toBe(1);
expect(judgedRows).toContain(2);
// Report page landed.
const date = new Date().toISOString().slice(0, 10);
const page = await engine.getPage(`reports/drift-${date}`);
expect(page).not.toBeNull();
expect(page!.compiled_truth).toContain('DRIFTED');
expect(page!.compiled_truth).toContain('Strong technical founder');
// Report-only v1: no take was mutated even though the judge suggested a weight.
const rows = await engine.executeRaw<{ weight: number; resolved_at: string | null }>(
'SELECT weight, resolved_at FROM takes WHERE page_id = $1 AND row_num = 2',
[alicePageId],
);
expect(Number(rows[0]!.weight)).toBe(0.6);
expect(rows[0]!.resolved_at).toBeNull();
await engine.setConfig('dream.drift.enabled', 'false');
});
test('auto_update mutates nothing in v1', async () => {
await engine.setConfig('dream.drift.enabled', 'true');
await engine.setConfig('dream.drift.auto_update', 'true');
const r = await runPhaseDrift(engine, {
dryRun: false,
auditPath: join(tmpDir, 'd-auto.jsonl'),
judge: async () => ({ drifted: true, confidence: 0.99, reasoning: 'x', suggested_weight: 0.1 }),
});
expect(r.status).toBe('complete');
const rows = await engine.executeRaw<{ weight: number }>(
'SELECT weight FROM takes WHERE page_id = $1 AND row_num = 3',
[alicePageId],
);
expect(Number(rows[0]!.weight)).toBe(0.5); // untouched
await engine.setConfig('dream.drift.auto_update', 'false');
await engine.setConfig('dream.drift.enabled', 'false');
});
test('budget exhaustion stops judging (partial, no judge calls)', async () => {
await engine.setConfig('dream.drift.enabled', 'true');
await engine.setConfig('dream.drift.budget', '0.0000001');
let judgeCalls = 0;
const r = await runPhaseDrift(engine, {
dryRun: false,
auditPath: join(tmpDir, 'd-budget.jsonl'),
judge: async () => { judgeCalls += 1; return { drifted: false, confidence: 0.5, reasoning: '' }; },
});
expect(r.status).toBe('partial');
expect(judgeCalls).toBe(0);
await engine.setConfig('dream.drift.budget', '1.0');
await engine.setConfig('dream.drift.enabled', 'false');
});
test('forceEnabled (--once) bypasses the dream.drift.enabled gate', async () => {
await engine.setConfig('dream.drift.enabled', 'false');
const r = await runPhaseDrift(engine, {
dryRun: false,
auditPath: join(tmpDir, 'd-once.jsonl'),
forceEnabled: true,
judge: async () => ({ drifted: false, confidence: 0.5, reasoning: 'ok' }),
});
expect(r.status).toBe('complete');
});
test('dry-run returns skipped with candidate count', async () => {
await engine.setConfig('dream.drift.enabled', 'true');
const r = await runPhaseDrift(engine, { dryRun: true, auditPath: join(tmpDir, 'd2.jsonl') });
expect(r.status).toBe('skipped');
expect(r.detail).toContain('dry-run');
await engine.setConfig('dream.drift.enabled', 'false');
});
});