mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
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>
This commit is contained in:
co-authored by
Garry Tan
Claude Fable 5
parent
69bc37f745
commit
40d9b83d5c
@@ -4,7 +4,7 @@ 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, __testing as driftTesting } from '../src/core/cycle/drift.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';
|
||||
|
||||
@@ -153,6 +153,18 @@ describe('runPhaseAutoThink', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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') });
|
||||
@@ -166,16 +178,89 @@ describe('runPhaseDrift', () => {
|
||||
expect(cands.every(c => c.weight >= 0.3 && c.weight <= 0.85)).toBe(true);
|
||||
});
|
||||
|
||||
test('runs and surfaces candidates when enabled', async () => {
|
||||
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 r = await runPhaseDrift(engine, { dryRun: false, auditPath: join(tmpDir, 'd1.jsonl') });
|
||||
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');
|
||||
expect((r.totals as { candidates?: number }).candidates).toBeGreaterThanOrEqual(0);
|
||||
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') });
|
||||
|
||||
@@ -394,7 +394,9 @@ describe('runCycle — yieldBetweenPhases hook', () => {
|
||||
// v0.41.11.0: 20 phases (added `conversation_facts_backfill` between consolidate and propose_takes).
|
||||
// v0.41.39 (#1700) + v0.42.0.0: 22 phases (added `enrich_thin` AND `skillopt`
|
||||
// between conversation_facts_backfill and embed — both landed in this merge).
|
||||
expect(hookCalls).toBe(22);
|
||||
// #2653: 23 phases (added `drift` between calibration_profile and
|
||||
// conversation_facts_backfill).
|
||||
expect(hookCalls).toBe(23);
|
||||
});
|
||||
|
||||
test('hook exceptions do not abort the cycle', async () => {
|
||||
@@ -409,7 +411,8 @@ describe('runCycle — yieldBetweenPhases hook', () => {
|
||||
// v0.39.0.0: 17 phases (T12 schema-suggest phase between orphans and purge).
|
||||
// v0.41.11.0: 20 phases (+extract_atoms, +synthesize_concepts, +conversation_facts_backfill).
|
||||
// v0.41.39 (#1700) + v0.42.0.0: 22 phases (+enrich_thin, +skillopt).
|
||||
expect(report.phases.length).toBe(22);
|
||||
// #2653: 23 phases (+drift).
|
||||
expect(report.phases.length).toBe(23);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ const EXPECTED_PHASES: CyclePhase[] = [
|
||||
'propose_takes', // v0.36.1.0 — hindsight calibration wave
|
||||
'grade_takes', // v0.36.1.0
|
||||
'calibration_profile', // v0.36.1.0
|
||||
'drift', // #2653 — drift detection (default OFF, report-only)
|
||||
'conversation_facts_backfill', // v0.41.11.0 — opt-in conversation backfill
|
||||
'enrich_thin', // v0.41.39 (#1700) — brain-internal stub enrichment (default OFF)
|
||||
'skillopt', // v0.42.0.0 — self-evolving skills (default OFF)
|
||||
|
||||
@@ -41,15 +41,16 @@ describe('PHASE_SCOPE coverage', () => {
|
||||
expect(invalid).toEqual([]);
|
||||
});
|
||||
|
||||
test('all 22 phases covered (regression on accidental omission)', () => {
|
||||
test('all 23 phases covered (regression on accidental omission)', () => {
|
||||
// Pin the count so a future PR that adds a phase to ALL_PHASES
|
||||
// without updating PHASE_SCOPE notices here too. The v0.39.1.0
|
||||
// master merge brought in the 17th phase (`schema-suggest`); v0.41
|
||||
// adds 'extract_atoms' + 'synthesize_concepts' (T9 lens packs) +
|
||||
// 'conversation_facts_backfill' (v0.41.11.0) for 20; v0.41.39 (#1700)
|
||||
// adds 'enrich_thin' and v0.42.0.0 adds 'skillopt' for a total of 22.
|
||||
expect(ALL_PHASES.length).toBe(22);
|
||||
expect(Object.keys(PHASE_SCOPE).length).toBe(22);
|
||||
// adds 'enrich_thin' and v0.42.0.0 adds 'skillopt' for a total of 22;
|
||||
// #2653 adds 'drift' for 23.
|
||||
expect(ALL_PHASES.length).toBe(23);
|
||||
expect(Object.keys(PHASE_SCOPE).length).toBe(23);
|
||||
});
|
||||
|
||||
test('embed remains global (the headline brain-wide phase)', () => {
|
||||
|
||||
Reference in New Issue
Block a user