diff --git a/assistant/src/memory/v3/__tests__/loop.test.ts b/assistant/src/memory/v3/__tests__/loop.test.ts new file mode 100644 index 00000000000..c16ea8fb591 --- /dev/null +++ b/assistant/src/memory/v3/__tests__/loop.test.ts @@ -0,0 +1,535 @@ +/** + * Tests for `assistant/src/memory/v3/loop.ts`. + * + * The loop is the composition layer over the v3 lanes. Every lane module + * (`scouts`, `filter`, `tree-walk`, `edges`, `gate`) plus the two index + * builders (`tree-index`, `page-index`) the loop calls are stubbed via + * `mock.module`, so the suite makes no real LLM, Qdrant, embedding, or + * filesystem calls. Each mock factory closes over a mutable `lane` state object + * that every test rewires before calling `runRetrievalLoop`; a `laneCalls` + * recorder captures the arguments the loop passed each lane so the composition + * wiring (seeding, query threading, toggles) is assertable. + * + * Coverage: + * - single-pass ready: scouts → filter → tree → edges → gate composes into a + * valid RetrievalOutput with per-lane source tags and one DescentPass. + * - multi-pass: gate "more" then "ready" runs two passes and threads the + * gate's questions into the second pass's NOW text. + * - passCap: a gate that always says "more" force-exits at passCap. + * - lane toggles: `lanes.tree=false` / `lanes.edges=false` suppress those + * lanes' candidates and trace fields. + * - trace: one DescentPass per pass. + * - cost: `ms` accumulates and is non-negative across passes. + * - failureReason: a filter failure is surfaced on the output. + */ + +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +import type { DrizzleDb } from "../../db-connection.js"; +import type { + RetrievalInput, + RetrievalOutput, +} from "../../v2/harness/retriever.js"; +import type { GateDecision, ScoutResult } from "../../v2/harness/trace.js"; + +// --------------------------------------------------------------------------- +// Lane stubs — installed before importing the module under test. +// --------------------------------------------------------------------------- + +interface RunScoutsResult { + scouts: ScoutResult[]; + sticky: Set; + bypass: Set; +} + +interface FilterResult { + kept: string[]; + trace: { judged: string[]; dropped: string[] }; + failureReason?: string; +} + +interface WalkResult { + pages: Set; + levels: Array<{ + node: string; + considered: string[]; + descended: string[]; + skipped: string[]; + reasoning: string; + }>; +} + +interface ExpandResult { + pulled: Set; + expansions: Array<{ from: string; pulled: string[] }>; +} + +interface GateResult { + decision: GateDecision; + selectedSlugs: string[]; +} + +/** + * Per-pass-programmable lane state. The mock factories close over these live + * refs; each test rewires them before calling `runRetrievalLoop`. List-valued + * fields are consumed pass-by-pass (one entry per pass) so a multi-pass test + * can script a different verdict per pass. + */ +const lane = { + scouts: [] as RunScoutsResult[], + filter: [] as FilterResult[], + walk: [] as WalkResult[], + edges: [] as ExpandResult[], + gate: [] as GateResult[], +}; + +/** Records the args the loop passed each lane, one entry per call. */ +const laneCalls = { + scouts: [] as Array<{ nowText: string }>, + filter: [] as Array<{ nowText: string; dense: ScoutResult }>, + walk: [] as Array<{ + nowText: string; + seeds: string[]; + scouts: ScoutResult[]; + }>, + edges: [] as Array<{ seeds: string[] }>, + gate: [] as Array<{ + nowText: string; + passNumber: number; + candidates: string[]; + sticky: string[]; + }>, +}; + +/** Pop the next scripted value for a pass, reusing the last entry if exhausted. */ +function nextOf(list: T[], index: number): T { + return list[Math.min(index, list.length - 1)]; +} + +let scoutCallCount = 0; +let walkCallCount = 0; +let edgeCallCount = 0; +let gateCallCount = 0; + +mock.module("../scouts.js", () => ({ + runScouts: async (input: RetrievalInput): Promise => { + laneCalls.scouts.push({ nowText: input.nowText }); + return nextOf(lane.scouts, scoutCallCount++); + }, +})); + +mock.module("../filter.js", () => ({ + filterDenseHits: async (args: { + input: RetrievalInput; + dense: ScoutResult; + }): Promise => { + laneCalls.filter.push({ nowText: args.input.nowText, dense: args.dense }); + // Filter calls share the scout pass index (one filter call per dense pass). + return nextOf(lane.filter, laneCalls.filter.length - 1); + }, +})); + +mock.module("../tree-walk.js", () => ({ + runTreeWalk: async (args: { + input: RetrievalInput; + seeds: string[]; + scouts: ScoutResult[]; + }): Promise => { + laneCalls.walk.push({ + nowText: args.input.nowText, + seeds: args.seeds, + scouts: args.scouts, + }); + return nextOf(lane.walk, walkCallCount++); + }, +})); + +mock.module("../edges.js", () => ({ + expandEdges: async (args: { + seeds: Iterable; + }): Promise => { + laneCalls.edges.push({ seeds: [...args.seeds] }); + return nextOf(lane.edges, edgeCallCount++); + }, +})); + +mock.module("../gate.js", () => ({ + runGate: async (args: { + input: RetrievalInput; + candidates: Set; + sticky: Set; + passNumber: number; + }): Promise => { + laneCalls.gate.push({ + nowText: args.input.nowText, + passNumber: args.passNumber, + candidates: [...args.candidates], + sticky: [...args.sticky], + }); + return nextOf(lane.gate, gateCallCount++); + }, +})); + +// The loop calls these index builders only to hand opaque handles to the +// (stubbed) tree walk. The stubs return harmless empty values. +mock.module("../tree-index.js", () => ({ + getTreeIndex: async () => ({ + nodes: new Map(), + childrenByNode: new Map(), + parentsByNode: new Map(), + pageParents: new Map(), + root: "_root", + }), +})); + +mock.module("../../v2/page-index.js", () => ({ + getPageIndex: async () => ({ + entries: [], + bySlug: new Map(), + byId: new Map(), + rendered: "", + }), +})); + +const { runRetrievalLoop } = await import("../loop.js"); + +// --------------------------------------------------------------------------- +// Fixtures. +// --------------------------------------------------------------------------- + +/** Opaque DB sentinel — the stubbed scout lane never dereferences it. */ +const db = {} as DrizzleDb; + +interface LaneConfig { + hot?: boolean; + sparse?: boolean; + dense?: boolean; + tree?: boolean; + edges?: boolean; +} + +/** + * Minimal `RetrievalInput`. Only `nowText` and `config.memory.v3` (passCap + + * lanes) are read by the loop; the lanes are stubbed so the rest is inert. + */ +function makeInput(opts?: { + nowText?: string; + passCap?: number; + lanes?: LaneConfig; +}): RetrievalInput { + const lanes = { + hot: true, + sparse: true, + dense: true, + tree: true, + edges: true, + ...opts?.lanes, + }; + return { + workspaceDir: "/tmp/does-not-matter", + recentTurnPairs: [], + nowText: opts?.nowText ?? "NOW", + priorEverInjected: [], + config: { + memory: { v3: { passCap: opts?.passCap ?? 3, lanes } }, + } as unknown as RetrievalInput["config"], + }; +} + +function scout(lane: ScoutResult["lane"], slugs: string[]): ScoutResult { + return { lane, slugs }; +} + +function readyGate(selected: string[]): GateResult { + return { decision: { decision: "ready" }, selectedSlugs: selected }; +} + +function moreGate(selected: string[], questions: string[]): GateResult { + return { decision: { decision: "more", questions }, selectedSlugs: selected }; +} + +function reset(): void { + lane.scouts = []; + lane.filter = []; + lane.walk = []; + lane.edges = []; + lane.gate = []; + laneCalls.scouts = []; + laneCalls.filter = []; + laneCalls.walk = []; + laneCalls.edges = []; + laneCalls.gate = []; + scoutCallCount = 0; + walkCallCount = 0; + edgeCallCount = 0; + gateCallCount = 0; +} + +beforeEach(reset); + +// --------------------------------------------------------------------------- +// Tests. +// --------------------------------------------------------------------------- + +describe("runRetrievalLoop — single pass", () => { + test("ready path composes a valid RetrievalOutput with per-lane source tags", async () => { + lane.scouts = [ + { + scouts: [ + scout("hot", ["a"]), + scout("sparse", ["b"]), + scout("dense", ["c", "d"]), + ], + sticky: new Set(["a", "b"]), + bypass: new Set(["b"]), + }, + ]; + lane.filter = [{ kept: ["c"], trace: { judged: ["d"], dropped: ["d"] } }]; + lane.walk = [ + { + pages: new Set(["t1"]), + levels: [ + { + node: "_root", + considered: ["sub"], + descended: ["sub"], + skipped: [], + reasoning: "r", + }, + ], + }, + ]; + lane.edges = [ + { pulled: new Set(["e1"]), expansions: [{ from: "a", pulled: ["e1"] }] }, + ]; + lane.gate = [readyGate(["a", "b", "c", "t1", "e1"])]; + + const out: RetrievalOutput = await runRetrievalLoop(makeInput(), { db }); + + expect(out.selectedSlugs).toEqual(["a", "b", "c", "t1", "e1"]); + // sourceBySlug tags each slug with the lane that first surfaced it. + expect(out.sourceBySlug.get("a")).toBe("hot"); + expect(out.sourceBySlug.get("b")).toBe("sparse"); + expect(out.sourceBySlug.get("c")).toBe("dense"); + expect(out.sourceBySlug.get("t1")).toBe("tree"); + expect(out.sourceBySlug.get("e1")).toBe("edge"); + // Dropped dense candidate `d` was filtered out — never tagged. + expect(out.sourceBySlug.has("d")).toBe(false); + + // Exactly one pass, with all four lane sub-traces present. + expect(out.trace?.passes).toHaveLength(1); + const pass = out.trace!.passes[0]; + expect(pass.passNumber).toBe(1); + expect(pass.scouts).toHaveLength(3); + expect(pass.treeLevels).toHaveLength(1); + expect(pass.edgeExpansions).toHaveLength(1); + expect(pass.gate).toEqual({ decision: "ready" }); + + expect(out.failureReason).toBeNull(); + expect(out.cost?.ms).toBeGreaterThanOrEqual(0); + }); + + test("dense lane is filtered before seeding tree + gate", async () => { + lane.scouts = [ + { + scouts: [scout("dense", ["keep", "drop"])], + sticky: new Set(), + bypass: new Set(), + }, + ]; + lane.filter = [ + { + kept: ["keep"], + trace: { judged: ["keep", "drop"], dropped: ["drop"] }, + }, + ]; + lane.walk = [{ pages: new Set(), levels: [] }]; + lane.edges = [{ pulled: new Set(), expansions: [] }]; + lane.gate = [readyGate(["keep"])]; + + const out = await runRetrievalLoop(makeInput(), { db }); + + // The filter saw the full dense lane. + expect(laneCalls.filter[0].dense.slugs).toEqual(["keep", "drop"]); + // Only the kept dense slug seeds the tree walk; `drop` never reaches it. + expect(laneCalls.walk[0].seeds).toEqual(["keep"]); + // Gate's candidate set excludes the dropped dense slug. + expect(laneCalls.gate[0].candidates).toEqual(["keep"]); + expect(out.selectedSlugs).toEqual(["keep"]); + }); +}); + +describe("runRetrievalLoop — multi pass", () => { + test("gate 'more' then 'ready' runs two passes and threads questions into NOW", async () => { + lane.scouts = [ + { + scouts: [scout("dense", ["p1"])], + sticky: new Set(), + bypass: new Set(), + }, + { + scouts: [scout("dense", ["p2"])], + sticky: new Set(), + bypass: new Set(), + }, + ]; + lane.filter = [ + { kept: ["p1"], trace: { judged: ["p1"], dropped: [] } }, + { kept: ["p2"], trace: { judged: ["p2"], dropped: [] } }, + ]; + lane.walk = [ + { pages: new Set(), levels: [] }, + { pages: new Set(), levels: [] }, + ]; + lane.edges = [ + { pulled: new Set(), expansions: [] }, + { pulled: new Set(), expansions: [] }, + ]; + lane.gate = [moreGate(["p1"], ["what about X?"]), readyGate(["p1", "p2"])]; + + const out = await runRetrievalLoop( + makeInput({ nowText: "BASE", passCap: 3 }), + { db }, + ); + + // Two passes ran. + expect(out.trace?.passes).toHaveLength(2); + expect(out.trace!.passes[0].gate).toEqual({ + decision: "more", + questions: ["what about X?"], + }); + expect(out.trace!.passes[1].gate).toEqual({ decision: "ready" }); + + // Pass 1 used the base NOW verbatim; pass 2's NOW carried the gate's + // generated follow-up question — the standing context is not rewritten. + expect(laneCalls.scouts[0].nowText).toBe("BASE"); + expect(laneCalls.scouts[1].nowText).toContain("BASE"); + expect(laneCalls.scouts[1].nowText).toContain("what about X?"); + + // Final selection is the last (ready) pass's selection. + expect(out.selectedSlugs).toEqual(["p1", "p2"]); + }); + + test("passCap force-exits with the current selection when the gate keeps asking for more", async () => { + lane.scouts = [ + { scouts: [scout("dense", ["p"])], sticky: new Set(), bypass: new Set() }, + ]; + lane.filter = [{ kept: ["p"], trace: { judged: ["p"], dropped: [] } }]; + lane.walk = [{ pages: new Set(), levels: [] }]; + lane.edges = [{ pulled: new Set(), expansions: [] }]; + // Gate always says "more"; reused across every pass via nextOf. + lane.gate = [moreGate(["p"], ["again?"])]; + + const out = await runRetrievalLoop(makeInput({ passCap: 2 }), { db }); + + // Capped at passCap passes despite the gate never saying ready. + expect(out.trace?.passes).toHaveLength(2); + expect(gateCallCount).toBe(2); + expect(out.selectedSlugs).toEqual(["p"]); + }); +}); + +describe("runRetrievalLoop — lane toggles", () => { + test("tree + edge lanes off removes their candidates and trace fields", async () => { + lane.scouts = [ + { scouts: [scout("dense", ["s"])], sticky: new Set(), bypass: new Set() }, + ]; + lane.filter = [{ kept: ["s"], trace: { judged: ["s"], dropped: [] } }]; + // These would contribute t1/e1 if their lanes ran — they must not. + lane.walk = [ + { + pages: new Set(["t1"]), + levels: [ + { + node: "_root", + considered: [], + descended: [], + skipped: [], + reasoning: "", + }, + ], + }, + ]; + lane.edges = [ + { pulled: new Set(["e1"]), expansions: [{ from: "s", pulled: ["e1"] }] }, + ]; + lane.gate = [readyGate(["s"])]; + + const out = await runRetrievalLoop( + makeInput({ lanes: { tree: false, edges: false } }), + { db }, + ); + + // Disabled lanes were never called. + expect(laneCalls.walk).toHaveLength(0); + expect(laneCalls.edges).toHaveLength(0); + // Their would-be candidates never entered the gate or the selection. + expect(laneCalls.gate[0].candidates).toEqual(["s"]); + expect(out.sourceBySlug.has("t1")).toBe(false); + expect(out.sourceBySlug.has("e1")).toBe(false); + // Trace omits the disabled lanes' fields. + expect(out.trace!.passes[0].treeLevels).toBeUndefined(); + expect(out.trace!.passes[0].edgeExpansions).toBeUndefined(); + }); + + test("edge lane on by default expands over the accumulated candidate set", async () => { + lane.scouts = [ + { + scouts: [scout("hot", ["h"]), scout("dense", ["d"])], + sticky: new Set(["h"]), + bypass: new Set(), + }, + ]; + lane.filter = [{ kept: ["d"], trace: { judged: ["d"], dropped: [] } }]; + lane.walk = [{ pages: new Set(["t"]), levels: [] }]; + lane.edges = [ + { pulled: new Set(["x"]), expansions: [{ from: "d", pulled: ["x"] }] }, + ]; + lane.gate = [readyGate(["h", "d", "t", "x"])]; + + await runRetrievalLoop(makeInput(), { db }); + + // Edge expansion seeds over every accumulated confident slug (hot, dense, + // tree) — not just the scouts. + expect(laneCalls.edges[0].seeds).toEqual( + expect.arrayContaining(["h", "d", "t"]), + ); + }); +}); + +describe("runRetrievalLoop — failure + cost", () => { + test("surfaces a filter failureReason on the output", async () => { + lane.scouts = [ + { scouts: [scout("dense", ["d"])], sticky: new Set(), bypass: new Set() }, + ]; + lane.filter = [ + { + kept: ["d"], + trace: { judged: ["d"], dropped: [] }, + failureReason: "no_provider", + }, + ]; + lane.walk = [{ pages: new Set(), levels: [] }]; + lane.edges = [{ pulled: new Set(), expansions: [] }]; + lane.gate = [readyGate(["d"])]; + + const out = await runRetrievalLoop(makeInput(), { db }); + + expect(out.failureReason).toBe("no_provider"); + }); + + test("cost.ms accumulates across passes", async () => { + lane.scouts = [ + { scouts: [scout("dense", ["p"])], sticky: new Set(), bypass: new Set() }, + ]; + lane.filter = [{ kept: ["p"], trace: { judged: ["p"], dropped: [] } }]; + lane.walk = [{ pages: new Set(), levels: [] }]; + lane.edges = [{ pulled: new Set(), expansions: [] }]; + lane.gate = [moreGate(["p"], ["q"])]; + + const out = await runRetrievalLoop(makeInput({ passCap: 3 }), { db }); + + expect(out.trace?.passes).toHaveLength(3); + expect(out.cost?.ms).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/assistant/src/memory/v3/loop.ts b/assistant/src/memory/v3/loop.ts new file mode 100644 index 00000000000..0763ecf8bf6 --- /dev/null +++ b/assistant/src/memory/v3/loop.ts @@ -0,0 +1,258 @@ +/** + * Memory v3 — retrieval-loop orchestration. + * + * The composition layer that wires the v3 lanes into a single bounded-descent + * retrieval loop. Each pass runs the lanes in a fixed order: + * + * 1. {@link runScouts} — always-on hot / sparse / dense fanout. Surfaces + * candidate slugs plus the `sticky` (keep-in-the- + * running) and `bypass` (skip-the-tree) sets. + * 2. {@link filterDenseHits} — one cheap LLM call over the *dense* lane only. + * Hot + near-exact-sparse hits arrive via + * sticky/bypass and are never judged; the dense + * near-neighbors are filtered down to meaningful + * associations. + * 3. {@link runTreeWalk} — scout-seeded hierarchical descent. Seeded by the + * surviving scout slugs (their tree parents) so + * descent starts near where the lanes landed but + * still fans out from the root. + * 4. {@link expandEdges} — provider-free 1–2 hop curated-graph expansion + * over every accumulated confident seed. + * 5. {@link runGate} — one capable LLM call over the unioned candidate + * set. Returns `ready` (finalize) or `more` + * (its generated follow-up questions seed the next + * pass's query). + * + * Pass control. The loop runs at most `config.memory.v3.passCap` passes. When + * the gate says `more` and another pass is allowed, the gate's questions become + * the next pass's query (folded into `nowText`); otherwise the loop force-exits + * with the current selection. The standing-context files conveyed via + * `input.nowText` are consumed as situational context for the scouts, descent, + * and gate — the loop selects concept pages to layer on top and NEVER rewrites + * or re-injects the standing-context files. + * + * Lane toggles. `config.memory.v3.lanes.tree` and `.edges` gate the tree-walk + * and edge-expansion lanes here; the hot/sparse/dense toggles are honored inside + * {@link runScouts}. Toggling a lane off removes its contribution from the + * candidate set so the offline harness can measure each lane's marginal recall. + * + * Cross-pass accumulation. A `visited` candidate accumulator deduplicates slugs + * across passes by canonical slug, tagging each with the first lane that + * surfaced it (`sourceBySlug`). The full {@link DescentTrace} carries one + * {@link DescentPass} per pass (scouts / treeLevels / edgeExpansions / gate), + * and {@link RetrievalCost} (wall-clock `ms`, the one dimension observable at + * this composition layer) accumulates across every pass. + */ + +import type { DrizzleDb } from "../db-connection.js"; +import type { + RetrievalCost, + RetrievalInput, + RetrievalOutput, +} from "../v2/harness/retriever.js"; +import type { + DescentPass, + DescentTrace, + GateDecision, +} from "../v2/harness/trace.js"; +import { getPageIndex } from "../v2/page-index.js"; +import { expandEdges } from "./edges.js"; +import { filterDenseHits } from "./filter.js"; +import { runGate } from "./gate.js"; +import { runScouts } from "./scouts.js"; +import { getTreeIndex } from "./tree-index.js"; +import { runTreeWalk } from "./tree-walk.js"; + +/** Lane label used to tag each selected slug's provenance in `sourceBySlug`. */ +type LaneSource = "hot" | "sparse" | "dense" | "tree" | "edge"; + +/** Injected dependencies — the SQLite handle the scout hot lane reads. */ +export interface RetrievalLoopDeps { + db: DrizzleDb; +} + +/** + * Run the full v3 retrieval loop for one turn. + * + * Composes the scout / filter / tree / edge / gate lanes over up to + * `config.memory.v3.passCap` passes, returning the P1 {@link RetrievalOutput}: + * the final selection, per-lane provenance, the complete multi-pass + * {@link DescentTrace}, and accumulated {@link RetrievalCost}. `failureReason` + * is set when the dense filter had to fail open on any pass (the loop still + * returns a usable selection — the filter degradation is recorded, not fatal). + */ +export async function runRetrievalLoop( + input: RetrievalInput, + deps: RetrievalLoopDeps, +): Promise { + const v3 = input.config.memory.v3; + const passCap = Math.max(1, v3.passCap); + const lanes = v3.lanes; + + // Cross-pass accumulators. + const sourceBySlug = new Map(); + const sticky = new Set(); + const passes: DescentPass[] = []; + // `ms` is the one cost dimension observable at this composition layer — the + // lanes consume their own LLM usage internally and don't surface tokens. + const cost: RetrievalCost & { ms: number } = { ms: 0 }; + let failureReason: string | null = null; + + // The query feeding each pass. Pass 1 uses the turn's NOW context verbatim; + // a gate `more` verdict appends its generated follow-up questions for the + // next pass. The standing-context files are never rewritten — questions are + // layered on as additional situational context only. + let passNowText = input.nowText; + + // Final selection — replaced by the gate each pass; the last pass's selection + // is what the loop returns (capped at passCap on a forced exit). + let selectedSlugs: string[] = []; + + for (let passNumber = 1; passNumber <= passCap; passNumber++) { + const passStart = Date.now(); + const passInput: RetrievalInput = { ...input, nowText: passNowText }; + + // 1. Scouts — always-on hot / sparse / dense fanout. + const scoutResult = await runScouts(passInput, { db: deps.db }); + for (const slug of scoutResult.sticky) sticky.add(slug); + + // Tag hot + sparse scout hits with their lane (first lane wins). Dense + // slugs are tagged only if they survive the filter below — a dropped dense + // near-neighbor never enters the candidate set, so it earns no source tag. + for (const scout of scoutResult.scouts) { + if (scout.lane === "dense") continue; + for (const slug of scout.slugs) tagSlug(sourceBySlug, slug, scout.lane); + } + + // 2. Dense filter — judges only the dense lane (hot/sparse bypass it). The + // surviving dense slugs replace the raw dense candidates in the running set. + const denseScout = scoutResult.scouts.find((s) => s.lane === "dense"); + const candidates = new Set(); + + // Hot + sparse lane hits enter the candidate set directly. + for (const scout of scoutResult.scouts) { + if (scout.lane === "dense") continue; + for (const slug of scout.slugs) candidates.add(slug); + } + + if (denseScout) { + const filtered = await filterDenseHits({ + input: passInput, + dense: denseScout, + sticky: scoutResult.sticky, + bypass: scoutResult.bypass, + }); + for (const slug of filtered.kept) { + candidates.add(slug); + tagSlug(sourceBySlug, slug, "dense"); + } + if (filtered.failureReason !== undefined) { + failureReason = filtered.failureReason; + } + } + + // The surviving scout slugs (kept dense + hot + sparse) seed the tree walk. + const survivingSeeds = [...candidates]; + + // 3. Tree walk — scout-seeded hierarchical descent. Gated by `lanes.tree`. + let treeLevels: DescentPass["treeLevels"]; + if (lanes.tree) { + const [tree, pages] = await Promise.all([ + getTreeIndex(passInput.workspaceDir), + getPageIndex(passInput.workspaceDir), + ]); + const walk = await runTreeWalk({ + input: passInput, + tree, + pages, + scouts: scoutResult.scouts, + seeds: survivingSeeds, + }); + treeLevels = walk.levels; + for (const slug of walk.pages) { + candidates.add(slug); + tagSlug(sourceBySlug, slug, "tree"); + } + } + + // 4. Edge expansion — 1–2 hop curated-graph pull over every accumulated + // confident seed. Gated by `lanes.edges`. + let edgeExpansions: DescentPass["edgeExpansions"]; + if (lanes.edges) { + const expansion = await expandEdges({ + workspaceDir: passInput.workspaceDir, + seeds: [...candidates], + }); + edgeExpansions = expansion.expansions; + for (const slug of expansion.pulled) { + candidates.add(slug); + tagSlug(sourceBySlug, slug, "edge"); + } + } + + // 5. Gate — one capable LLM call over the unioned candidate set. + const gateResult = await runGate({ + input: passInput, + candidates, + sticky, + passNumber, + }); + selectedSlugs = gateResult.selectedSlugs; + + // Record this pass's trace. + const pass: DescentPass = { + passNumber, + scouts: scoutResult.scouts, + ...(treeLevels !== undefined ? { treeLevels } : {}), + ...(edgeExpansions !== undefined ? { edgeExpansions } : {}), + gate: gateResult.decision, + }; + passes.push(pass); + + cost.ms += Date.now() - passStart; + + // Pass control. A `more` verdict with another pass available feeds the + // gate's generated questions into the next pass's query; otherwise (ready, + // or passCap reached) the loop exits with the current selection. + if (gateResult.decision.decision !== "more") break; + if (passNumber >= passCap) break; + passNowText = nextPassNowText(input.nowText, gateResult.decision); + } + + const trace: DescentTrace = { passes }; + return { + selectedSlugs, + sourceBySlug, + trace, + cost, + failureReason, + }; +} + +/** + * Tag `slug`'s provenance with `lane`, keeping the first lane that surfaced it. + * The pass order (scouts → tree → edge) gives a deterministic precedence: a + * slug first seen by a scout lane keeps that label even when the tree or edge + * lane re-surfaces it. + */ +function tagSlug( + sourceBySlug: Map, + slug: string, + lane: LaneSource, +): void { + if (!sourceBySlug.has(slug)) sourceBySlug.set(slug, lane); +} + +/** + * Build the next pass's NOW text from the original standing context plus the + * gate's generated follow-up questions. The standing-context files are never + * rewritten — the questions are appended as an additional situational-context + * block the scouts/descent/gate read on top of NOW. With no questions the + * original NOW is reused verbatim. + */ +function nextPassNowText(baseNowText: string, decision: GateDecision): string { + const questions = decision.questions ?? []; + if (questions.length === 0) return baseNowText; + const block = `\n${questions.join("\n")}\n`; + return `${baseNowText}\n\n${block}`; +}