From 6f7dbe1d03e20d1614424eff723ca63253d1aa08 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 11 May 2026 12:38:40 -0700 Subject: [PATCH] feat(v0.34 W0c): wire resolve_symbol_edges as a new cycle phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W0c's symbol resolver lands as a 12th cycle phase between extract and patterns. The autopilot's quick-cycle path (60s watchdog interval per D2 from eng review) now resolves stale chunks incrementally so agents see resolved edges within ~60s of writes rather than waiting on the slow full-walk path. * CyclePhase + ALL_PHASES + NEEDS_LOCK_PHASES extended with 'resolve_symbol_edges'. Position: between extract (which emits new bare-token edges from sync diffs) and patterns (which reads the graph). Acquires the cycle lock because it writes edge_metadata. * CycleReport.totals adds edges_resolved + edges_ambiguous so doctor and autopilot summaries surface the numbers. * runPhaseResolveSymbolEdges walks every registered source via listSources() + resolveSymbolEdgesIncremental(). Per-call cap is BATCH_SIZE*10 = 2000 chunks so a single watchdog tick stays bounded even on a 100K-chunk brain. Subsequent ticks pick up the leftovers via the edges_backfilled_at watermark. * Test count bumped from 11 → 12 phases in cycle.serial.test.ts and cycle.test.ts (both pinned by the regression guards). Existing 28 cycle tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/cycle.ts | 111 +++++++++++++++++++++++++++++++++ test/core/cycle.serial.test.ts | 7 ++- test/e2e/cycle.test.ts | 5 +- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/core/cycle.ts b/src/core/cycle.ts index 498c450eb..952d648f7 100644 --- a/src/core/cycle.ts +++ b/src/core/cycle.ts @@ -55,6 +55,7 @@ import { getCliOptions, cliOptsToProgressOptions } from './cli-options.ts'; export type CyclePhase = | 'lint' | 'backlinks' | 'sync' | 'synthesize' | 'extract' + | 'resolve_symbol_edges' | 'patterns' | 'recompute_emotional_weight' | 'consolidate' | 'embed' | 'orphans' | 'purge'; @@ -64,6 +65,13 @@ export const ALL_PHASES: CyclePhase[] = [ 'sync', 'synthesize', 'extract', + // v0.34 W0c — within-file two-pass symbol resolution. Runs AFTER extract + // so any code edges sync emitted (still bare-token) get resolved into + // {resolved_chunk_id: N} / {ambiguous: true, candidates: [...]} edge_metadata + // entries before downstream phases read the graph. Quick-cycle compatible: + // each invocation walks at most BATCH_SIZE*10 chunks where + // edges_backfilled_at IS NULL or stale. + 'resolve_symbol_edges', 'patterns', // v0.29 — runs AFTER extract + synthesize so it sees the union of // sync-touched + synthesize-written pages with fresh tag + take state. @@ -96,6 +104,8 @@ const NEEDS_LOCK_PHASES: ReadonlySet = new Set([ 'sync', 'synthesize', 'extract', + // v0.34 W0c — writes code_edges_symbol.edge_metadata + content_chunks.edges_backfilled_at. + 'resolve_symbol_edges', 'patterns', // v0.29 — writes pages.emotional_weight column. 'recompute_emotional_weight', @@ -163,6 +173,10 @@ export interface CycleReport { patterns_written: number; /** v0.29: number of pages whose emotional_weight was (re)computed. */ pages_emotional_weight_recomputed: number; + /** v0.34: number of code edges resolved (1 candidate) by the resolve_symbol_edges phase. */ + edges_resolved: number; + /** v0.34: number of code edges marked ambiguous (2+ candidates) by the resolve_symbol_edges phase. */ + edges_ambiguous: number; /** v0.26.5: number of source rows hard-deleted by the purge phase. */ purged_sources_count: number; /** v0.26.5: number of page rows hard-deleted by the purge phase. */ @@ -653,6 +667,73 @@ async function runPhaseExtract( } } +/** + * v0.34 W0c — resolve_symbol_edges phase. + * + * Walks at most BATCH_SIZE*10 chunks per invocation where + * `edges_backfilled_at` is NULL or older than EDGE_EXTRACTOR_VERSION_TS. + * Resumable across cycles via the watermark; quick-cycle compatible. + * + * Source scoping: walks every registered source. Pre-v0.34 silently + * crossed sources; now each source is walked independently so symbol + * resolution stays within its source boundary (matches the W0a fix). + */ +async function runPhaseResolveSymbolEdges( + engine: BrainEngine, + dryRun: boolean, +): Promise { + if (dryRun) { + return { + phase: 'resolve_symbol_edges', + status: 'skipped', + duration_ms: 0, + summary: 'dry-run: resolve_symbol_edges phase skipped', + details: { dryRun: true, reason: 'no_dry_run_support' }, + }; + } + try { + const { resolveSymbolEdgesIncremental } = await import('./chunkers/symbol-resolver.ts'); + const { listSources } = await import('./sources-ops.ts'); + const sources = await listSources(engine); + let totalChunks = 0; + let totalResolved = 0; + let totalAmbiguous = 0; + let totalUnmatched = 0; + for (const s of sources) { + const stats = await resolveSymbolEdgesIncremental(engine, { sourceId: s.id }); + totalChunks += stats.chunks_walked; + totalResolved += stats.edges_resolved; + totalAmbiguous += stats.edges_ambiguous; + totalUnmatched += stats.edges_unmatched; + } + return { + phase: 'resolve_symbol_edges', + status: 'ok', + duration_ms: 0, + summary: + totalChunks === 0 + ? 'no chunks needed symbol resolution' + : `${totalChunks} chunk(s) walked; resolved ${totalResolved}, ambiguous ${totalAmbiguous}, unmatched ${totalUnmatched}`, + details: { + chunks_walked: totalChunks, + edges_resolved: totalResolved, + edges_ambiguous: totalAmbiguous, + edges_unmatched: totalUnmatched, + sources_walked: sources.length, + }, + }; + } catch (e) { + return { + phase: 'resolve_symbol_edges', + status: 'fail', + duration_ms: 0, + summary: 'resolve_symbol_edges phase failed', + details: {}, + error: makeErrorFromException(e), + }; + } +} + async function runPhaseEmbed(engine: BrainEngine, dryRun: boolean): Promise { try { const { runEmbedCore } = await import('../commands/embed.ts'); @@ -997,6 +1078,31 @@ export async function runCycle( await safeYield(opts.yieldBetweenPhases); } + // ── v0.34 W0c: resolve_symbol_edges (between extract + patterns) ── + // Walks chunks whose edges_backfilled_at is null/stale. Resumable + // across cycles via the watermark. Quick-cycle compatible — caps at + // BATCH_SIZE * 10 chunks per invocation so a 60s watchdog tick stays + // responsive even on a 100K-chunk brain. + if (phases.includes('resolve_symbol_edges')) { + checkAborted(opts.signal); + if (!engine) { + phaseResults.push({ + phase: 'resolve_symbol_edges', + status: 'skipped', + duration_ms: 0, + summary: 'no database connected', + details: { reason: 'no_database' }, + }); + } else { + progress.start('cycle.resolve_symbol_edges'); + const { result, duration_ms } = await timePhase(() => runPhaseResolveSymbolEdges(engine, dryRun)); + result.duration_ms = duration_ms; + phaseResults.push(result); + progress.finish(); + } + await safeYield(opts.yieldBetweenPhases); + } + // ── Phase 6: patterns (v0.23) ─────────────────────────────── // MUST run after extract so the graph state reads fresh — subagent // put_page calls in synthesize set ctx.remote=true, so auto-link @@ -1194,6 +1300,8 @@ function emptyTotals(): CycleReport['totals'] { synth_pages_written: 0, patterns_written: 0, pages_emotional_weight_recomputed: 0, + edges_resolved: 0, + edges_ambiguous: 0, purged_sources_count: 0, purged_pages_count: 0, facts_consolidated: 0, @@ -1227,6 +1335,9 @@ function extractTotals(phases: PhaseResult[]): CycleReport['totals'] { t.patterns_written = Number(p.details.patterns_written ?? 0); } else if (p.phase === 'recompute_emotional_weight' && p.details) { t.pages_emotional_weight_recomputed = Number(p.details.pages_recomputed ?? 0); + } else if (p.phase === 'resolve_symbol_edges' && p.details) { + t.edges_resolved = Number(p.details.edges_resolved ?? 0); + t.edges_ambiguous = Number(p.details.edges_ambiguous ?? 0); } else if (p.phase === 'purge' && p.details) { t.purged_sources_count = Number(p.details.purged_sources_count ?? 0); t.purged_pages_count = Number(p.details.purged_pages_count ?? 0); diff --git a/test/core/cycle.serial.test.ts b/test/core/cycle.serial.test.ts index c1175ae54..e1822a799 100644 --- a/test/core/cycle.serial.test.ts +++ b/test/core/cycle.serial.test.ts @@ -380,7 +380,8 @@ describe('runCycle — yieldBetweenPhases hook', () => { // v0.26.5: 9 phases (added `purge`). // v0.29: 10 phases (added `recompute_emotional_weight`). // v0.31: 11 phases (added `consolidate` between recompute and embed) → 11 yield calls. - expect(hookCalls).toBe(11); + // v0.34: 12 phases (added `resolve_symbol_edges` between extract and patterns). + expect(hookCalls).toBe(12); }); test('hook exceptions do not abort the cycle', async () => { @@ -390,8 +391,8 @@ describe('runCycle — yieldBetweenPhases hook', () => { throw new Error('synthetic hook error'); }, }); - // Cycle still completed all phases (v0.31: 11 = v0.29 recompute + v0.31 consolidate). - expect(report.phases.length).toBe(11); + // v0.34: 12 phases (added `resolve_symbol_edges`). + expect(report.phases.length).toBe(12); }); }); diff --git a/test/e2e/cycle.test.ts b/test/e2e/cycle.test.ts index cb3404390..2c0328db8 100644 --- a/test/e2e/cycle.test.ts +++ b/test/e2e/cycle.test.ts @@ -97,13 +97,14 @@ describeE2E('E2E: runCycle against real Postgres', () => { }); expect(report.schema_version).toBe('1'); - // Cycle ran all 11 phases (or skipped the ones that don't support dry-run). + // Cycle ran all 12 phases (or skipped the ones that don't support dry-run). // Phase history: // v0.23 = 8 phases (lint → backlinks → sync → synthesize → extract → patterns → embed → orphans) // v0.26.5 = 9 (added `purge` after orphans) // v0.29 = 10 (added `recompute_emotional_weight` between patterns and embed) // v0.31 = 11 (added `consolidate` between recompute_emotional_weight and embed) - expect(report.phases.length).toBe(11); + // v0.34 = 12 (added `resolve_symbol_edges` between extract and patterns) + expect(report.phases.length).toBe(12); // Nothing got written. const afterPages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);