diff --git a/src/commands/dream.ts b/src/commands/dream.ts index 71b598c6d..cc08b79f6 100644 --- a/src/commands/dream.ts +++ b/src/commands/dream.ts @@ -86,7 +86,7 @@ interface DreamArgs { * `--phase `; bare `--once` is a usage error (there'd be no single * phase to target). Applies only to phases with a config `.enabled` gate * (patterns, synthesize, conversation_facts_backfill, enrich_thin, - * skillopt) — a no-op for phases that always run when named directly. + * skillopt, drift) — a no-op for phases that always run when named directly. */ once: boolean; } @@ -367,7 +367,7 @@ Options: unlike toggling the flag on/off around the run, a crash mid-invocation can't leave it stuck. Applies to patterns, synthesize, conversation_facts_backfill, - enrich_thin, skillopt; no-op on phases with no such + enrich_thin, skillopt, drift; no-op on phases with no such gate. Requires an EXPLICIT --phase — a phase implied by --input or --drain does not count (bare --once, or --once with --input/--drain and no diff --git a/src/core/cycle.ts b/src/core/cycle.ts index f98e370e8..ce043006d 100644 --- a/src/core/cycle.ts +++ b/src/core/cycle.ts @@ -66,6 +66,10 @@ export type CyclePhase = // - calibration_profile: aggregates the resolved subset into 2-4 // narrative pattern statements + active bias tags. Voice-gated. | 'propose_takes' | 'grade_takes' | 'calibration_profile' + // #2653 — drift detection (default OFF; dream.drift.enabled). LLM-judges + // soft-band takes against recent timeline evidence; report-only in v1 + // (writes reports/drift-; auto_update mutates nothing). + | 'drift' | 'embed' | 'orphans' | 'purge' // v0.39 T12: schema-suggest passive trigger (D3 + D4 plan-eng-review). // Wraps runSuggest() — same library the CLI verb + EIIRP call. @@ -151,6 +155,10 @@ export const ALL_PHASES: CyclePhase[] = [ 'propose_takes', 'grade_takes', 'calibration_profile', + // #2653 — drift detection. Default OFF (dream.drift.enabled). Runs AFTER + // the calibration trio (fresh take resolutions) and BEFORE embed so the + // drift report page gets embedded same-cycle. Report-only in v1. + 'drift', // v0.41.11.0 — opt-in conversation-facts backfill. Default OFF; reads // cycle.conversation_facts_backfill.enabled gate inside the wrapper. // Ordered AFTER calibration_profile (matches the runCycle dispatch @@ -221,6 +229,9 @@ export const PHASE_SCOPE: Record = { propose_takes: 'source', grade_takes: 'global', calibration_profile: 'global', + // #2653 — drift walks takes brain-wide (same posture as grade_takes) and + // writes one brain-global report page. + drift: 'global', embed: 'global', orphans: 'global', purge: 'global', @@ -289,6 +300,8 @@ const NEEDS_LOCK_PHASES: ReadonlySet = new Set([ 'propose_takes', 'grade_takes', 'calibration_profile', + // #2653 — writes the reports/drift- page. + 'drift', // v0.41 T9 — extract_atoms writes atom-typed pages via put_page; // synthesize_concepts writes concept-typed pages + tier updates. Both // mutate DB state and need the lock. @@ -2151,6 +2164,49 @@ export async function runCycle( } } + // ── #2653: drift detection ────────────────────────────────── + // Default OFF (dream.drift.enabled). LLM-judges soft-band takes + // against recent timeline evidence; report-only in v1 — writes one + // reports/drift- page, mutates no takes regardless of + // dream.drift.auto_update. + if (phases.includes('drift')) { + checkAborted(opts.signal); + if (!engine) { + phaseResults.push({ + phase: 'drift', + status: 'skipped', + duration_ms: 0, + summary: 'no database connected', + details: { reason: 'no_database' }, + }); + } else { + progress.start('cycle.drift'); + const { runPhaseDrift } = await import('./cycle/drift.ts'); + const { result, duration_ms } = await timePhase(async (): Promise => { + const r = await runPhaseDrift(engine, { + dryRun, + brainDir: brainDir ?? undefined, + forceEnabled: opts.onceForPhase === 'drift', + }); + const status: PhaseStatus = + r.status === 'complete' ? 'ok' : + r.status === 'partial' ? 'warn' : + r.status === 'failed' ? 'fail' : 'skipped'; + return { + phase: 'drift', + status, + duration_ms: 0, + summary: r.detail, + details: { ...(r.totals ?? {}) }, + }; + }); + result.duration_ms = duration_ms; + phaseResults.push(result); + progress.finish(); + } + await safeYield(opts.yieldBetweenPhases); + } + // ── v0.41.11.0: conversation_facts_backfill ───────────────── // Opt-in (default OFF). Walks long-form conversation/meeting/slack/ // email pages, segments by 30-min gap, runs facts extractor with a diff --git a/src/core/cycle/drift.ts b/src/core/cycle/drift.ts index 4042e13d8..971a9e789 100644 --- a/src/core/cycle/drift.ts +++ b/src/core/cycle/drift.ts @@ -1,18 +1,25 @@ /** - * v0.28: drift dream phase. + * Drift dream phase (#2653 — wired v0.42.x; scaffold shipped v0.28). * - * Detects takes where the underlying evidence has shifted since the take - * was made. v0.28 ships the SCAFFOLD: the phase iterates active takes, - * runs a lightweight check against recent timeline entries on the same - * page, and writes a drift-report-.md if any takes look stale. + * Detects takes whose underlying evidence has shifted since the take was + * made. Two stages: + * 1. Cheap SQL heuristic (findDriftCandidates): soft-band takes + * (weight 0.3–0.85, active, unresolved) on pages with fresh + * timeline_entries evidence inside the lookback window. + * 2. LLM judge: each candidate's claim is compared against the recent + * timeline evidence; the judge returns {drifted, confidence, + * reasoning, suggested_weight}. BudgetMeter-gated. * - * The full LLM-driven drift detection (compare each take's claim to recent - * page evidence and propose a weight adjustment) is the v0.29 follow-up. - * v0.28 lays the phase orchestration so the contract is stable. + * Output is REPORT-ONLY (v1 conservative posture): judged candidates land + * on a `reports/drift-` page. `dream.drift.auto_update` mutates + * NOTHING in v1 — it is recorded in the report so operators can see the + * flag state, and a future wave may wire weight adjustment behind it. * * Default-disabled. Operator opts in: * gbrain config set dream.drift.enabled true * gbrain config set dream.drift.lookback_days 30 + * gbrain config set dream.drift.max_per_cycle 20 + * gbrain config set dream.drift.budget 1.0 */ import type { BrainEngine } from '../engine.ts'; @@ -25,6 +32,10 @@ export interface DriftPhaseOpts { dryRun: boolean; /** Override the audit ledger path (tests). */ auditPath?: string; + /** issue #2860 --once: bypass the dream.drift.enabled gate for this run only. */ + forceEnabled?: boolean; + /** Inject the judge model call (tests). Defaults to gateway chat. */ + judge?: DriftJudgeFn; } export interface DriftConfig { @@ -32,6 +43,7 @@ export interface DriftConfig { lookbackDays: number; budgetUsd: number; autoUpdate: boolean; + maxPerCycle: number; } async function loadDriftConfig(engine: BrainEngine): Promise { @@ -39,16 +51,19 @@ async function loadDriftConfig(engine: BrainEngine): Promise { const lookbackStr = await engine.getConfig('dream.drift.lookback_days'); const budgetStr = await engine.getConfig('dream.drift.budget'); const autoStr = await engine.getConfig('dream.drift.auto_update'); + const maxPerStr = await engine.getConfig('dream.drift.max_per_cycle'); return { enabled: enabledStr === 'true', lookbackDays: lookbackStr ? Math.max(1, parseInt(lookbackStr, 10) || 30) : 30, budgetUsd: budgetStr ? Math.max(0, parseFloat(budgetStr) || 1.0) : 1.0, autoUpdate: autoStr === 'true', + maxPerCycle: maxPerStr ? Math.max(1, parseInt(maxPerStr, 10) || 20) : 20, }; } -interface DriftCandidate { +export interface DriftCandidate { takeId: number; + pageId: number; pageSlug: string; rowNum: number; claim: string; @@ -57,24 +72,121 @@ interface DriftCandidate { recentEvidenceCount: number; } +/** Judge verdict: has the claim drifted relative to the evidence? */ +export interface DriftVerdict { + drifted: boolean; + confidence: number; + reasoning: string; + /** Judge's suggested new weight (advisory only — v1 never applies it). */ + suggested_weight?: number; +} + +/** Judge function signature — injected for tests. */ +export type DriftJudgeFn = (input: { + candidate: DriftCandidate; + evidence: string; + modelHint?: string; +}) => Promise; + +export const DRIFT_JUDGE_PROMPT = `You are auditing a knowledge-base "take" (a weighted claim) for drift: +has newer evidence shifted the ground under this claim since it was made? + +Output ONLY one JSON object with these fields: +- drifted (boolean) — true when the evidence meaningfully contradicts, + supersedes, or reframes the claim; false when it is + consistent or merely adjacent. +- confidence (number in [0,1]) — your confidence in the drifted verdict. +- reasoning (string, <=300 chars) — what in the evidence drove the verdict. +- suggested_weight (number in [0,1], optional) — where the take's weight should + move if drifted. Advisory only. + +If the evidence is sparse or unrelated to the claim, return drifted=false with +low confidence. + +TAKE: + Claim: {CLAIM} + Weight: {WEIGHT} + Page: {PAGE} + +RECENT EVIDENCE (timeline entries on the same page): +{EVIDENCE_BLOCK} +`; + +/** + * Parse the judge model's JSON output. Tolerant of fence wrapping and + * leading prose; returns null on unrecoverable parse failure. + */ +export function parseDriftOutput(raw: string): DriftVerdict | null { + if (!raw || raw.trim().length === 0) return null; + let text = raw.trim(); + const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/); + if (fenced) text = (fenced[1] ?? '').trim(); + const firstObj = text.indexOf('{'); + if (firstObj === -1) return null; + let parsed: unknown; + try { + parsed = JSON.parse(text.slice(firstObj)); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + const r = parsed as Record; + if (typeof r.drifted !== 'boolean') return null; + const confRaw = typeof r.confidence === 'number' ? r.confidence : Number.parseFloat(String(r.confidence ?? '')); + if (!Number.isFinite(confRaw)) return null; + const verdict: DriftVerdict = { + drifted: r.drifted, + confidence: Math.max(0, Math.min(1, confRaw)), + reasoning: typeof r.reasoning === 'string' ? r.reasoning.slice(0, 300) : '', + }; + const sw = typeof r.suggested_weight === 'number' ? r.suggested_weight : NaN; + if (Number.isFinite(sw)) verdict.suggested_weight = Math.max(0, Math.min(1, sw)); + return verdict; +} + +/** Production judge — calls gateway.chat with the DRIFT_JUDGE_PROMPT. */ +export async function defaultDriftJudge(input: { + candidate: DriftCandidate; + evidence: string; + modelHint?: string; +}): Promise { + const { chat } = await import('../ai/gateway.ts'); + const prompt = DRIFT_JUDGE_PROMPT + .replace('{CLAIM}', input.candidate.claim) + .replace('{WEIGHT}', String(input.candidate.weight)) + .replace('{PAGE}', input.candidate.pageSlug) + .replace('{EVIDENCE_BLOCK}', input.evidence); + const result = await chat({ + messages: [{ role: 'user', content: prompt }], + ...(input.modelHint ? { model: input.modelHint } : {}), + maxTokens: 400, + }); + const parsed = parseDriftOutput(result.text); + if (!parsed) { + // Failed parse — conservative no-drift at zero confidence so the row + // still surfaces in the report instead of disappearing silently. + return { drifted: false, confidence: 0, reasoning: 'judge_output_parse_failed' }; + } + return parsed; +} + /** * Cheap pre-LLM heuristic: takes that have substantial recent timeline - * evidence on the same page MAY have drifted. Surface them; the v0.29 - * LLM judge will decide if the weight should move. + * evidence on the same page MAY have drifted. Surface them; the LLM judge + * decides. */ async function findDriftCandidates( engine: BrainEngine, lookbackDays: number, ): Promise { - const cutoffMs = Date.now() - lookbackDays * 86_400_000; - const cutoffIso = new Date(cutoffMs).toISOString().slice(0, 10); + const cutoffIso = lookbackCutoffIso(lookbackDays); // Only consider takes with weight in the "soft" middle band (0.3..0.85) // — facts (1.0) don't drift, very-low hunches (<0.3) aren't actionable yet. const rows = await engine.executeRaw<{ - take_id: number; page_slug: string; row_num: number; + take_id: number; page_id: number; page_slug: string; row_num: number; claim: string; weight: number; recent_evidence: number; }>(` - SELECT t.id AS take_id, p.slug AS page_slug, t.row_num, + SELECT t.id AS take_id, p.id AS page_id, p.slug AS page_slug, t.row_num, t.claim, t.weight, (SELECT count(*)::int FROM timeline_entries te WHERE te.page_id = p.id @@ -92,6 +204,7 @@ async function findDriftCandidates( .filter(r => Number(r.recent_evidence) >= 1) .map(r => ({ takeId: Number(r.take_id), + pageId: Number(r.page_id), pageSlug: String(r.page_slug), rowNum: Number(r.row_num), claim: String(r.claim), @@ -100,6 +213,58 @@ async function findDriftCandidates( })); } +function lookbackCutoffIso(lookbackDays: number): string { + return new Date(Date.now() - lookbackDays * 86_400_000).toISOString().slice(0, 10); +} + +/** Format the candidate page's recent timeline entries as judge evidence. */ +async function loadEvidence( + engine: BrainEngine, + pageId: number, + cutoffIso: string, +): Promise { + const rows = await engine.executeRaw<{ date: string; source: string; summary: string }>( + `SELECT date::text AS date, source, summary FROM timeline_entries + WHERE page_id = $1 AND date >= $2::date + ORDER BY date DESC LIMIT 12`, + [pageId, cutoffIso], + ); + if (rows.length === 0) return '(no timeline entries in window)'; + return rows.map(r => `- ${r.date} [${r.source}] ${r.summary}`).join('\n'); +} + +interface JudgedCandidate { + candidate: DriftCandidate; + verdict: DriftVerdict; +} + +function buildReportBody( + judged: JudgedCandidate[], + cfg: DriftConfig, + modelId: string, +): string { + const drifted = judged.filter(j => j.verdict.drifted); + const lines: string[] = [ + `Drift check over active soft-band takes (weight 0.3–0.85) with timeline`, + `evidence from the last ${cfg.lookbackDays} day(s). Judge model: ${modelId}.`, + ``, + `**Report-only:** no takes were modified. \`dream.drift.auto_update\` is`, + `${cfg.autoUpdate ? 'set but ignored in v1 (report-only posture)' : 'off'}; review and adjust weights manually.`, + ``, + `${drifted.length} of ${judged.length} judged take(s) look drifted.`, + ``, + ]; + for (const { candidate: c, verdict: v } of judged) { + lines.push(`## ${v.drifted ? 'DRIFTED' : 'stable'} — ${c.pageSlug} (take #${c.rowNum})`); + lines.push(`- Claim: ${c.claim}`); + lines.push(`- Weight: ${c.weight}${v.suggested_weight !== undefined ? ` → suggested ${v.suggested_weight}` : ''}`); + lines.push(`- Confidence: ${v.confidence.toFixed(2)}`); + if (v.reasoning) lines.push(`- Reasoning: ${v.reasoning}`); + lines.push(''); + } + return lines.join('\n'); +} + function skipped(_reason: string, detail: string): DreamPhaseResult { return { name: 'drift', status: 'skipped', detail, duration_ms: 0 }; } @@ -110,7 +275,7 @@ export async function runPhaseDrift( ): Promise { const start = Date.now(); const config = await loadDriftConfig(engine); - if (!config.enabled) { + if (!config.enabled && !opts.forceEnabled) { return skipped('not_configured', 'dream.drift.enabled is false'); } @@ -125,9 +290,16 @@ export async function runPhaseDrift( }; } - // Resolve model for the (future v0.29) LLM judge. For v0.28 we just - // surface the candidates — the meter call is a no-op when we don't actually - // submit, but resolveModel sets the right pricing key when v0.29 ships. + if (opts.dryRun) { + return { + name: 'drift', + status: 'skipped', + detail: `dry-run: ${Math.min(candidates.length, config.maxPerCycle)} of ${candidates.length} candidates would be evaluated`, + totals: { candidates: candidates.length }, + duration_ms: Date.now() - start, + }; + } + const modelId = await resolveModel(engine, { configKey: 'models.drift', deprecatedConfigKey: 'dream.drift.model', @@ -139,27 +311,70 @@ export async function runPhaseDrift( phase: 'drift', auditPath: opts.auditPath, }); + const judge = opts.judge ?? defaultDriftJudge; + const cutoffIso = lookbackCutoffIso(config.lookbackDays); - // v0.28 scaffold: write a candidate report. v0.29 wires LLM-driven weight - // adjustment through autoUpdate. modelId + meter are wired now so the - // ledger captures the gate state even when we don't submit. - void modelId; void meter; - - if (opts.dryRun) { - return { - name: 'drift', - status: 'skipped', - detail: `dry-run: ${candidates.length} candidates would be evaluated`, - totals: { candidates: candidates.length }, - duration_ms: Date.now() - start, - }; + const judged: JudgedCandidate[] = []; + let budgetExhausted = false; + let failed = 0; + for (const candidate of candidates.slice(0, config.maxPerCycle)) { + const check = meter.check({ + modelId, + estimatedInputTokens: 1500, + maxOutputTokens: 400, + label: `drift:${candidate.pageSlug}#${candidate.rowNum}`, + }); + if (!check.allowed) { + budgetExhausted = true; + break; + } + const evidence = await loadEvidence(engine, candidate.pageId, cutoffIso); + try { + const verdict = await judge({ candidate, evidence, modelHint: modelId }); + judged.push({ candidate, verdict }); + } catch (e) { + failed += 1; + process.stderr.write(`[drift] judge failed on take ${candidate.takeId}: ${(e as Error).message}\n`); + } } + const driftedCount = judged.filter(j => j.verdict.drifted).length; + let reportSlug: string | undefined; + if (judged.length > 0) { + const date = new Date().toISOString().slice(0, 10); + reportSlug = `reports/drift-${date}`; + // Report-only v1: the report page is the ONLY write this phase makes. + // Lands in the default source (brain-global artifact, same-day re-runs + // upsert the same slug). + await engine.putPage(reportSlug, { + type: 'report', + title: `Drift report ${date}`, + compiled_truth: buildReportBody(judged, config, modelId), + }); + } + + const detail = + `judged ${judged.length}/${candidates.length} candidates: ${driftedCount} drifted` + + (reportSlug ? ` → ${reportSlug}` : '') + + (budgetExhausted ? ' (budget exhausted)' : '') + + (failed > 0 ? ` (${failed} judge failure(s))` : '') + + `. Cumulative cost: $${meter.totalSpent.toFixed(4)} / $${config.budgetUsd.toFixed(2)}` + + `. Report-only: auto_update=${config.autoUpdate} mutates nothing in v1.`; + return { name: 'drift', - status: 'complete', - detail: `surfaced ${candidates.length} drift candidates (LLM judge: v0.29 follow-up). autoUpdate=${config.autoUpdate}`, - totals: { candidates: candidates.length }, + status: judged.length > 0 + ? (budgetExhausted || failed > 0 ? 'partial' : 'complete') + // Zero judged: budget capped before any judge ran → partial (capped, + // not broken); otherwise every judge call failed → failed. + : (budgetExhausted ? 'partial' : 'failed'), + detail, + totals: { + candidates: candidates.length, + judged: judged.length, + drifted: driftedCount, + failed, + }, duration_ms: Date.now() - start, }; } diff --git a/test/auto-think-phase.test.ts b/test/auto-think-phase.test.ts index f46462c2e..4d2d05f62 100644 --- a/test/auto-think-phase.test.ts +++ b/test/auto-think-phase.test.ts @@ -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') }); diff --git a/test/core/cycle.serial.test.ts b/test/core/cycle.serial.test.ts index 247124d58..8676bbd67 100644 --- a/test/core/cycle.serial.test.ts +++ b/test/core/cycle.serial.test.ts @@ -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); }); }); diff --git a/test/e2e/dream-cycle-phase-order-pglite.test.ts b/test/e2e/dream-cycle-phase-order-pglite.test.ts index 53d8add53..c9356d752 100644 --- a/test/e2e/dream-cycle-phase-order-pglite.test.ts +++ b/test/e2e/dream-cycle-phase-order-pglite.test.ts @@ -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) diff --git a/test/phase-scope-coverage.test.ts b/test/phase-scope-coverage.test.ts index ccd58b326..957caf47a 100644 --- a/test/phase-scope-coverage.test.ts +++ b/test/phase-scope-coverage.test.ts @@ -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)', () => {