From f31979aa5f051e930d0afeb0e08aac20aea51f47 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 02:43:55 -0700 Subject: [PATCH 01/59] refactor(core): introduce runtime container composition root (phase 1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an explicit, isolated composition root for Atomicmemory-core and remove the last module-level mutable wiring (setEntityRepository). Part of the rearchitecture plan at atomicmemory-research/docs/atomicmemory-core-rearchitecture-plan-2026-04-16.md. New composition layer (src/app/): - runtime-container.ts: createCoreRuntime({ pool, config? }) composes repositories and services from explicit deps. Pool is required — the composition root is a pure function with no side-effectful singleton imports at module load. Config defaults to the module-level value because it is pure env-derived data. - startup-checks.ts: checkEmbeddingDimensions() returns a structured result instead of calling process.exit. - create-app.ts: createApp(runtime) builds the Express app, wiring routers and middleware onto a runtime container. - __tests__/runtime-container.test.ts: 9 composition tests covering container wiring, startup-check branches, and the app factory. server.ts: thinned from 101 → 71 lines. Imports the singleton pool once and passes it to createCoreRuntime({ pool }). Shutdown closes runtime.pool so a custom bootstrap closes the right pool. Preserves all original named exports (app, service, repo, claimRepo, trustRepo, linkRepo) plus the new `runtime` export. search-pipeline.ts: removed module-level entityRepo and the setEntityRepository() setter. Threaded entityRepo through as an explicit parameter to runSearchPipelineWithTrace and all ten internal helpers that consume it. The one call site in memory-search.ts passes deps.entities. Test fixtures and current-state-retrieval-regression dropped their references to the removed setEntityRepository export. Preserved: - Endpoint behavior unchanged, routes unchanged, MemoryService kept as a thin compatibility wrapper. - No DI framework, no package split, no config/index/route churn. Validation: - npx tsc --noEmit: clean - Targeted run (composition, current-state retrieval regression, route-validation, smoke): 19/19 pass - Full test suite: 884 pass / 1 pre-existing fail (deployment-config regex predates this change; confirmed via stash against main) - fallow --no-cache: 0 above threshold, maintainability 90.9 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/__tests__/runtime-container.test.ts | 98 +++++++++++++++ src/app/create-app.ts | 39 ++++++ src/app/runtime-container.ts | 95 +++++++++++++++ src/app/startup-checks.ts | 70 +++++++++++ src/server.ts | 113 +++++++----------- ...current-state-retrieval-regression.test.ts | 2 - src/services/__tests__/test-fixtures.ts | 1 - src/services/memory-search.ts | 2 +- src/services/search-pipeline.ts | 45 ++++--- 9 files changed, 371 insertions(+), 94 deletions(-) create mode 100644 src/app/__tests__/runtime-container.test.ts create mode 100644 src/app/create-app.ts create mode 100644 src/app/runtime-container.ts create mode 100644 src/app/startup-checks.ts diff --git a/src/app/__tests__/runtime-container.test.ts b/src/app/__tests__/runtime-container.test.ts new file mode 100644 index 0000000..0d7e467 --- /dev/null +++ b/src/app/__tests__/runtime-container.test.ts @@ -0,0 +1,98 @@ +/** + * Phase 1A composition tests. + * + * Verifies the runtime container boots cleanly with explicit deps, and + * that startup checks return a structured result instead of exiting the + * process. These tests don't depend on a live database — they exercise + * the composition seam itself. + */ + +import { describe, it, expect, vi } from 'vitest'; +import pg from 'pg'; +import { createCoreRuntime } from '../runtime-container.js'; +import { checkEmbeddingDimensions } from '../startup-checks.js'; +import { createApp } from '../create-app.js'; +import { config } from '../../config.js'; + +function stubPool(rows: Array<{ typmod: number }> = []): pg.Pool { + return { query: vi.fn(async () => ({ rows })) } as unknown as pg.Pool; +} + +describe('createCoreRuntime', () => { + it('composes a runtime with explicit pool dep', () => { + const pool = stubPool(); + const runtime = createCoreRuntime({ pool }); + expect(runtime.pool).toBe(pool); + expect(runtime.config).toBe(config); + expect(runtime.repos.memory).toBeDefined(); + expect(runtime.repos.claims).toBeDefined(); + expect(runtime.repos.trust).toBeDefined(); + expect(runtime.repos.links).toBeDefined(); + expect(runtime.services.memory).toBeDefined(); + }); + + it('entity repo is null when entityGraphEnabled is false', () => { + const pool = stubPool(); + const cfg = { ...config, entityGraphEnabled: false }; + const runtime = createCoreRuntime({ pool, config: cfg }); + expect(runtime.repos.entities).toBeNull(); + }); + + it('entity repo is constructed when entityGraphEnabled is true', () => { + const pool = stubPool(); + const cfg = { ...config, entityGraphEnabled: true }; + const runtime = createCoreRuntime({ pool, config: cfg }); + expect(runtime.repos.entities).not.toBeNull(); + }); + + it('lesson repo matches lessonsEnabled flag', () => { + const pool = stubPool(); + expect(createCoreRuntime({ pool, config: { ...config, lessonsEnabled: false } }).repos.lessons).toBeNull(); + expect(createCoreRuntime({ pool, config: { ...config, lessonsEnabled: true } }).repos.lessons).not.toBeNull(); + }); +}); + +describe('checkEmbeddingDimensions', () => { + it('returns ok=false when memories.embedding column is missing', async () => { + const pool = stubPool([]); + const result = await checkEmbeddingDimensions(pool, config); + expect(result.ok).toBe(false); + expect(result.message).toContain('run npm run migrate'); + }); + + it('returns ok=false when DB dims differ from config', async () => { + const pool = stubPool([{ typmod: 1024 }]); + const cfg = { ...config, embeddingDimensions: 1536 }; + const result = await checkEmbeddingDimensions(pool, cfg); + expect(result.ok).toBe(false); + expect(result.dbDims).toBe(1024); + expect(result.configDims).toBe(1536); + expect(result.message).toContain('1024 dimensions'); + expect(result.message).toContain('EMBEDDING_DIMENSIONS=1536'); + }); + + it('returns ok=true when DB dims match config', async () => { + const pool = stubPool([{ typmod: 1024 }]); + const cfg = { ...config, embeddingDimensions: 1024 }; + const result = await checkEmbeddingDimensions(pool, cfg); + expect(result.ok).toBe(true); + expect(result.dbDims).toBe(1024); + }); + + it('returns ok=true when DB typmod is unset (0 or negative)', async () => { + const pool = stubPool([{ typmod: -1 }]); + const result = await checkEmbeddingDimensions(pool, config); + expect(result.ok).toBe(true); + expect(result.dbDims).toBeNull(); + }); +}); + +describe('createApp', () => { + it('returns an Express app wired from a runtime container', () => { + const pool = stubPool(); + const runtime = createCoreRuntime({ pool }); + const app = createApp(runtime); + expect(typeof app.use).toBe('function'); + expect(typeof app.listen).toBe('function'); + }); +}); diff --git a/src/app/create-app.ts b/src/app/create-app.ts new file mode 100644 index 0000000..2f2a989 --- /dev/null +++ b/src/app/create-app.ts @@ -0,0 +1,39 @@ +/** + * Express application factory — wires routers onto a runtime container. + * + * Separates composition (done in `runtime-container.ts`) from HTTP + * transport concerns. Tests and harnesses can create an Express app from + * any runtime container without touching the server bootstrap. + */ + +import express from 'express'; +import { createAgentRouter } from '../routes/agents.js'; +import { createMemoryRouter } from '../routes/memories.js'; +import type { CoreRuntime } from './runtime-container.js'; + +/** + * Build an Express application from a composed runtime container. The + * runtime owns all deps; this module only wires HTTP concerns (CORS, body + * parsing, routes, health). + */ +export function createApp(runtime: CoreRuntime): ReturnType { + const app = express(); + + app.use((_req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + next(); + }); + + app.use(express.json({ limit: '1mb' })); + + app.use('/memories', createMemoryRouter(runtime.services.memory)); + app.use('/agents', createAgentRouter(runtime.repos.trust)); + + app.get('/health', (_req, res) => { + res.json({ status: 'ok' }); + }); + + return app; +} diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts new file mode 100644 index 0000000..6fdb9cd --- /dev/null +++ b/src/app/runtime-container.ts @@ -0,0 +1,95 @@ +/** + * Core runtime container — the explicit composition root for Atomicmemory-core. + * + * Owns the construction of config, pool, repositories, and services so + * startup (`server.ts`), tests, and in-process research harnesses all boot + * through the same seam. Replaces the hidden singleton wiring that used to + * live inline in `server.ts`. + * + * Phase 1A of the rearchitecture — see + * atomicmemory-research/docs/atomicmemory-core-rearchitecture-plan-2026-04-16.md. + */ + +import pg from 'pg'; +import { config as defaultConfig } from '../config.js'; +import { AgentTrustRepository } from '../db/agent-trust-repository.js'; +import { ClaimRepository } from '../db/claim-repository.js'; +import { LinkRepository } from '../db/link-repository.js'; +import { MemoryRepository } from '../db/memory-repository.js'; +import { EntityRepository } from '../db/repository-entities.js'; +import { LessonRepository } from '../db/repository-lessons.js'; +import { MemoryService } from '../services/memory-service.js'; + +/** + * Public runtime configuration subset. Phase 1A exposes the full config + * object for compatibility; later phases will split public runtime config + * from internal policy flags. + */ +export type CoreRuntimeConfig = typeof defaultConfig; + +/** Repositories constructed by the runtime container. */ +export interface CoreRuntimeRepos { + memory: MemoryRepository; + claims: ClaimRepository; + trust: AgentTrustRepository; + links: LinkRepository; + entities: EntityRepository | null; + lessons: LessonRepository | null; +} + +/** Services constructed on top of repositories. */ +export interface CoreRuntimeServices { + memory: MemoryService; +} + +/** + * Explicit dependency bundle accepted by `createCoreRuntime`. + * + * `pool` is required — the composition root never reaches around to + * import the singleton `pg.Pool` itself. `config` is optional because + * it is pure env-derived data with no I/O side effects at construction; + * tests that want a custom config override it, everyone else inherits + * the module-level default. + */ +export interface CoreRuntimeDeps { + pool: pg.Pool; + config?: CoreRuntimeConfig; +} + +/** The composed runtime — single source of truth for route registration. */ +export interface CoreRuntime { + config: CoreRuntimeConfig; + pool: pg.Pool; + repos: CoreRuntimeRepos; + services: CoreRuntimeServices; +} + +/** + * Compose the core runtime. Instantiates repositories and the memory + * service from an explicit config and pool. No module-level mutation. + */ +export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime { + const cfg = deps.config ?? defaultConfig; + const { pool } = deps; + + const memory = new MemoryRepository(pool); + const claims = new ClaimRepository(pool); + const trust = new AgentTrustRepository(pool); + const links = new LinkRepository(pool); + const entities = cfg.entityGraphEnabled ? new EntityRepository(pool) : null; + const lessons = cfg.lessonsEnabled ? new LessonRepository(pool) : null; + + const service = new MemoryService( + memory, + claims, + entities ?? undefined, + lessons ?? undefined, + ); + + return { + config: cfg, + pool, + repos: { memory, claims, trust, links, entities, lessons }, + services: { memory: service }, + }; +} diff --git a/src/app/startup-checks.ts b/src/app/startup-checks.ts new file mode 100644 index 0000000..7045706 --- /dev/null +++ b/src/app/startup-checks.ts @@ -0,0 +1,70 @@ +/** + * Startup guard functions run before the HTTP server accepts traffic. + * + * Extracted from `server.ts` so tests can exercise individual checks and + * future startup guards can be added without growing the server bootstrap + * module. Phase 1A of the rearchitecture. + */ + +import pg from 'pg'; +import type { CoreRuntimeConfig } from './runtime-container.js'; + +/** + * Result of an embedding dimension check against the `memories.embedding` + * column. `ok=false` means the process should exit before serving traffic. + */ +export interface EmbeddingDimensionCheckResult { + ok: boolean; + dbDims: number | null; + configDims: number; + message: string; +} + +/** + * Verify DB embedding column dimensions match the configured embedding size. + * Catches the "expected N dimensions, not M" class of errors at startup, + * which would otherwise surface as opaque insert failures during ingest. + * + * This function never throws or exits — it returns a structured result so + * the caller decides how to react (log + exit at boot, throw in tests). + */ +export async function checkEmbeddingDimensions( + pool: pg.Pool, + config: CoreRuntimeConfig, +): Promise { + const { rows } = await pool.query<{ typmod: number }>( + `SELECT atttypmod AS typmod + FROM pg_attribute a + JOIN pg_class c ON a.attrelid = c.oid + WHERE c.relname = 'memories' AND a.attname = 'embedding'`, + ); + + if (rows.length === 0) { + return { + ok: false, + dbDims: null, + configDims: config.embeddingDimensions, + message: 'memories.embedding column not found — run npm run migrate first', + }; + } + + const dbDims = rows[0].typmod > 0 ? rows[0].typmod : null; + + if (dbDims !== null && dbDims !== config.embeddingDimensions) { + return { + ok: false, + dbDims, + configDims: config.embeddingDimensions, + message: + `DB vector column is ${dbDims} dimensions but EMBEDDING_DIMENSIONS=${config.embeddingDimensions}. ` + + `Fix: set EMBEDDING_DIMENSIONS=${dbDims} or run 'npm run migrate' to recreate the schema.`, + }; + } + + return { + ok: true, + dbDims, + configDims: config.embeddingDimensions, + message: `Embedding dimensions OK: config=${config.embeddingDimensions}, DB=${dbDims ?? 'unset'}`, + }; +} diff --git a/src/server.ts b/src/server.ts index e6f0c59..3b938ce 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,83 +1,52 @@ /** - * AtomicMemory Core API Server. - * Express server with memory ingest, search, and agent trust endpoints. + * AtomicMemory Core API Server — bootstrap entry point. + * + * Composes the runtime container, runs startup guards, builds the Express + * app, and starts listening. All composition logic lives in `./app/`; + * this file only owns the process lifecycle (boot → listen → shutdown). + * + * The `runtime` is the single source of truth for config, pool, repos, + * and services. Nothing in this file reaches around it to import + * singletons directly — if a consumer bootstraps with custom deps later, + * shutdown and lifecycle still act on the right graph. */ -import express from 'express'; import { pool } from './db/pool.js'; -import { AgentTrustRepository } from './db/agent-trust-repository.js'; -import { ClaimRepository } from './db/claim-repository.js'; -import { LinkRepository } from './db/link-repository.js'; -import { MemoryRepository } from './db/memory-repository.js'; -import { EntityRepository } from './db/repository-entities.js'; -import { LessonRepository } from './db/repository-lessons.js'; -import { MemoryService } from './services/memory-service.js'; -import { setEntityRepository } from './services/search-pipeline.js'; -import { createAgentRouter } from './routes/agents.js'; -import { createMemoryRouter } from './routes/memories.js'; -import { config } from './config.js'; +import { createCoreRuntime } from './app/runtime-container.js'; +import { createApp } from './app/create-app.js'; +import { checkEmbeddingDimensions } from './app/startup-checks.js'; -const app: ReturnType = express(); -app.use((_req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type'); - next(); -}); -app.use(express.json({ limit: '1mb' })); - -const repo = new MemoryRepository(pool); -const claimRepo = new ClaimRepository(pool); -const trustRepo = new AgentTrustRepository(pool); -const linkRepo = new LinkRepository(pool); -const entityRepo = config.entityGraphEnabled ? new EntityRepository(pool) : undefined; -if (entityRepo) setEntityRepository(entityRepo); -const lessonRepo = config.lessonsEnabled ? new LessonRepository(pool) : undefined; -const service = new MemoryService(repo, claimRepo, entityRepo, lessonRepo); +// Compose the runtime from explicit deps. The singleton pool is +// imported here (and only here) so the composition root itself has no +// side-effectful singleton dependencies. +const runtime = createCoreRuntime({ pool }); +const app = createApp(runtime); -app.use('/memories', createMemoryRouter(service)); -app.use('/agents', createAgentRouter(trustRepo)); - -app.get('/health', (_req, res) => { - res.json({ status: 'ok' }); -}); +// Re-export composed pieces for existing consumers (tests, research harnesses). +// These preserve the public surface from the previous server.ts. +const service = runtime.services.memory; +const repo = runtime.repos.memory; +const claimRepo = runtime.repos.claims; +const trustRepo = runtime.repos.trust; +const linkRepo = runtime.repos.links; -/** - * Verify DB embedding column dimensions match config before accepting traffic. - * Catches the "expected N dimensions, not M" class of errors at startup. - */ -async function checkEmbeddingDimensions(): Promise { - const { rows } = await pool.query<{ typmod: number }>( - `SELECT atttypmod AS typmod - FROM pg_attribute a - JOIN pg_class c ON a.attrelid = c.oid - WHERE c.relname = 'memories' AND a.attname = 'embedding'`, - ); - if (rows.length === 0) { - console.error('[startup] memories.embedding column not found — run npm run migrate first'); - process.exit(1); - } - const dbDims = rows[0].typmod > 0 ? rows[0].typmod : null; - if (dbDims !== null && dbDims !== config.embeddingDimensions) { - console.error( - `[startup] FATAL: DB vector column is ${dbDims} dimensions but EMBEDDING_DIMENSIONS=${config.embeddingDimensions}.\n` + - ` Fix: set EMBEDDING_DIMENSIONS=${dbDims} or run 'npm run migrate' to recreate the schema.`, - ); +async function bootstrap(): Promise { + const check = await checkEmbeddingDimensions(runtime.pool, runtime.config); + if (!check.ok) { + console.error(`[startup] FATAL: ${check.message}`); process.exit(1); } - console.log(`[startup] Embedding dimensions OK: config=${config.embeddingDimensions}, DB=${dbDims ?? 'unset'}`); -} + console.log(`[startup] ${check.message}`); -checkEmbeddingDimensions() - .then(() => { - app.listen(config.port, () => { - console.log(`AtomicMemory Core running on http://localhost:${config.port}`); - }); - }) - .catch((err) => { - console.error('[startup] Embedding dimension check failed:', err); - process.exit(1); + app.listen(runtime.config.port, () => { + console.log(`AtomicMemory Core running on http://localhost:${runtime.config.port}`); }); +} + +bootstrap().catch((err) => { + console.error('[startup] bootstrap failed:', err); + process.exit(1); +}); process.on('uncaughtException', (err) => { console.error('[FATAL] Uncaught exception:', err); @@ -90,12 +59,12 @@ process.on('unhandledRejection', (reason) => { process.on('SIGTERM', () => { console.log('[shutdown] Received SIGTERM, closing...'); - pool.end().then(() => process.exit(0)); + runtime.pool.end().then(() => process.exit(0)); }); process.on('SIGINT', () => { console.log('[shutdown] Received SIGINT, closing...'); - pool.end().then(() => process.exit(0)); + runtime.pool.end().then(() => process.exit(0)); }); -export { app, service, repo, claimRepo, trustRepo, linkRepo }; +export { app, service, repo, claimRepo, trustRepo, linkRepo, runtime }; diff --git a/src/services/__tests__/current-state-retrieval-regression.test.ts b/src/services/__tests__/current-state-retrieval-regression.test.ts index 96c1d20..7718f8d 100644 --- a/src/services/__tests__/current-state-retrieval-regression.test.ts +++ b/src/services/__tests__/current-state-retrieval-regression.test.ts @@ -31,7 +31,6 @@ vi.mock('../embedding.js', async () => { import { config } from '../../config.js'; import { pool } from '../../db/pool.js'; import { createServiceTestContext, unitVector, offsetVector } from '../../db/__tests__/test-fixtures.js'; -import { setEntityRepository } from '../search-pipeline.js'; const TEST_USER = 'current-state-retrieval-regression-user'; const OLD_CONVERSATION = [ @@ -60,7 +59,6 @@ describe('current-state retrieval regression', () => { beforeEach(async () => { mockChat.mockReset(); embeddingOverrides.clear(); - setEntityRepository(null); await claimRepo.deleteAll(); await repo.deleteAll(); registerEmbeddings(); diff --git a/src/services/__tests__/test-fixtures.ts b/src/services/__tests__/test-fixtures.ts index 1e30956..a35b41e 100644 --- a/src/services/__tests__/test-fixtures.ts +++ b/src/services/__tests__/test-fixtures.ts @@ -103,7 +103,6 @@ export function createSearchPipelineMockFactory( return { runSearchPipelineWithTrace: (...args: unknown[]) => mockRunSearchPipelineWithTrace(...args), generateLinks: () => {}, - setEntityRepository: () => {}, }; } diff --git a/src/services/memory-search.ts b/src/services/memory-search.ts index 2e3af0b..d9152c1 100644 --- a/src/services/memory-search.ts +++ b/src/services/memory-search.ts @@ -70,7 +70,7 @@ async function executeSearchStep( trace.stage('as-of-search', memories, { asOf }); return { memories, activeTrace: trace }; } - const pipelineResult = await runSearchPipelineWithTrace(deps.repo, userId, query, effectiveLimit, sourceSite, referenceTime, { + const pipelineResult = await runSearchPipelineWithTrace(deps.repo, deps.entities, userId, query, effectiveLimit, sourceSite, referenceTime, { namespaceScope, retrievalMode: retrievalOptions?.retrievalMode, searchStrategy: retrievalOptions?.searchStrategy, diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 26b9ead..91e954f 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -53,9 +53,6 @@ function shouldAutoSkipReranking(results: SearchResult[]): boolean { return topSim >= config.rerankSkipTopSimilarity && (topSim - secondSim) >= config.rerankSkipMinGap; } -/** Optional entity repository for entity-aware retrieval expansion. */ -let entityRepo: EntityRepository | null = null; - export interface SearchPipelineOptions { namespaceScope?: string; retrievalMode?: RetrievalMode; @@ -66,15 +63,12 @@ export interface SearchPipelineOptions { skipReranking?: boolean; } -export function setEntityRepository(repo: EntityRepository | null): void { - entityRepo = repo; -} - /** * Core search pipeline implementation with explicit trace collection. */ export async function runSearchPipelineWithTrace( repo: MemoryRepository, + entityRepo: EntityRepository | null, userId: string, query: string, limit: number, @@ -91,21 +85,21 @@ export async function runSearchPipelineWithTrace( // Phase 2: Entity-grounded query augmentation (zero-LLM) const augmentation = await timed('search.augmentation', () => applyQueryAugmentation( - userId, query, rawQueryEmbedding, trace, + entityRepo, userId, query, rawQueryEmbedding, trace, )); const queryEmbedding = augmentation.augmentedEmbedding; const searchQuery = augmentation.searchQuery; const initialResults = await timed('search.vector', () => runInitialRetrieval( - repo, userId, searchQuery, queryEmbedding, candidateDepth, sourceSite, referenceTime, options.searchStrategy, + repo, entityRepo, userId, searchQuery, queryEmbedding, candidateDepth, sourceSite, referenceTime, options.searchStrategy, )); const seededResults = await timed('search.hybrid-fallback', () => maybeApplyAbstractHybridFallback( - repo, userId, query, searchQuery, queryEmbedding, candidateDepth, sourceSite, referenceTime, + repo, entityRepo, userId, query, searchQuery, queryEmbedding, candidateDepth, sourceSite, referenceTime, options.retrievalMode, options.searchStrategy, initialResults, trace, )); console.log(`[search] Query: "${query}", Results: ${seededResults.length}`); - + trace.stage('initial', seededResults, { candidateDepth, hybrid: config.hybridSearchEnabled, @@ -117,7 +111,7 @@ export async function runSearchPipelineWithTrace( // Entity name co-retrieval const withCoRetrieval = await timed('search.co-retrieval', () => applyEntityNameCoRetrieval( - repo, userId, query, queryEmbedding, seededResults, candidateDepth, trace, + repo, entityRepo, userId, query, queryEmbedding, seededResults, candidateDepth, trace, )); const withSubjectExpansion = await timed('search.subject-query-expansion', () => applySubjectQueryExpansion( @@ -134,13 +128,14 @@ export async function runSearchPipelineWithTrace( // Query expansion const withExpansion = await timed('search.query-expansion', () => applyQueryExpansion( - repo, userId, query, queryEmbedding, temporalExpansion.memories, candidateDepth, trace, + repo, entityRepo, userId, query, queryEmbedding, temporalExpansion.memories, candidateDepth, trace, )); const repaired = options.skipRepairLoop ? { memories: withExpansion, queryText: searchQuery } : await timed('search.repair-loop', () => applyRepairLoop( repo, + entityRepo, query, queryEmbedding, withExpansion, @@ -192,6 +187,7 @@ export async function runSearchPipelineWithTrace( const selected = await timed('search.expansion-reranking', () => applyExpansionAndReranking( repo, + entityRepo, userId, searchQuery, results, @@ -222,6 +218,7 @@ export async function runSearchPipelineWithTrace( async function runInitialRetrieval( repo: MemoryRepository, + entityRepo: EntityRepository | null, userId: string, searchQuery: string, queryEmbedding: number[], @@ -242,6 +239,7 @@ async function runInitialRetrieval( } return runMemoryRrfRetrieval( repo, + entityRepo, userId, searchQuery, queryEmbedding, @@ -254,6 +252,7 @@ async function runInitialRetrieval( async function maybeApplyAbstractHybridFallback( repo: MemoryRepository, + entityRepo: EntityRepository | null, userId: string, rawQuery: string, searchQuery: string, @@ -273,6 +272,7 @@ async function maybeApplyAbstractHybridFallback( } const fallbackResults = await runMemoryRrfRetrieval( repo, + entityRepo, userId, searchQuery, queryEmbedding, @@ -290,6 +290,7 @@ async function maybeApplyAbstractHybridFallback( */ async function applyRepairLoop( repo: MemoryRepository, + entityRepo: EntityRepository | null, query: string, queryEmbedding: number[], initialResults: SearchResult[], @@ -316,6 +317,7 @@ async function applyRepairLoop( ? await repo.searchAtomicFactsHybrid(userId, rewrittenQuery, rewrittenEmbedding, candidateDepth, sourceSite, referenceTime) : await runMemoryRrfRetrieval( repo, + entityRepo, userId, rewrittenQuery, rewrittenEmbedding, @@ -363,6 +365,7 @@ async function applyRepairLoop( */ async function applyQueryExpansion( repo: MemoryRepository, + entityRepo: EntityRepository | null, userId: string, query: string, queryEmbedding: number[], @@ -405,6 +408,7 @@ async function applyQueryExpansion( * If disabled or no entities match, returns the original query and embedding unchanged. */ async function applyQueryAugmentation( + entityRepo: EntityRepository | null, userId: string, query: string, queryEmbedding: number[], @@ -443,6 +447,7 @@ async function applyQueryAugmentation( */ async function applyEntityNameCoRetrieval( repo: MemoryRepository, + entityRepo: EntityRepository | null, userId: string, query: string, queryEmbedding: number[], @@ -603,6 +608,7 @@ async function applySubjectQueryExpansion( */ async function applyExpansionAndReranking( repo: MemoryRepository, + entityRepo: EntityRepository | null, userId: string, query: string, results: SearchResult[], @@ -645,7 +651,7 @@ async function applyExpansionAndReranking( candidates = applyConcisenessPenalty(candidates); if (config.linkExpansionBeforeMMR && config.linkExpansionEnabled && config.mmrEnabled) { - const preExpanded = await expandWithLinks(repo, userId, candidates.slice(0, limit), queryEmbedding, referenceTime); + const preExpanded = await expandWithLinks(repo, entityRepo, userId, candidates.slice(0, limit), queryEmbedding, referenceTime); trace.stage('link-expansion', preExpanded, { order: 'before-mmr' }); const selected = preserveProtectedResults( applyMMR(preExpanded, queryEmbedding, limit, config.mmrLambda), @@ -665,13 +671,13 @@ async function applyExpansionAndReranking( limit, ); trace.stage('mmr', mmrResults, { lambda: config.mmrLambda }); - const expanded = await expandWithLinks(repo, userId, mmrResults, queryEmbedding, referenceTime); + const expanded = await expandWithLinks(repo, entityRepo, userId, mmrResults, queryEmbedding, referenceTime); trace.stage('link-expansion', expanded, { order: 'after-mmr' }); return expanded; } const sliced = preserveProtectedResults(candidates.slice(0, limit), candidates, protectedFingerprints, limit); - const expanded = await expandWithLinks(repo, userId, sliced, queryEmbedding, referenceTime); + const expanded = await expandWithLinks(repo, entityRepo, userId, sliced, queryEmbedding, referenceTime); trace.stage('link-expansion', expanded, { order: 'no-mmr' }); return expanded; } @@ -682,6 +688,7 @@ async function applyExpansionAndReranking( */ async function expandWithLinks( repo: MemoryRepository, + entityRepo: EntityRepository | null, userId: string, results: SearchResult[], queryEmbedding: number[], @@ -715,7 +722,7 @@ async function expandWithLinks( const dedupedTemporal = temporalNeighbors.filter((m) => !seen.has(m.id)); // Entity graph expansion: find entities matching the query and pull in their linked memories - const entityMemories = await expandViaEntities(repo, userId, queryEmbedding, seen, budget); + const entityMemories = await expandViaEntities(repo, entityRepo, userId, queryEmbedding, seen, budget); const expansions = [...linkedMemories, ...dedupedTemporal, ...entityMemories] .sort((a, b) => b.score - a.score) @@ -726,6 +733,7 @@ async function expandWithLinks( async function runMemoryRrfRetrieval( repo: MemoryRepository, + entityRepo: EntityRepository | null, userId: string, queryText: string, queryEmbedding: number[], @@ -746,7 +754,7 @@ async function runMemoryRrfRetrieval( ]; if (config.entityGraphEnabled && entityRepo) { - const entityResults = await expandViaEntities(repo, userId, queryEmbedding, new Set(), limit); + const entityResults = await expandViaEntities(repo, entityRepo, userId, queryEmbedding, new Set(), limit); if (entityResults.length > 0) { channels.push({ name: 'entity', weight: ENTITY_RRF_WEIGHT, results: entityResults }); } @@ -865,6 +873,7 @@ export async function generateLinks( */ async function expandViaEntities( repo: MemoryRepository, + entityRepo: EntityRepository | null, userId: string, queryEmbedding: number[], excludeIds: Set, From 9f19199371bcb21f8513fa25157b311b125ac2d5 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 03:48:15 -0700 Subject: [PATCH 02/59] refactor(app): remove misleading config override from createCoreRuntime (phase 1a.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex stop-time review flagged the runtime container's `config?` override as dishonest: the override only influenced repo construction (entityGraphEnabled, lessonsEnabled), while routes/, services/, and the search pipeline still read config directly from the module singleton at 25+ call sites. A consumer passing a custom config would silently get split-brain behavior — repos honoring the override, everything else ignoring it. Scope decision: - Preferred path (thread runtime.config through createMemoryRouter and MemoryService deps) was scoped and rejected for Phase 1A.5: any honest thread requires reaching memory-search.ts (4 reads) which delegates to search-pipeline.ts (35 reads), which is Phase 1B work. Stopping short would preserve the split-brain condition the reviewer flagged. - Safest honest fallback: remove the override. Keep runtime.config as a stable reference to the module singleton so consumers still have a single named entry point for config, but drop the promise that `createCoreRuntime` can take a different config. Changes: - CoreRuntimeDeps: drop `config?: CoreRuntimeConfig`. Only `pool` is accepted. JSDoc documents why: most config reads happen below this layer, so an override here would be silently ignored. - createCoreRuntime: read `config` from the module singleton directly for repo-construction flags. Single source of truth. - CoreRuntimeConfig type kept as `typeof config` with a comment flagging Phase 1B as the place proper config splitting + per-runtime override will land. - Tests: the flag-branch coverage (`entityGraphEnabled`, `lessonsEnabled`) now uses a small `withConfigFlag` helper that temporarily mutates the module singleton and restores on cleanup. That is an honest reflection of how the app actually works — config mutation via the live singleton is already the mechanism behind `PUT /memories/config`. Added one explicit test that `runtime.config` references the module singleton, so future drift is caught. Validation: - npx tsc --noEmit: clean - src/app/__tests__/runtime-container.test.ts: 9/9 pass - Full suite: 884 pass / 1 pre-existing fail (deployment-config regex) - fallow --no-cache: 0 above threshold, maintainability 90.9 Follow-ups deferred to Phase 1B: - Thread config through createMemoryRouter, MemoryService deps, and the search pipeline so per-runtime config overrides become honest. - Split config into CoreRuntimeConfig (public) + InternalPolicyConfig (experimental), per the rearchitecture plan. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/__tests__/runtime-container.test.ts | 50 ++++++++++++++++----- src/app/runtime-container.ts | 36 ++++++++++----- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/app/__tests__/runtime-container.test.ts b/src/app/__tests__/runtime-container.test.ts index 0d7e467..ae786a7 100644 --- a/src/app/__tests__/runtime-container.test.ts +++ b/src/app/__tests__/runtime-container.test.ts @@ -18,6 +18,26 @@ function stubPool(rows: Array<{ typmod: number }> = []): pg.Pool { return { query: vi.fn(async () => ({ rows })) } as unknown as pg.Pool; } +/** + * Temporarily mutate a config flag, run a function, and restore. + * Used to exercise repo-construction branches without accepting a config + * override on createCoreRuntime (which would be dishonest — most code + * reads config from the module singleton directly). + */ +function withConfigFlag( + key: K, + value: typeof config[K], + run: () => void, +): void { + const previous = config[key]; + (config as unknown as Record)[key] = value; + try { + run(); + } finally { + (config as unknown as Record)[key] = previous; + } +} + describe('createCoreRuntime', () => { it('composes a runtime with explicit pool dep', () => { const pool = stubPool(); @@ -31,24 +51,32 @@ describe('createCoreRuntime', () => { expect(runtime.services.memory).toBeDefined(); }); - it('entity repo is null when entityGraphEnabled is false', () => { + it('runtime.config references the module-level config singleton', () => { + // Phase 1A.5 truthfulness: the container does not accept a config + // override because routes/services still read the singleton. const pool = stubPool(); - const cfg = { ...config, entityGraphEnabled: false }; - const runtime = createCoreRuntime({ pool, config: cfg }); - expect(runtime.repos.entities).toBeNull(); + const runtime = createCoreRuntime({ pool }); + expect(runtime.config).toBe(config); }); - it('entity repo is constructed when entityGraphEnabled is true', () => { + it('entity repo tracks config.entityGraphEnabled', () => { const pool = stubPool(); - const cfg = { ...config, entityGraphEnabled: true }; - const runtime = createCoreRuntime({ pool, config: cfg }); - expect(runtime.repos.entities).not.toBeNull(); + withConfigFlag('entityGraphEnabled', false, () => { + expect(createCoreRuntime({ pool }).repos.entities).toBeNull(); + }); + withConfigFlag('entityGraphEnabled', true, () => { + expect(createCoreRuntime({ pool }).repos.entities).not.toBeNull(); + }); }); - it('lesson repo matches lessonsEnabled flag', () => { + it('lesson repo tracks config.lessonsEnabled', () => { const pool = stubPool(); - expect(createCoreRuntime({ pool, config: { ...config, lessonsEnabled: false } }).repos.lessons).toBeNull(); - expect(createCoreRuntime({ pool, config: { ...config, lessonsEnabled: true } }).repos.lessons).not.toBeNull(); + withConfigFlag('lessonsEnabled', false, () => { + expect(createCoreRuntime({ pool }).repos.lessons).toBeNull(); + }); + withConfigFlag('lessonsEnabled', true, () => { + expect(createCoreRuntime({ pool }).repos.lessons).not.toBeNull(); + }); }); }); diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index 6fdb9cd..fa2386f 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -11,7 +11,7 @@ */ import pg from 'pg'; -import { config as defaultConfig } from '../config.js'; +import { config } from '../config.js'; import { AgentTrustRepository } from '../db/agent-trust-repository.js'; import { ClaimRepository } from '../db/claim-repository.js'; import { LinkRepository } from '../db/link-repository.js'; @@ -24,8 +24,16 @@ import { MemoryService } from '../services/memory-service.js'; * Public runtime configuration subset. Phase 1A exposes the full config * object for compatibility; later phases will split public runtime config * from internal policy flags. + * + * NOTE (phase 1a.5): `runtime.config` currently references the same + * module-level singleton that routes, services, and the search pipeline + * all read from directly. There is no per-runtime config copy yet — + * consumers cannot construct two runtimes with different configs because + * the deeper service code (25+ `import { config }` sites across + * routes/, services/) reads the module singleton regardless. Phase 1B + * will thread config through properly and reintroduce a genuine override. */ -export type CoreRuntimeConfig = typeof defaultConfig; +export type CoreRuntimeConfig = typeof config; /** Repositories constructed by the runtime container. */ export interface CoreRuntimeRepos { @@ -46,14 +54,17 @@ export interface CoreRuntimeServices { * Explicit dependency bundle accepted by `createCoreRuntime`. * * `pool` is required — the composition root never reaches around to - * import the singleton `pg.Pool` itself. `config` is optional because - * it is pure env-derived data with no I/O side effects at construction; - * tests that want a custom config override it, everyone else inherits - * the module-level default. + * import the singleton `pg.Pool` itself. + * + * A `config` override is deliberately NOT accepted here. Most downstream + * route and service code still reads config directly from the module + * singleton, so any override passed here would only influence repo + * construction (`entityGraphEnabled`, `lessonsEnabled`) and silently be + * ignored everywhere else — a dishonest contract. Phase 1B will thread + * config through routes and services and reintroduce a genuine override. */ export interface CoreRuntimeDeps { pool: pg.Pool; - config?: CoreRuntimeConfig; } /** The composed runtime — single source of truth for route registration. */ @@ -66,18 +77,19 @@ export interface CoreRuntime { /** * Compose the core runtime. Instantiates repositories and the memory - * service from an explicit config and pool. No module-level mutation. + * service from an explicit pool. Reads the module-level config singleton + * for repo-construction flags so there is a single source of truth + * between the container and the rest of the codebase. No mutation. */ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime { - const cfg = deps.config ?? defaultConfig; const { pool } = deps; const memory = new MemoryRepository(pool); const claims = new ClaimRepository(pool); const trust = new AgentTrustRepository(pool); const links = new LinkRepository(pool); - const entities = cfg.entityGraphEnabled ? new EntityRepository(pool) : null; - const lessons = cfg.lessonsEnabled ? new LessonRepository(pool) : null; + const entities = config.entityGraphEnabled ? new EntityRepository(pool) : null; + const lessons = config.lessonsEnabled ? new LessonRepository(pool) : null; const service = new MemoryService( memory, @@ -87,7 +99,7 @@ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime { ); return { - config: cfg, + config, pool, repos: { memory, claims, trust, links, entities, lessons }, services: { memory: service }, From b4486c1ff5e4c29602fb1f47e20978f1f5667898 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 04:21:29 -0700 Subject: [PATCH 03/59] test: fix deployment-config port-binding regex drift after PR #6 PR #6 changed docker-compose.yml ports from "3050:3050" to "${APP_PORT:-3050}:3050" for side-by-side CI safety. The regex in deployment-config.test.ts:105 was written for the bare-digit shape and silently failed on the env-var substitution form, leaving the app-port exposure assertion broken since that PR landed. Update the assertion to use a new composePortBindingRegex(internalPort) helper that accepts either a literal external port or a ${VAR:-default} shell-variable substitution. The helper makes the pattern intent explicit and prevents the same drift from recurring on future compose changes. Restores the deployment-config test suite to a clean baseline (15/15 pass). Recommended in the Phase 1A follow-up analysis as the smallest move to restore baseline integrity before Phase 1B. Known follow-up: src/__tests__/route-validation.test.ts "skip_extraction (storeVerbatim)" returns 500 in some local environments. Out of scope for this fix; investigate separately before declaring full suite green. Validation: - npx tsc --noEmit: clean - npx vitest run src/__tests__/deployment-config.test.ts: 15/15 pass - fallow --no-cache: 0 above threshold, maintainability 90.9 unchanged Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/deployment-config.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/__tests__/deployment-config.test.ts b/src/__tests__/deployment-config.test.ts index 5c1f06e..503d66c 100644 --- a/src/__tests__/deployment-config.test.ts +++ b/src/__tests__/deployment-config.test.ts @@ -42,6 +42,19 @@ function extractComposeEnvVar(composeContent: string, varName: string): string | return match ? match[1].trim().replace(/^["']|["']$/g, '') : null; } +/** + * Build a regex that matches a docker-compose `ports:` list entry binding + * an external host port to the given internal container port. Accepts both + * a literal external port (`"3050:3050"`) and a shell-variable substitution + * (`"${APP_PORT:-3050}:3050"`). The substitution form is the side-by-side-CI + * shape introduced in PR #6. + */ +function composePortBindingRegex(internalPort: number): RegExp { + return new RegExp( + `ports:\\s*\\n\\s*-\\s*["']?(?:\\d+|\\$\\{[A-Z_]+:-\\d+\\}):${internalPort}`, + ); +} + describe('deployment configuration', () => { describe('docker-compose.yml', () => { it('app service uses host.docker.internal for OLLAMA_BASE_URL, not localhost', () => { @@ -102,7 +115,7 @@ describe('deployment configuration', () => { it('app port is exposed', () => { const compose = readComposeRaw(); - expect(compose).toMatch(/ports:\s*\n\s*-\s*["']?\d+:3050/); + expect(compose).toMatch(composePortBindingRegex(3050)); }); }); From 709285904c2747d0852116ee1971d4b76fa4aa34 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 04:22:47 -0700 Subject: [PATCH 04/59] docs(design): add Phase 1A singleton-hazard audit + follow-on sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memo-only artifact in docs/design/. Documents the source-grounded audit that informed the Phase 1A refactor (ff57540, 0da468e) and the minimum safe sequence for Phase 1B onward. Contents: - Hazards that existed before Phase 1A: search-pipeline module-level entityRepo with setter, server.ts as singleton composition root with bootstrap-on-import, parallel DI paths for the entity repo - What Phase 1A resolved: runtime-container composition root, createApp factory, startup-checks extraction, module-global removal, search-pipeline functions now take entityRepo as a param - What Phase 1A deliberately did NOT do: config override on createCoreRuntime (53 call sites still import config directly), process-global handler extraction, server.ts re-export surface - Remaining hazards post-Phase 1A, with exact file:surface pointers - Files and tests to watch during Phase 1B+ churn - Phase 1B through 1E follow-on sequence with independently testable and reversible exit criteria Source-grounded throughout — every reference is either a commit SHA or a path within src/. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/design/phase-1a-singleton-hazards.md | 192 ++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/design/phase-1a-singleton-hazards.md diff --git a/docs/design/phase-1a-singleton-hazards.md b/docs/design/phase-1a-singleton-hazards.md new file mode 100644 index 0000000..7eb348f --- /dev/null +++ b/docs/design/phase-1a-singleton-hazards.md @@ -0,0 +1,192 @@ +# Phase 1A — Singleton & Global Hazard Audit + Follow-on Sequence + +**Date:** 2026-04-16 +**Status:** Phase 1A landed (`ff57540`, `0da468e`); this memo captures the +source-grounded audit that informed the refactor and the minimum safe +sequence for Phase 1B and beyond. +**Scope:** `Atomicmemory-core` only. + +--- + +## Why Phase 1A was needed + +Two independent singleton/global patterns were entangling startup, +tests, and research harnesses, making it impossible to boot two +runtimes in one process (e.g. for `/compare` style evaluations) or to +unit-test the search pipeline without module-load side effects. + +### Hazard 1 — `src/services/search-pipeline.ts` module-level entity repo + +A mutable module global and a dedicated setter: + +- `let entityRepo: EntityRepository | null = null;` at module scope +- `export function setEntityRepository(repo)` as the only way to mutate +- ~11 read sites inside the pipeline dereferencing it directly + (`runSearchPipelineWithTrace`, `generateLinks`, query-expansion, + query-augmentation, link-expansion branches) + +Production flow: `server.ts` called `setEntityRepository(entityRepo)` +at module load, gated by `config.entityGraphEnabled`. Tests that +imported anything pulling in `server.ts` inherited that global state. +Two tests mocked the whole module via `createSearchPipelineMockFactory`, +locking in three exported names (`runSearchPipelineWithTrace`, +`generateLinks`, `setEntityRepository`) — any rename silently broke +the mock. + +### Hazard 2 — `src/server.ts` as a singleton composition root + +- `repo`, `claimRepo`, `trustRepo`, `linkRepo`, `entityRepo`, + `lessonRepo`, `service`, and `app` all declared at module scope +- `checkEmbeddingDimensions().then(() => app.listen(config.port, …))` + fired at module evaluation — **importing `./server.ts` bound a port** +- Process-global handlers (`uncaughtException`, `unhandledRejection`, + `SIGTERM`, `SIGINT`) registered at import time +- Exports (`app, service, repo, claimRepo, trustRepo, linkRepo`) had + zero in-repo readers; the shape advertised a reusable API that no + one could actually use without triggering the side effects + +### Hazard 3 — parallel DI paths for the entity repo + +`MemoryService` accepted `entities` as an optional constructor arg +(`memory-service.ts:33-48`) and threaded it through `deps.entities` +for storage/dedup/audn paths. The search pipeline, meanwhile, read +its entity repo from the module global. Two independent sources of +truth for "is the entity graph wired?" — one constructor-injected, +one module-global. + +--- + +## What Phase 1A resolved + +Commits on `feat/phase-1a-runtime-container`: + +| Commit | Scope | +|---|---| +| `ff57540` refactor(core): introduce runtime container composition root | Extracted `runtime-container.ts`, `create-app.ts`, `startup-checks.ts`; server.ts now just bootstraps | +| `0da468e` refactor(app): remove misleading config override from createCoreRuntime | Removed the nascent `config` override parameter that would have been silently ignored by the 53 downstream `import { config }` sites | + +### Concrete changes + +- **`src/app/runtime-container.ts`** (new, 107 lines) — `createCoreRuntime({ pool })` returns an explicit `{ config, pool, repos, services }` graph. No hidden singletons. +- **`src/app/create-app.ts`** (new, 39 lines) — `createApp(runtime)` is the only way to build the Express app. CORS, body parsing, routes, health are bound to the passed-in runtime. +- **`src/app/startup-checks.ts`** (new, 70 lines) — embedding-dimension guard extracted behind a plain function, no process exits from inside the check. +- **`src/app/__tests__/runtime-container.test.ts`** (new, 126 lines) — coverage of the composition root. +- **`src/server.ts`** shrunk from 101 → 70 lines — now just process lifecycle (boot, listen, shutdown) over the composed runtime. +- **`src/services/search-pipeline.ts`** — module-level `entityRepo` and `setEntityRepository` removed; `runSearchPipelineWithTrace` and `generateLinks` now accept `entityRepo: EntityRepository | null` as an explicit function parameter. +- **`src/services/__tests__/test-fixtures.ts`** — `createSearchPipelineMockFactory` no longer needs to stub `setEntityRepository`. +- **`src/services/__tests__/current-state-retrieval-regression.test.ts`** — the `setEntityRepository(null)` reset in `beforeEach` is gone (not needed without the global). + +### What Phase 1A deliberately did **not** do + +- **Config override in the runtime.** An early draft of `createCoreRuntime` accepted a `config` override. Removed in `0da468e` because 53 files still `import { config }` directly from `../config.js`; an override would have influenced only repo construction (`entityGraphEnabled`, `lessonsEnabled`) and been silently ignored everywhere else. Reintroducing this override is a Phase 1B deliverable, not a Phase 1A one. +- **Process-global handlers.** `uncaughtException`/`unhandledRejection`/`SIGTERM`/`SIGINT` registrations still live at import time in `server.ts`. Safe while `server.ts` is purely an entrypoint — becomes unsafe if anyone imports `server.ts` from library code. +- **`server.ts` re-exports.** `{ app, service, repo, claimRepo, trustRepo, linkRepo, runtime }` are still exported. Kept for back-compat with any external consumer; zero in-repo readers. + +--- + +## Hazards that remain (post Phase 1A) + +### Remaining 1 — config as a 53-reader module singleton + +- 53 files `import { config }` from `../config.js` directly. +- `createCoreRuntime` reflects this honestly: `runtime.config` is the + same reference as the module singleton. You cannot construct two + runtimes with different configs in one process. +- Any attempt to override per-runtime config without first threading + config through routes/services/pipeline is a silent no-op for + every flag not named `entityGraphEnabled` or `lessonsEnabled`. + +### Remaining 2 — `server.ts` side-effects-on-import + +- `bootstrap()` fires at module load. +- Four `process.on(...)` handlers register at module load. +- Importing `./server.ts` still has a nontrivial runtime contract. + +### Remaining 3 — zero-reader back-compat export surface on `server.ts` + +- `export { app, service, repo, claimRepo, trustRepo, linkRepo, runtime };` +- No in-repo importers; external consumers (if any) would inherit the + import-time side effects above. + +--- + +## Files and tests to watch during Phase 1B+ + +### Production code (expect churn) + +- `src/config.ts` — the singleton that will grow a per-runtime construction path +- `src/services/search-pipeline.ts` — currently reads `config` inline (e.g. `config.entityGraphEnabled`, `config.queryExpansionEnabled`, `config.mmrEnabled`); candidate for threading config through rather than module-level reads +- `src/services/memory-search.ts` — same pattern +- `src/services/memory-ingest.ts`, `memory-audn.ts`, `memory-storage.ts` — 53-site config audit lands here +- `src/routes/memories.ts`, `src/routes/agents.ts` — routes also read config directly in places +- `src/server.ts` — when `bootstrap()` becomes a function callable by tests/harnesses, the import-time side effects go with it + +### Tests (fragile surfaces if Phase 1A exports shift) + +- `src/app/__tests__/runtime-container.test.ts` — locks the shape of `createCoreRuntime` and the `CoreRuntime` interface +- `src/__tests__/route-validation.test.ts` — builds its own `express()` app, calls `new MemoryService(repo, claimRepo)` (2-arg ctor); if `MemoryService` ctor changes, update here +- `src/__tests__/smoke.test.ts` — consumes `createServiceTestContext` (2-arg `MemoryService`) +- `src/db/__tests__/test-fixtures.ts` — `createServiceTestContext` locks the 2-arg `MemoryService` ctor shape +- `src/services/__tests__/test-fixtures.ts` — `createSearchPipelineMockFactory` locks two exported names now (`runSearchPipelineWithTrace`, `generateLinks`); any rename is a silent mock break +- `src/services/__tests__/stale-composite-retrieval.test.ts`, `current-state-composite-packaging.test.ts` — both use `createSearchPipelineMockFactory` +- `src/services/__tests__/current-state-retrieval-regression.test.ts` — no longer imports `setEntityRepository`; should now pass an explicit entity repo (or leave as null) when it invokes the pipeline + +--- + +## Minimum safe follow-on sequence + +Each step is independently testable and reversible. Don't skip ahead. + +### Phase 1B — thread config through the service layer + +**Goal:** `runtime.config` becomes the one config source; reintroduce a genuine `config` override on `createCoreRuntime`. + +1. **Audit the 53 `import { config }` sites.** Classify each as (a) flag read at request time, (b) flag read at construction time, or (c) constant-use-as-pseudo-env. The counts inform Phase 1B's scope. +2. **Thread config through `MemoryService` construction.** Add a config argument to `MemoryService` and `MemoryServiceDeps`; remove inline `import { config }` from the service-level modules called from the facade. +3. **Thread config through the search pipeline.** `runSearchPipelineWithTrace` and `generateLinks` already take `entityRepo` explicitly; extend the same treatment to the config fields they read (`queryExpansionEnabled`, `mmrEnabled`, `rerankSkipTopSimilarity`, `rerankSkipMinGap`, etc.). +4. **Thread config through routes.** Route factories (`createMemoryRouter`, `createAgentRouter`) already accept services; add a config parameter where routes read `config` inline today (spot-check first — some routes may not need it). +5. **Reintroduce `config` override on `createCoreRuntime`.** Once steps 2–4 are landed, a `CoreRuntimeDeps.config` override is honest again: every flag passed in will actually be consulted by downstream code. +6. **Test:** extend `runtime-container.test.ts` to assert two concurrently-constructed runtimes with different configs behave independently on at least one flag. + +**Exit criterion:** `grep -r "import { config } from" src | wc -l` drops meaningfully; the remaining sites are deliberate (e.g. `config.ts` itself, bootstrap files, truly environment-scoped reads). + +### Phase 1C — extract `bootstrap()` to `src/app/bootstrap.ts` + +**Goal:** `server.ts` becomes a thin `bin/`-style entrypoint; `bootstrap(runtime)` is callable from tests and harnesses. + +1. Move `bootstrap()` body, the `app.listen` call, and the four `process.on` handlers into `src/app/bootstrap.ts`. Export `startRuntime(runtime): Promise<() => Promise>` returning a shutdown function. +2. `server.ts` becomes: `const runtime = createCoreRuntime({ pool }); const app = createApp(runtime); startRuntime({ runtime, app });` — no exports. +3. Delete the back-compat re-exports from `server.ts`. Verified above: zero in-repo readers. +4. **Test:** unit test `startRuntime` can bind to an ephemeral port (`listen(0)`) and be cleanly shut down by its returned function. + +**Exit criterion:** `server.ts` has no exports and no side effects beyond the bootstrap call; importing it from a test does not bind a port or register process handlers. + +### Phase 1D — `createCoreRuntime` accepts override hooks for testing + +**Goal:** tests can substitute individual repositories or services without reconstructing the whole graph. + +1. Add optional `repos?: Partial` and `services?: Partial` to `CoreRuntimeDeps`. +2. Merge overrides after default construction; add a runtime-container test that verifies an injected mock `MemoryService` is what `createApp` binds to `/memories`. +3. Migrate `route-validation.test.ts` and `smoke.test.ts` to use `createCoreRuntime` instead of constructing `MemoryService` directly. + +**Exit criterion:** no test file reaches past `createCoreRuntime` to import repositories or the `MemoryService` class directly. + +### Phase 1E — lifecycle for `pool` + +**Goal:** the process-global `pool` singleton (imported only by `server.ts` now) becomes an explicit parameter that tests can replace with an isolated pool. + +1. Audit importers of `src/db/pool.ts`. If `server.ts` is the only one, Phase 1E is a 2-line change to thread `pool` via a constructor arg to any remaining importer. +2. If tests still import `pool` directly for setup (`route-validation.test.ts`, `test-fixtures.ts`), add a `createTestPool()` helper so test and production paths don't share the same pg connection pool by accident. + +**Exit criterion:** `pool` is never imported outside `server.ts` (production) and the test-pool helper (tests). + +--- + +## One-line summary + +Phase 1A eliminated the search-pipeline module global and the +server.ts composition root; what remains is config-as-singleton +(53 sites), bootstrap-on-import in `server.ts`, and the zero-reader +re-export surface. Phase 1B threads config, Phase 1C extracts +bootstrap, Phase 1D enables test overrides, Phase 1E isolates pool. +Each step is independently landable. From 39a84fa6d637c91ebcde633368f250e8bd8af6f0 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 05:02:28 -0700 Subject: [PATCH 05/59] docs(design): add minimum missing integration-test plan for Phase 1A composed boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memo-only artifact in docs/design/. Records the one integration test still missing after 0da468e: boot createApp(createCoreRuntime(...)) and prove config-facing HTTP behavior matches the singleton-backed runtime end-to-end. Contents: - What is missing, in one sentence - Why runtime-container.test.ts is insufficient — source-grounded at src/app/__tests__/runtime-container.test.ts lines 17, 42-80, 119-126 - Why route-validation.test.ts is insufficient — it bypasses createCoreRuntime and createApp by design - Test shape (not implementation): boots app.listen(0), at least one write + read HTTP round-trip through the composed graph, parity assertion against a reference - Narrow acceptance criteria (6 items, each binary) - Explicit non-goals (route-validation, per-endpoint business logic, config-threading parity across 53 sites — those are other tests) No code, no test file. This memo only defines what "done" looks like so the test can be written in a focused follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../phase-1a-composed-boot-parity-test.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/design/phase-1a-composed-boot-parity-test.md diff --git a/docs/design/phase-1a-composed-boot-parity-test.md b/docs/design/phase-1a-composed-boot-parity-test.md new file mode 100644 index 0000000..2c01e66 --- /dev/null +++ b/docs/design/phase-1a-composed-boot-parity-test.md @@ -0,0 +1,121 @@ +# Phase 1A — Minimum Missing Integration Test: Composed Boot Parity + +**Date:** 2026-04-16 +**Status:** Gap identified (codex2 review of `0da468e`); test not yet written. +Test-plan artifact only — this memo does not introduce code. +**Scope:** `Atomicmemory-core`, branch `feat/phase-1a-runtime-container`. + +--- + +## What is missing + +One live-HTTP integration test that boots + +``` +createApp(createCoreRuntime({ pool })) +``` + +against an ephemeral port and proves the composed app's config-facing +HTTP behavior matches the runtime backed by the module-level config +singleton end-to-end. Today no such test exists on this branch. + +## Why `runtime-container.test.ts` alone is insufficient + +`src/app/__tests__/runtime-container.test.ts` (126 lines) is a +**composition-shape test**, not a parity test. Specifically: + +- **Stubbed pool, no DB round-trip.** `stubPool()` at line 17 returns a + minimal `{ query: vi.fn(...) }` object. No SQL actually runs; no + repo→DB seam is exercised. +- **`createApp` test is type-shape only.** Line 119–126 asserts + `typeof app.use === 'function'` and `typeof app.listen === 'function'` + on the returned app. No request is issued, no route is hit, no + response is observed. A bug that binds `/memories` to the wrong + service — or silently drops the route mount — would pass this test. +- **No observed config-driven behavior.** Lines 42–80 verify that + `runtime.config === config` and that `repos.entities` / + `repos.lessons` track `entityGraphEnabled` / `lessonsEnabled` at the + container level. None of that proves the flags surface correctly + into the HTTP layer once routes are mounted. +- **No parity assertion.** There is no comparison of composed-boot + behavior against a reference. If someone later threads config + through routes/services and introduces a subtle divergence between + the composed path and the singleton-backed path, the current suite + would not detect it. + +The existing `src/__tests__/route-validation.test.ts` is a +**route-behavior test**, not a composition-parity test: it constructs +its own `express()` app (line 41), its own `new MemoryService(repo, +claimRepo)` (line 51), and mounts `createMemoryRouter` directly. It +deliberately bypasses `createCoreRuntime` and `createApp`. Useful for +route logic; silent on whether the Phase 1A composition seam actually +produces an equivalent app. + +## The minimum missing test — shape (not implementation) + +A single integration test file under `src/app/__tests__/` that: + +1. Boots a live Express listener via + `createApp(createCoreRuntime({ pool })).listen(0)` where `pool` is + the shared test pool (same pattern as + `route-validation.test.ts:54-61`). +2. Applies `schema.sql` to the test DB once in `beforeAll` (match the + existing integration-test pattern; do not reinvent setup). +3. Issues a small number of HTTP requests against the ephemeral port + that collectively exercise at least one config-sensitive path. + Minimum request set: one write (ingest or verbatim store) and one + read that surfaces the write. This proves routes → services → + repos → pool all connected correctly through the composition seam. +4. Asserts responses match what a reference request against a + hand-wired runtime (the `route-validation.test.ts` 2-arg + `MemoryService` shape, or a second composed runtime built the same + way) produces. Parity is the point; the specific endpoint matters + less than the observation that the composed path and the + known-working path agree. +5. Closes the listener and releases any ephemeral resources in + `afterAll`. + +The test does not need to be exhaustive. It needs to prove, with at +least one live round-trip, that the composition seam does not drop +fidelity between the runtime container and the HTTP surface. + +## Narrow acceptance criteria + +The test is considered sufficient iff: + +1. It boots `createApp(createCoreRuntime({ pool }))` — not a hand-wired + Express app, not a manually-constructed `MemoryService`. +2. It binds `app.listen(0)` (ephemeral port) inside the test lifecycle, + not a hardcoded port. +3. It issues at least one HTTP request whose response depends on code + paths reached through the runtime container (routes, services, + repos). A `GET /health` alone does not satisfy this — `/health` + does not traverse the runtime graph. +4. It asserts equivalence against a reference behavior, either by + (a) constructing a second runtime the same way and comparing + responses, or (b) asserting concrete expected values that a + singleton-backed server would also produce under identical config. +5. It runs green under the existing `npm test` harness with no new + dependencies and no new env requirements beyond `.env.test`. +6. It lives under `src/app/__tests__/` — adjacent to the composition + code it covers, not under `src/__tests__/`, and not under + `src/services/__tests__/`. + +## What this test deliberately does not cover + +- **Route-level input validation** — already covered by + `route-validation.test.ts` (UUID guards, filter params). +- **Per-endpoint business logic** — covered by service-level and + repo-level integration tests. +- **Config-threading correctness across 53 `import { config }` sites** + — that is Phase 1B scope and requires its own test strategy. +- **Startup-check behavior** — already covered in + `runtime-container.test.ts:83-116`. +- **Process lifecycle** (SIGTERM, SIGINT, uncaught handlers) — covered + in Phase 1C when `bootstrap()` is extracted. + +## One-line framing + +Prove that `createApp(createCoreRuntime(...))` produces an HTTP server +whose observable behavior matches the singleton-backed reference on +at least one config-sensitive round-trip. No more; no less. From c057326f9f9470a17decbb9d21190c1c20281d1e Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 05:16:38 -0700 Subject: [PATCH 06/59] test(app): add composed-boot parity integration test (phase 1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap identified in docs/design/phase-1a-composed-boot-parity-test.md after 0da468e: no live-HTTP test existed proving that createApp(createCoreRuntime({ pool })) produces a server whose observable behavior matches the singleton-backed reference. The existing runtime-container.test.ts is composition-shape only (stubbed pool, no SQL, no requests issued). The existing route-validation.test.ts is route-logic only (deliberately bypasses the composition seam, hand-wires its own MemoryService). Neither covered the seam itself. This test boots two ephemeral Express listeners — one composed via the Phase 1A seam, one hand-wired in the legacy shape — and asserts: 1. GET /memories/health returns identical config payloads from both, proving config flows correctly through composition into routes. 2. GET /memories/stats returns identical stats from both, proving the full route → service → repo → pool graph is connected end-to-end through the composition seam (this query actually hits the DB). Two requests are sufficient: more would shade into per-endpoint behavior coverage, which is route-validation.test.ts territory and explicitly out of scope per the design doc's acceptance criteria. Validation: - npx tsc --noEmit: clean - vitest run src/app/__tests__/composed-boot-parity.test.ts: 2/2 pass - vitest run src/app/__tests__/: 11/11 pass (no regression in runtime-container.test.ts) - fallow --no-cache: 0 above threshold, maintainability 90.9 unchanged Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/composed-boot-parity.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/app/__tests__/composed-boot-parity.test.ts diff --git a/src/app/__tests__/composed-boot-parity.test.ts b/src/app/__tests__/composed-boot-parity.test.ts new file mode 100644 index 0000000..a840144 --- /dev/null +++ b/src/app/__tests__/composed-boot-parity.test.ts @@ -0,0 +1,123 @@ +/** + * Phase 1A composed-boot parity test. + * + * Boots `createApp(createCoreRuntime({ pool }))` against an ephemeral + * port and proves that the composed app's HTTP behavior matches a + * hand-wired singleton-backed reference. This closes the gap left by + * `runtime-container.test.ts` (composition-shape only, no live HTTP) + * and `route-validation.test.ts` (route logic, deliberately bypasses + * the composition seam). + * + * The test is narrow by design — Phase 1A is about proving the + * composition seam doesn't drop fidelity, not about covering every + * config-threading scenario. That's Phase 1B scope. + * + * See docs/design/phase-1a-composed-boot-parity-test.md for the + * acceptance criteria this test satisfies. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import express from 'express'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { pool } from '../../db/pool.js'; +import { config } from '../../config.js'; +import { MemoryRepository } from '../../db/memory-repository.js'; +import { ClaimRepository } from '../../db/claim-repository.js'; +import { MemoryService } from '../../services/memory-service.js'; +import { createMemoryRouter } from '../../routes/memories.js'; +import { createCoreRuntime } from '../runtime-container.js'; +import { createApp } from '../create-app.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEST_USER = 'composed-boot-parity-user'; + +interface BootedApp { + baseUrl: string; + close: () => Promise; +} + +/** + * Bind an Express app to an ephemeral port and return its base URL plus + * a close handle. Mirrors the listen pattern in route-validation.test.ts + * but isolated here so each test app shuts down independently. + */ +async function bindEphemeral(app: ReturnType): Promise { + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', () => resolve())); + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + return { + baseUrl: `http://localhost:${port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +/** + * Build the singleton-backed reference app — what server.ts looked like + * before Phase 1A. Used as the parity baseline. + */ +function buildReferenceApp(): ReturnType { + const app = express(); + app.use(express.json()); + const repo = new MemoryRepository(pool); + const claimRepo = new ClaimRepository(pool); + const service = new MemoryService(repo, claimRepo); + app.use('/memories', createMemoryRouter(service)); + return app; +} + +describe('composed boot parity', () => { + let composed: BootedApp; + let reference: BootedApp; + + beforeAll(async () => { + const raw = readFileSync(resolve(__dirname, '../../db/schema.sql'), 'utf-8'); + const sql = raw.replace(/\{\{EMBEDDING_DIMENSIONS\}\}/g, String(config.embeddingDimensions)); + await pool.query(sql); + + composed = await bindEphemeral(createApp(createCoreRuntime({ pool }))); + reference = await bindEphemeral(buildReferenceApp()); + }); + + afterAll(async () => { + await composed.close(); + await reference.close(); + await pool.end(); + }); + + it('GET /memories/health returns the same config payload from both apps', async () => { + const composedRes = await fetch(`${composed.baseUrl}/memories/health`); + const referenceRes = await fetch(`${reference.baseUrl}/memories/health`); + + expect(composedRes.status).toBe(200); + expect(referenceRes.status).toBe(200); + + const composedBody = await composedRes.json(); + const referenceBody = await referenceRes.json(); + + expect(composedBody).toEqual(referenceBody); + expect(composedBody.status).toBe('ok'); + expect(composedBody.config.embedding_provider).toBe(config.embeddingProvider); + expect(composedBody.config.entity_graph_enabled).toBe(config.entityGraphEnabled); + }); + + it('GET /memories/stats traverses routes → services → repos → pool through the composition seam', async () => { + const composedRes = await fetch(`${composed.baseUrl}/memories/stats?user_id=${TEST_USER}`); + const referenceRes = await fetch(`${reference.baseUrl}/memories/stats?user_id=${TEST_USER}`); + + expect(composedRes.status).toBe(200); + expect(referenceRes.status).toBe(200); + + const composedBody = await composedRes.json(); + const referenceBody = await referenceRes.json(); + + // Both queries hit the same DB with no preceding writes — counts + // must match. If the composed seam silently dropped a layer, this + // would either 500 or return a different shape. + expect(composedBody).toEqual(referenceBody); + expect(typeof composedBody.count).toBe('number'); + expect(composedBody.sourceDistribution).toBeDefined(); + }); +}); From 8a600311e691509468ee0722e242a63f12242b9b Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 05:30:18 -0700 Subject: [PATCH 07/59] test(app): cover PUT /memories/config write seam in composed-boot parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex2 review of ba7fd6e flagged the remaining gap: composed-boot parity covered config-facing READS (GET /memories/health, GET /memories/stats) but not the config-facing WRITE seam (PUT /memories/config), which is the one route that mutates the module-level config singleton end-to-end. Add a single test case to the existing composed-boot-parity file: 1. PUT /memories/config on the composed app with a sentinel max_search_results value (current + 17, safely past validation floor). 2. Assert the PUT response surfaces { applied, config } with the sentinel applied. 3. GET /memories/health on BOTH the composed app and the reference app and assert both surface the sentinel — proving the composed write seam mutates the same singleton the reference app reads. 4. finally{} restores the original value via direct config mutation (not a follow-up PUT) so cleanup does not depend on either server still being healthy at teardown. Test ordering is deliberate: this test runs LAST in the file so any finally{} hiccup cannot bleed sentinel state into the existing GET parity tests above. Validation: - npx tsc --noEmit: clean - vitest run src/app/__tests__/composed-boot-parity.test.ts: 3/3 pass - vitest run src/app/__tests__/{composed-boot-parity,runtime-container}.test.ts: 12/12 pass (no regression) - fallow --no-cache: 0 above threshold, maintainability 90.9 unchanged - git diff --stat: 1 file changed, +39/-0 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/composed-boot-parity.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/app/__tests__/composed-boot-parity.test.ts b/src/app/__tests__/composed-boot-parity.test.ts index a840144..90e3e9f 100644 --- a/src/app/__tests__/composed-boot-parity.test.ts +++ b/src/app/__tests__/composed-boot-parity.test.ts @@ -120,4 +120,43 @@ describe('composed boot parity', () => { expect(typeof composedBody.count).toBe('number'); expect(composedBody.sourceDistribution).toBeDefined(); }); + + // Runs last so any failure of the finally{} cleanup cannot bleed into + // the GET parity tests above. Cleanup mutates the config singleton + // directly rather than via a follow-up PUT so it does not depend on + // either server still being healthy at teardown. + it('PUT /memories/config mutation is observable via GET /memories/health on both composed and reference apps', async () => { + const originalMaxResults = config.maxSearchResults; + const sentinel = originalMaxResults + 17; + + try { + const putRes = await fetch(`${composed.baseUrl}/memories/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ max_search_results: sentinel }), + }); + expect(putRes.status).toBe(200); + const putBody = await putRes.json(); + expect(putBody.applied).toContain('maxSearchResults'); + expect(putBody.config.max_search_results).toBe(sentinel); + + // The mutation goes through the composed app's PUT route into the + // module-level config singleton. The reference app reads the same + // singleton, so its /health must reflect the change too — that + // parity is the proof the composed write seam is honestly wired + // to the same config the rest of the runtime reads from. + const composedHealthRes = await fetch(`${composed.baseUrl}/memories/health`); + const referenceHealthRes = await fetch(`${reference.baseUrl}/memories/health`); + const composedHealth = await composedHealthRes.json(); + const referenceHealth = await referenceHealthRes.json(); + + expect(composedHealth.config.max_search_results).toBe(sentinel); + expect(referenceHealth.config.max_search_results).toBe(sentinel); + expect(composedHealth).toEqual(referenceHealth); + } finally { + // Restore the singleton directly so a server hiccup cannot leak + // the sentinel into subsequent test files in the same worker. + (config as { maxSearchResults: number }).maxSearchResults = originalMaxResults; + } + }); }); From b51ecdf908d9f877833785e441dd43f8113ee3cf Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 09:59:06 -0700 Subject: [PATCH 08/59] feat(api): return canonical search scope contract --- src/__tests__/route-validation.test.ts | 42 +++++++++++++++++++++++ src/routes/memories.ts | 47 +++++++++++++++++++------- src/services/memory-service-types.ts | 12 +++++++ 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/__tests__/route-validation.test.ts b/src/__tests__/route-validation.test.ts index 9e7fef0..be01c84 100644 --- a/src/__tests__/route-validation.test.ts +++ b/src/__tests__/route-validation.test.ts @@ -123,6 +123,48 @@ describe('GET /memories/list — source_site filter', () => { }); }); +describe('POST /memories/search — scope and observability contract', () => { + it('returns canonical user scope and omits observability when no retrieval trace payload is produced', async () => { + const res = await fetch(`${baseUrl}/memories/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: TEST_USER, + query: 'verbatim', + source_site: 'verbatim-test', + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.scope).toEqual({ kind: 'user', userId: TEST_USER }); + }); + + it('returns canonical workspace scope for workspace searches', async () => { + const workspaceId = '00000000-0000-0000-0000-000000000111'; + const agentId = '00000000-0000-0000-0000-000000000222'; + const res = await fetch(`${baseUrl}/memories/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: TEST_USER, + query: 'verbatim', + workspace_id: workspaceId, + agent_id: agentId, + source_site: 'verbatim-test', + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.scope).toEqual({ + kind: 'workspace', + userId: TEST_USER, + workspaceId, + }); + }); +}); + describe('GET /memories/list — episode_id filter', () => { it('returns 400 for an invalid episode_id', async () => { const res = await fetch( diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 96bdb9c..1f1fdc1 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -6,7 +6,7 @@ import { Router, type Request, type Response } from 'express'; import { config, updateRuntimeConfig } from '../config.js'; import { MemoryService, type RetrievalResult } from '../services/memory-service.js'; -import type { RetrievalMode } from '../services/memory-service-types.js'; +import type { RetrievalMode, MemoryScope, RetrievalObservability } from '../services/memory-service-types.js'; import type { AgentScope, WorkspaceContext } from '../db/repository-types.js'; import { InputError, handleRouteError } from './route-errors.js'; @@ -104,20 +104,21 @@ function registerSearchRoute(router: Router, service: MemoryService): void { router.post('/search', async (req: Request, res: Response) => { try { const body = parseSearchBody(req.body); + const scope = toMemoryScope(body.userId, body.workspace, body.agentScope); const requestLimit = body.limit === undefined ? undefined : resolveEffectiveSearchLimit(body.limit); const retrievalOptions: { retrievalMode?: typeof body.retrievalMode; tokenBudget?: typeof body.tokenBudget; skipRepairLoop?: boolean } = { retrievalMode: body.retrievalMode, tokenBudget: body.tokenBudget, ...(body.skipRepair ? { skipRepairLoop: true } : {}), }; - const result = body.workspace - ? await service.workspaceSearch(body.userId, body.query, body.workspace, { - agentScope: body.agentScope, + const result = scope.kind === 'workspace' + ? await service.workspaceSearch(scope.userId, body.query, body.workspace!, { + agentScope: scope.agentScope, limit: requestLimit, retrievalOptions, }) : await service.search( - body.userId, + scope.userId, body.query, body.sourceSite, requestLimit, @@ -126,7 +127,7 @@ function registerSearchRoute(router: Router, service: MemoryService): void { body.namespaceScope, retrievalOptions, ); - res.json(formatSearchResponse(result)); + res.json(formatSearchResponse(result, scope)); } catch (err) { handleRouteError(res, 'POST /memories/search', err); } @@ -141,20 +142,21 @@ function registerFastSearchRoute(router: Router, service: MemoryService): void { router.post('/search/fast', async (req: Request, res: Response) => { try { const body = parseSearchBody(req.body); + const scope = toMemoryScope(body.userId, body.workspace, body.agentScope); const requestLimit = body.limit === undefined ? undefined : resolveEffectiveSearchLimit(body.limit); - const result = body.workspace - ? await service.workspaceSearch(body.userId, body.query, body.workspace, { - agentScope: body.agentScope, + const result = scope.kind === 'workspace' + ? await service.workspaceSearch(scope.userId, body.query, body.workspace!, { + agentScope: scope.agentScope, limit: requestLimit, }) : await service.fastSearch( - body.userId, + scope.userId, body.query, body.sourceSite, requestLimit, body.namespaceScope, ); - res.json(formatSearchResponse(result)); + res.json(formatSearchResponse(result, scope)); } catch (err) { handleRouteError(res, 'POST /memories/search/fast', err); } @@ -580,6 +582,24 @@ function resolveEffectiveSearchLimit(requestedLimit: number | undefined): number return Math.min(requestedLimit, maxLimit); } +function toMemoryScope( + userId: string, + workspace: WorkspaceContext | undefined, + agentScope: AgentScope | undefined, +): MemoryScope { + if (!workspace) return { kind: 'user', userId }; + return { kind: 'workspace', userId, workspaceId: workspace.workspaceId, agentScope }; +} + +function buildRetrievalObservability(result: RetrievalResult): RetrievalObservability | undefined { + if (!result.retrievalSummary || !result.packagingSummary || !result.assemblySummary) return undefined; + return { + retrieval: result.retrievalSummary, + packaging: result.packagingSummary, + assembly: result.assemblySummary, + }; +} + function applyCorsHeaders(req: Request, res: Response): void { const origin = req.headers.origin; if (origin && ALLOWED_ORIGINS.has(origin)) res.header('Access-Control-Allow-Origin', origin); @@ -606,10 +626,12 @@ function formatHealthConfig() { }; } -function formatSearchResponse(result: RetrievalResult) { +function formatSearchResponse(result: RetrievalResult, scope: MemoryScope) { + const observability = buildRetrievalObservability(result); return { count: result.memories.length, retrieval_mode: result.retrievalMode, + scope, memories: result.memories.map((memory) => ({ id: memory.id, content: memory.content, @@ -648,6 +670,7 @@ function formatSearchResponse(result: RetrievalResult) { removed_memory_ids: result.consensusResult.removedMemoryIds, }, } : {}), + ...(observability ? { observability } : {}), }; } diff --git a/src/services/memory-service-types.ts b/src/services/memory-service-types.ts index 6e476b9..856f153 100644 --- a/src/services/memory-service-types.ts +++ b/src/services/memory-service-types.ts @@ -125,6 +125,18 @@ export interface RetrievalOptions { skipReranking?: boolean; } +/** Canonical runtime retrieval scope contract. */ +export type MemoryScope = + | { kind: 'user'; userId: string } + | { kind: 'workspace'; userId: string; workspaceId: string; agentScope?: import('../db/repository-types.js').AgentScope }; + +/** Supported observability payload for retrieval responses. */ +export interface RetrievalObservability { + retrieval: import('./retrieval-trace.js').RetrievalTraceSummary; + packaging: import('./retrieval-trace.js').PackagingTraceSummary; + assembly: import('./retrieval-trace.js').AssemblyTraceSummary; +} + /** * Internal dependency bundle for memory service sub-modules. * Exposes the repositories and optional services needed by ingest, search, and CRUD. From e7b898fb1ab1c8bfb39f3dd1247e0b992a92d692 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 10:00:06 -0700 Subject: [PATCH 09/59] refactor(api): make retrieval observability contract optional --- src/__tests__/route-validation.test.ts | 3 ++- src/routes/memories.ts | 11 ++++++----- src/services/memory-service-types.ts | 6 +++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/__tests__/route-validation.test.ts b/src/__tests__/route-validation.test.ts index be01c84..4355d03 100644 --- a/src/__tests__/route-validation.test.ts +++ b/src/__tests__/route-validation.test.ts @@ -124,7 +124,7 @@ describe('GET /memories/list — source_site filter', () => { }); describe('POST /memories/search — scope and observability contract', () => { - it('returns canonical user scope and omits observability when no retrieval trace payload is produced', async () => { + it('returns canonical user scope and does not fake observability for flat search paths that do not emit it', async () => { const res = await fetch(`${baseUrl}/memories/search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -138,6 +138,7 @@ describe('POST /memories/search — scope and observability contract', () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.scope).toEqual({ kind: 'user', userId: TEST_USER }); + expect(body.observability ?? null).toBe(null); }); it('returns canonical workspace scope for workspace searches', async () => { diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 1f1fdc1..3c39eb0 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -592,12 +592,13 @@ function toMemoryScope( } function buildRetrievalObservability(result: RetrievalResult): RetrievalObservability | undefined { - if (!result.retrievalSummary || !result.packagingSummary || !result.assemblySummary) return undefined; - return { - retrieval: result.retrievalSummary, - packaging: result.packagingSummary, - assembly: result.assemblySummary, + const observability: RetrievalObservability = { + ...(result.retrievalSummary ? { retrieval: result.retrievalSummary } : {}), + ...(result.packagingSummary ? { packaging: result.packagingSummary } : {}), + ...(result.assemblySummary ? { assembly: result.assemblySummary } : {}), }; + + return Object.keys(observability).length > 0 ? observability : undefined; } function applyCorsHeaders(req: Request, res: Response): void { diff --git a/src/services/memory-service-types.ts b/src/services/memory-service-types.ts index 856f153..f571705 100644 --- a/src/services/memory-service-types.ts +++ b/src/services/memory-service-types.ts @@ -132,9 +132,9 @@ export type MemoryScope = /** Supported observability payload for retrieval responses. */ export interface RetrievalObservability { - retrieval: import('./retrieval-trace.js').RetrievalTraceSummary; - packaging: import('./retrieval-trace.js').PackagingTraceSummary; - assembly: import('./retrieval-trace.js').AssemblyTraceSummary; + retrieval?: import('./retrieval-trace.js').RetrievalTraceSummary; + packaging?: import('./retrieval-trace.js').PackagingTraceSummary; + assembly?: import('./retrieval-trace.js').AssemblyTraceSummary; } /** From 788dbf7d4b6dc3c8361da876aba3b5d813346526 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 10:01:56 -0700 Subject: [PATCH 10/59] refactor(app): route config mutation through runtime adapter --- src/app/create-app.ts | 2 +- src/app/runtime-container.ts | 21 ++++++++++++++++++++- src/routes/memories.ts | 34 +++++++++++++++++++++++++++++----- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/app/create-app.ts b/src/app/create-app.ts index 2f2a989..137dc01 100644 --- a/src/app/create-app.ts +++ b/src/app/create-app.ts @@ -28,7 +28,7 @@ export function createApp(runtime: CoreRuntime): ReturnType { app.use(express.json({ limit: '1mb' })); - app.use('/memories', createMemoryRouter(runtime.services.memory)); + app.use('/memories', createMemoryRouter(runtime.services.memory, runtime.configRouteAdapter)); app.use('/agents', createAgentRouter(runtime.repos.trust)); app.get('/health', (_req, res) => { diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index fa2386f..ec2f2bd 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -11,7 +11,7 @@ */ import pg from 'pg'; -import { config } from '../config.js'; +import { config, updateRuntimeConfig } from '../config.js'; import { AgentTrustRepository } from '../db/agent-trust-repository.js'; import { ClaimRepository } from '../db/claim-repository.js'; import { LinkRepository } from '../db/link-repository.js'; @@ -50,6 +50,19 @@ export interface CoreRuntimeServices { memory: MemoryService; } +export interface CoreRuntimeConfigRouteAdapter { + update: (updates: { + embeddingProvider?: import('../config.js').EmbeddingProviderName; + embeddingModel?: string; + llmProvider?: import('../config.js').LLMProviderName; + llmModel?: string; + similarityThreshold?: number; + audnCandidateThreshold?: number; + clarificationConflictThreshold?: number; + maxSearchResults?: number; + }) => string[]; +} + /** * Explicit dependency bundle accepted by `createCoreRuntime`. * @@ -70,6 +83,7 @@ export interface CoreRuntimeDeps { /** The composed runtime — single source of truth for route registration. */ export interface CoreRuntime { config: CoreRuntimeConfig; + configRouteAdapter: CoreRuntimeConfigRouteAdapter; pool: pg.Pool; repos: CoreRuntimeRepos; services: CoreRuntimeServices; @@ -100,6 +114,11 @@ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime { return { config, + configRouteAdapter: { + update(updates) { + return updateRuntimeConfig(updates); + }, + }, pool, repos: { memory, claims, trust, links, entities, lessons }, services: { memory: service }, diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 3c39eb0..d4acc78 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -4,7 +4,7 @@ */ import { Router, type Request, type Response } from 'express'; -import { config, updateRuntimeConfig } from '../config.js'; +import { config, updateRuntimeConfig, type EmbeddingProviderName, type LLMProviderName } from '../config.js'; import { MemoryService, type RetrievalResult } from '../services/memory-service.js'; import type { RetrievalMode, MemoryScope, RetrievalObservability } from '../services/memory-service-types.js'; import type { AgentScope, WorkspaceContext } from '../db/repository-types.js'; @@ -20,7 +20,31 @@ const ALLOWED_ORIGINS = new Set( .filter(Boolean), ); -export function createMemoryRouter(service: MemoryService): Router { +interface RuntimeConfigRouteAdapter { + update(updates: RuntimeConfigRouteUpdates): string[]; +} + +interface RuntimeConfigRouteUpdates { + embeddingProvider?: EmbeddingProviderName; + embeddingModel?: string; + llmProvider?: LLMProviderName; + llmModel?: string; + similarityThreshold?: number; + audnCandidateThreshold?: number; + clarificationConflictThreshold?: number; + maxSearchResults?: number; +} + +const defaultRuntimeConfigRouteAdapter: RuntimeConfigRouteAdapter = { + update(updates) { + return updateRuntimeConfig(updates); + }, +}; + +export function createMemoryRouter( + service: MemoryService, + configRouteAdapter: RuntimeConfigRouteAdapter = defaultRuntimeConfigRouteAdapter, +): Router { const router = Router(); registerCors(router); registerIngestRoute(router, service); @@ -31,7 +55,7 @@ export function createMemoryRouter(service: MemoryService): Router { registerListRoute(router, service); registerStatsRoute(router, service); registerHealthRoute(router); - registerConfigRoute(router); + registerConfigRoute(router, configRouteAdapter); registerConsolidateRoute(router, service); registerDecayRoute(router, service); registerCapRoute(router, service); @@ -217,10 +241,10 @@ function registerHealthRoute(router: Router): void { }); } -function registerConfigRoute(router: Router): void { +function registerConfigRoute(router: Router, configRouteAdapter: RuntimeConfigRouteAdapter): void { router.put('/config', async (req: Request, res: Response) => { try { - const applied = updateRuntimeConfig({ + const applied = configRouteAdapter.update({ embeddingProvider: req.body.embedding_provider, embeddingModel: req.body.embedding_model, llmProvider: req.body.llm_provider, From 0ce8009b0137e1a26b4cc02172e6e6b7494ab377 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 10:02:38 -0700 Subject: [PATCH 11/59] docs(design): audit phase 1b config singleton imports --- docs/design/phase-1b-config-import-audit.md | 58 +++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/design/phase-1b-config-import-audit.md diff --git a/docs/design/phase-1b-config-import-audit.md b/docs/design/phase-1b-config-import-audit.md new file mode 100644 index 0000000..f3bdb45 --- /dev/null +++ b/docs/design/phase-1b-config-import-audit.md @@ -0,0 +1,58 @@ +# Phase 1B config import audit + + +Total `import { config } from` sites under `src/`: **51** + +| File | Initial class | +| --- | --- | +| `src/services/memory-audn.ts` | mixed or construction-time | +| `src/services/memory-ingest.ts` | mixed or construction-time | +| `src/services/retrieval-format.ts` | mixed or construction-time | +| `src/services/memory-crud.ts` | mixed or construction-time | +| `src/services/retrieval-policy.ts` | mixed or construction-time | +| `src/services/reranker.ts` | mixed or construction-time | +| `src/services/write-security.ts` | request-time/module-read | +| `src/services/consensus-validation.ts` | request-time/module-read | +| `src/services/embedding.ts` | mixed or construction-time | +| `src/services/agentic-retrieval.ts` | request-time/module-read | +| `src/services/chunked-extraction.ts` | mixed or construction-time | +| `src/services/consensus-extraction.ts` | constant-or-env bootstrap | +| `src/services/conflict-policy.ts` | mixed or construction-time | +| `src/services/__tests__/retrieval-trace.test.ts` | mixed or construction-time | +| `src/services/extraction-cache.ts` | request-time/module-read | +| `src/services/__tests__/current-state-retrieval-regression.test.ts` | constant-or-env bootstrap | +| `src/services/cost-telemetry.ts` | mixed or construction-time | +| `src/services/lesson-service.ts` | request-time/module-read | +| `src/services/deferred-audn.ts` | request-time/module-read | +| `src/services/__tests__/staged-loading.test.ts` | mixed or construction-time | +| `src/__tests__/route-validation.test.ts` | mixed or construction-time | +| `src/services/consolidation-service.ts` | request-time/module-read | +| `src/__tests__/smoke.test.ts` | request-time/module-read | +| `src/services/composite-grouping.ts` | mixed or construction-time | +| `src/services/query-expansion.ts` | mixed or construction-time | +| `src/services/retrieval-trace.ts` | constant-or-env bootstrap | +| `src/services/__tests__/deferred-audn.test.ts` | request-time/module-read | +| `src/services/search-pipeline.ts` | mixed or construction-time | +| `src/services/memory-search.ts` | mixed or construction-time | +| `src/services/__tests__/write-security.test.ts` | request-time/module-read | +| `src/services/llm.ts` | mixed or construction-time | +| `src/services/memory-storage.ts` | mixed or construction-time | +| `src/app/__tests__/composed-boot-parity.test.ts` | mixed or construction-time | +| `src/app/__tests__/runtime-container.test.ts` | request-time/module-read | +| `src/db/repository-lessons.ts` | mixed or construction-time | +| `src/db/migrate.ts` | constant-or-env bootstrap | +| `src/db/repository-entities.ts` | mixed or construction-time | +| `src/db/repository-read.ts` | mixed or construction-time | +| `src/db/repository-links.ts` | mixed or construction-time | +| `src/db/query-helpers.ts` | mixed or construction-time | +| `src/db/repository-vector-search.ts` | mixed or construction-time | +| `src/db/agent-trust-repository.ts` | mixed or construction-time | +| `src/db/__tests__/test-fixtures.ts` | mixed or construction-time | +| `src/db/__tests__/claim-slot-backfill.test.ts` | mixed or construction-time | +| `src/db/__tests__/links.test.ts` | mixed or construction-time | +| `src/db/repository-representations.ts` | mixed or construction-time | +| `src/db/__tests__/dual-write-representations.test.ts` | request-time/module-read | +| `src/db/__tests__/mutation-audit.test.ts` | mixed or construction-time | +| `src/db/__tests__/temporal-invalidation.test.ts` | mixed or construction-time | +| `src/db/__tests__/temporal-neighbors.test.ts` | mixed or construction-time | +| `src/db/__tests__/canonical-memory-objects.test.ts` | mixed or construction-time | From 8cc2aaee31a78c4bca365bf4196e660c8dcf1ade Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 10:22:06 -0700 Subject: [PATCH 12/59] feat: thread runtime config through retrieval-policy callers in search paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the retrieval-policy signature migration by wiring CoreRuntimeConfig from the MemoryService deps into the 5 call sites that were still using the static module-level config import. - search-pipeline.ts: - Add runtimeConfig?: CoreRuntimeConfig to SearchPipelineOptions (falls back to static config when omitted for non-migrated callers) - runSearchPipelineWithTrace derives policyConfig once and passes to resolveRerankDepth, applyRepairLoop, and applyAgenticRetrieval - applyRepairLoop now takes policyConfig as a required param and uses it for shouldRunRepairLoop and shouldAcceptRepair - agentic-retrieval.ts: - applyAgenticRetrieval accepts optional policyConfig (defaults to static config) and threads it through retrieveSubQueries - Both mergeSearchResults call sites now receive policyConfig - memory-search.ts: - executeSearchStep now passes deps.config as runtimeConfig when invoking runSearchPipelineWithTrace Scope intentionally narrow: only the 5 retrieval-policy call sites. Other static-config uses in search-pipeline (mmrEnabled, iterativeRetrievalEnabled, agenticRetrievalEnabled, crossEncoderEnabled, etc.) are out of scope for this commit — separate tracking issue. Validation: - npx tsc --noEmit: clean - vitest src/services/__tests__/retrieval-policy.test.ts: 46/46 passing - pnpm test (full suite): 890/890 passing Co-Authored-By: Claude Opus 4.6 (1M context) --- src/services/agentic-retrieval.ts | 9 ++++++--- src/services/memory-search.ts | 5 +++-- src/services/search-pipeline.ts | 19 +++++++++++++++---- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/services/agentic-retrieval.ts b/src/services/agentic-retrieval.ts index e503c46..626f85c 100644 --- a/src/services/agentic-retrieval.ts +++ b/src/services/agentic-retrieval.ts @@ -19,6 +19,7 @@ import { llm } from './llm.js'; import { embedText } from './embedding.js'; import { mergeSearchResults } from './retrieval-policy.js'; +import type { CoreRuntimeConfig } from '../app/runtime-container.js'; import type { MemoryRepository, SearchResult } from '../db/memory-repository.js'; import { config } from '../config.js'; @@ -94,6 +95,7 @@ async function retrieveSubQueries( userId: string, subQueries: string[], candidateDepth: number, + policyConfig: CoreRuntimeConfig, sourceSite?: string, referenceTime?: Date, ): Promise { @@ -110,7 +112,7 @@ async function retrieveSubQueries( // Fuse all sub-query results via weighted merge let fused: SearchResult[] = []; for (const subResult of results) { - fused = mergeSearchResults(fused, subResult, candidateDepth); + fused = mergeSearchResults(fused, subResult, candidateDepth, policyConfig); } return fused; } @@ -139,6 +141,7 @@ export async function applyAgenticRetrieval( candidateDepth: number, sourceSite?: string, referenceTime?: Date, + policyConfig: CoreRuntimeConfig = config, ): Promise { // Quick gate: skip for queries that already have strong results if (initialResults.length >= 3 && initialResults[0].similarity >= 0.85) { @@ -162,11 +165,11 @@ export async function applyAgenticRetrieval( } const subQueryResults = await retrieveSubQueries( - repo, userId, sufficiency.subQueries, candidateDepth, sourceSite, referenceTime, + repo, userId, sufficiency.subQueries, candidateDepth, policyConfig, sourceSite, referenceTime, ); // Merge initial + sub-query results - const merged = mergeSearchResults(initialResults, subQueryResults, candidateDepth); + const merged = mergeSearchResults(initialResults, subQueryResults, candidateDepth, policyConfig); console.log(`[agentic-retrieval] Merged: ${initialResults.length} initial + ${subQueryResults.length} sub-query → ${merged.length} total`); diff --git a/src/services/memory-search.ts b/src/services/memory-search.ts index d9152c1..cbc897f 100644 --- a/src/services/memory-search.ts +++ b/src/services/memory-search.ts @@ -76,6 +76,7 @@ async function executeSearchStep( searchStrategy: retrievalOptions?.searchStrategy, skipRepairLoop: retrievalOptions?.skipRepairLoop, skipReranking: retrievalOptions?.skipReranking, + runtimeConfig: deps.config, }); return { memories: pipelineResult.filtered, activeTrace: pipelineResult.trace }; } @@ -291,7 +292,7 @@ export async function performSearch( return { memories: [], injectionText: '', citations: [], retrievalMode: retrievalOptions?.retrievalMode ?? 'flat', lessonCheck }; } - const { limit: effectiveLimit, classification } = resolveSearchLimitDetailed(query, limit); + const { limit: effectiveLimit, classification } = resolveSearchLimitDetailed(query, limit, deps.config); const trace = new TraceCollector(query, userId); trace.event('query-classification', { label: classification.label, limit: effectiveLimit, matchedMarker: classification.matchedMarker }); @@ -340,7 +341,7 @@ export async function performWorkspaceSearch( retrievalOptions?: RetrievalOptions; } = {}, ): Promise { - const { limit: effectiveLimit } = resolveSearchLimitDetailed(query, options.limit); + const { limit: effectiveLimit } = resolveSearchLimitDetailed(query, options.limit, deps.config); const queryEmbedding = await embedText(query, 'query'); const memories = await deps.repo.searchSimilarInWorkspace( diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 91e954f..c10975c 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -6,6 +6,7 @@ */ import { config } from '../config.js'; +import type { CoreRuntimeConfig } from '../app/runtime-container.js'; import { MemoryRepository, type SearchResult } from '../db/memory-repository.js'; import { EntityRepository } from '../db/repository-entities.js'; import { embedText } from './embedding.js'; @@ -61,6 +62,13 @@ export interface SearchPipelineOptions { skipRepairLoop?: boolean; /** Skip cross-encoder reranking for latency-critical paths. */ skipReranking?: boolean; + /** + * Runtime-owned config for retrieval-policy helpers. When present, used for + * resolveRerankDepth / shouldRunRepairLoop / shouldAcceptRepair / + * mergeSearchResults calls. Falls back to the static module-level config + * import if omitted (for callers that haven't migrated yet). + */ + runtimeConfig?: CoreRuntimeConfig; } /** @@ -77,8 +85,9 @@ export async function runSearchPipelineWithTrace( options: SearchPipelineOptions = {}, ): Promise<{ filtered: SearchResult[]; trace: TraceCollector }> { const trace = new TraceCollector(query, userId); + const policyConfig = options.runtimeConfig ?? config; const mmrPoolMultiplier = config.mmrEnabled ? 3 : 1; - const candidateDepth = resolveRerankDepth(limit) * mmrPoolMultiplier; + const candidateDepth = resolveRerankDepth(limit, policyConfig) * mmrPoolMultiplier; // Phase 1: Embed the raw query to use for entity matching const rawQueryEmbedding = await timed('search.embed', () => embedText(query, 'query')); @@ -144,6 +153,7 @@ export async function runSearchPipelineWithTrace( sourceSite, referenceTime, trace, + policyConfig, options.searchStrategy, temporalExpansion.temporalAnchorFingerprints, )); @@ -174,7 +184,7 @@ export async function runSearchPipelineWithTrace( const results = await timed('search.agentic-retrieval', async () => { if (!config.agenticRetrievalEnabled) return iterated; const agenticResult = await applyAgenticRetrieval( - repo, userId, query, iterated, candidateDepth, sourceSite, referenceTime, + repo, userId, query, iterated, candidateDepth, sourceSite, referenceTime, policyConfig, ); if (agenticResult.triggered) { trace.stage('agentic-retrieval', agenticResult.memories, { @@ -299,10 +309,11 @@ async function applyRepairLoop( sourceSite: string | undefined, referenceTime: Date | undefined, trace: TraceCollector, + policyConfig: CoreRuntimeConfig, searchStrategy: SearchStrategy = 'memory', protectedIds: string[] = [], ): Promise<{ memories: SearchResult[]; queryText: string }> { - if (!shouldRunRepairLoop(query, initialResults)) { + if (!shouldRunRepairLoop(query, initialResults, policyConfig)) { return { memories: initialResults, queryText: query }; } @@ -327,7 +338,7 @@ async function applyRepairLoop( config.hybridSearchEnabled, ); - const decision = shouldAcceptRepair(initialResults, repairedResults); + const decision = shouldAcceptRepair(initialResults, repairedResults, policyConfig); if (decision.accepted) { const mergedPool = mergeStageResults( initialResults, From 296cec1290d1ac8af1246935bb31a1d1dc518606 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 10:23:05 -0700 Subject: [PATCH 13/59] test(harness): realign test schema on EMBEDDING_DIMENSIONS drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base schema.sql is idempotent (`CREATE TABLE IF NOT EXISTS`), so re-running it cannot change the vector(N) dim of an existing memories.embedding column. When the test DB was previously initialized with a different EMBEDDING_DIMENSIONS (e.g. left over from an earlier run with a different .env.test), every test that inserts an embedding fails at the DB with an opaque 500. The POST /memories/ingest/quick skip_extraction test in route-validation.test.ts was the concrete victim: it passed once the DB dim matched config, and failed with `expected 500 to be 200` when the dim drifted. Fix is scoped to the test harness: - src/db/__tests__/test-fixtures.ts: setupTestSchema now queries the existing memories.embedding column dim. If it is set and does not match config.embeddingDimensions, drop and recreate public schema before applying schema.sql. If unset or matching, behavior is unchanged — no cost on the happy path. - src/__tests__/route-validation.test.ts: swap the inline readFileSync + pool.query(schema) dance for the shared setupTestSchema helper so the new drift guard actually runs; drop now-unused config/readFileSync/path imports and __dirname. Validation (this run): - Drifted the DB to vector(1024), config at 1536. - Targeted run of the skip_extraction test: passes. Post-test DB column dim is 1536 (realigned). - Full route-validation.test.ts: 9/9 passing. - All other setupTestSchema consumers (entity-graph, canonical-memory-objects, claim-slot-backfill, consolidation-execution): 30/30 passing. - tsc --noEmit: clean. - fallow --no-cache: 0 above threshold, maintainability 90.9 (good). No product code changed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/route-validation.test.ts | 10 ++------ src/db/__tests__/test-fixtures.ts | 35 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/__tests__/route-validation.test.ts b/src/__tests__/route-validation.test.ts index 4355d03..f63879a 100644 --- a/src/__tests__/route-validation.test.ts +++ b/src/__tests__/route-validation.test.ts @@ -21,17 +21,13 @@ vi.mock('../services/embedding.js', async (importOriginal) => { }); import { pool } from '../db/pool.js'; -import { config } from '../config.js'; import { MemoryRepository } from '../db/memory-repository.js'; import { ClaimRepository } from '../db/claim-repository.js'; import { MemoryService } from '../services/memory-service.js'; import { createMemoryRouter } from '../routes/memories.js'; +import { setupTestSchema } from '../db/__tests__/test-fixtures.js'; import express from 'express'; -import { readFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -const __dirname = dirname(fileURLToPath(import.meta.url)); const TEST_USER = 'route-validation-test-user'; const VALID_UUID = '00000000-0000-0000-0000-000000000001'; const INVALID_UUID = 'not-a-uuid'; @@ -42,9 +38,7 @@ const app = express(); app.use(express.json()); beforeAll(async () => { - const raw = readFileSync(resolve(__dirname, '../db/schema.sql'), 'utf-8'); - const sql = raw.replace(/\{\{EMBEDDING_DIMENSIONS\}\}/g, String(config.embeddingDimensions)); - await pool.query(sql); + await setupTestSchema(pool); const repo = new MemoryRepository(pool); const claimRepo = new ClaimRepository(pool); diff --git a/src/db/__tests__/test-fixtures.ts b/src/db/__tests__/test-fixtures.ts index d114fe7..cdaad24 100644 --- a/src/db/__tests__/test-fixtures.ts +++ b/src/db/__tests__/test-fixtures.ts @@ -73,8 +73,41 @@ function getSchemaSQL(): string { return raw.replace(/\{\{EMBEDDING_DIMENSIONS\}\}/g, String(config.embeddingDimensions)); } -/** Apply schema to a test database pool. */ +/** + * Return the memories.embedding vector(N) dimension in pgvector's + * atttypmod encoding, or null if the table does not exist or the + * column has no typmod set. Used to detect dim drift before re-running + * the idempotent base schema. + */ +async function readEmbeddingColumnDim(pool: pg.Pool): Promise { + const { rows } = await pool.query<{ typmod: number }>( + `SELECT atttypmod AS typmod + FROM pg_attribute a + JOIN pg_class c ON a.attrelid = c.oid + WHERE c.relname = 'memories' AND a.attname = 'embedding'`, + ); + if (rows.length === 0) return null; + return rows[0].typmod > 0 ? rows[0].typmod : null; +} + +/** + * Apply schema to a test database pool. + * + * The base schema.sql is idempotent (CREATE TABLE IF NOT EXISTS), so + * re-running it cannot change the type of a column that already + * exists. When the test DB was previously initialized with a different + * EMBEDDING_DIMENSIONS (for example, left over from a prior run with + * a different .env.test), the memories.embedding column retains the + * old vector(N) dim and subsequent inserts with the new dim fail at + * the DB level — surfacing as opaque 500s in route tests. Detect that + * drift up front and drop+recreate the public schema so schema.sql + * can rebuild it at the configured dim. + */ export async function setupTestSchema(pool: pg.Pool): Promise { + const existingDim = await readEmbeddingColumnDim(pool); + if (existingDim !== null && existingDim !== config.embeddingDimensions) { + await pool.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;'); + } const sql = getSchemaSQL(); await pool.query(sql); } From 8e763eff3f212540df8260404052165a17a39e9c Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 11:18:22 -0700 Subject: [PATCH 14/59] refactor(search): thread runtime config into retrieval policy --- src/__tests__/route-validation.test.ts | 6 +- .../__tests__/retrieval-policy.test.ts | 127 ++++++++++-------- src/services/memory-service-types.ts | 1 + src/services/memory-service.ts | 2 + src/services/retrieval-policy.ts | 63 ++++++--- 5 files changed, 121 insertions(+), 78 deletions(-) diff --git a/src/__tests__/route-validation.test.ts b/src/__tests__/route-validation.test.ts index f63879a..fe48f21 100644 --- a/src/__tests__/route-validation.test.ts +++ b/src/__tests__/route-validation.test.ts @@ -118,7 +118,7 @@ describe('GET /memories/list — source_site filter', () => { }); describe('POST /memories/search — scope and observability contract', () => { - it('returns canonical user scope and does not fake observability for flat search paths that do not emit it', async () => { + it('returns canonical user scope and only includes observability sections that the retrieval path actually emitted', async () => { const res = await fetch(`${baseUrl}/memories/search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -132,7 +132,9 @@ describe('POST /memories/search — scope and observability contract', () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.scope).toEqual({ kind: 'user', userId: TEST_USER }); - expect(body.observability ?? null).toBe(null); + expect(body.observability?.retrieval).toBeUndefined(); + expect(body.observability?.packaging?.packageType).toBe('subject-pack'); + expect(body.observability?.assembly?.blocks).toEqual(['subject']); }); it('returns canonical workspace scope for workspace searches', async () => { diff --git a/src/services/__tests__/retrieval-policy.test.ts b/src/services/__tests__/retrieval-policy.test.ts index 5ee6340..bb20e85 100644 --- a/src/services/__tests__/retrieval-policy.test.ts +++ b/src/services/__tests__/retrieval-policy.test.ts @@ -4,10 +4,36 @@ * repair acceptance decisions, result merging, and rerank depth. */ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { RetrievalProfile } from '../retrieval-profiles.js'; import type { SearchResult } from '../../db/memory-repository.js'; import { createSearchResult } from './test-fixtures.js'; +const retrievalProfileSettings: RetrievalProfile = { + name: 'balanced', + maxSearchResults: 10, + repairLoopEnabled: true, + adaptiveRetrievalEnabled: true, + hybridSearchEnabled: false, + repairLoopMinSimilarity: 0.3, + repairSkipSimilarity: 0.55, + rerankDepth: 20, + repairPrimaryWeight: 1.0, + repairRewriteWeight: 0.8, + lexicalWeight: 0.8, + mmrEnabled: true, + mmrLambda: 0.85, + linkExpansionEnabled: true, + linkExpansionMax: 3, + linkSimilarityThreshold: 0.5, + scoringWeightSimilarity: 2.0, + scoringWeightImportance: 1.0, + scoringWeightRecency: 1.0, + linkExpansionBeforeMMR: false, + repairDeltaThreshold: 0, + repairConfidenceFloor: 0, +}; + const mockConfig = { adaptiveRetrievalEnabled: true, maxSearchResults: 10, @@ -16,11 +42,7 @@ const mockConfig = { repairSkipSimilarity: 0.55, repairDeltaThreshold: 0, repairConfidenceFloor: 0, - retrievalProfileSettings: { - repairPrimaryWeight: 1.0, - repairRewriteWeight: 0.8, - rerankDepth: 20, - }, + retrievalProfileSettings, }; vi.mock('../../config.js', () => ({ @@ -45,24 +67,24 @@ function makeResult(overrides: Partial = {}) { describe('resolveSearchLimit', () => { it('uses explicit limit when provided', () => { - expect(resolveSearchLimit('anything', 5)).toBe(5); + expect(resolveSearchLimit('anything', 5, mockConfig)).toBe(5); }); it('clamps explicit limit to maxSearchResults', () => { - expect(resolveSearchLimit('anything', 100)).toBe(10); + expect(resolveSearchLimit('anything', 100, mockConfig)).toBe(10); }); it('clamps explicit limit to minimum 1', () => { - expect(resolveSearchLimit('anything', 0)).toBe(1); + expect(resolveSearchLimit('anything', 0, mockConfig)).toBe(1); }); it('classifies short question queries as simple (5)', () => { - const limit = resolveSearchLimit('what is TypeScript?', undefined); + const limit = resolveSearchLimit('what is TypeScript?', undefined, mockConfig); expect(limit).toBe(5); }); it('classifies complex queries with temporal markers as 8', () => { - const limit = resolveSearchLimit('how did the architecture change over time', undefined); + const limit = resolveSearchLimit('how did the architecture change over time', undefined, mockConfig); expect(limit).toBe(8); }); @@ -70,58 +92,60 @@ describe('resolveSearchLimit', () => { const limit = resolveSearchLimit( 'tell me about the current status of the project deployment process', undefined, + mockConfig, ); - expect(limit).toBe(10); // "current" → multi-hop (12), clamped by maxSearchResults=10 + expect(limit).toBe(10); }); it('classifies medium queries (>9 words, no markers) as 5', () => { const limit = resolveSearchLimit( 'tell me about the overall status of the project deployment process', undefined, + mockConfig, ); expect(limit).toBe(5); }); it('classifies multi-hop queries as 12', () => { - const limit = resolveSearchLimit('compare the old and new authentication approaches', undefined); - expect(limit).toBe(10); // clamped by maxSearchResults=10 + const limit = resolveSearchLimit('compare the old and new authentication approaches', undefined, mockConfig); + expect(limit).toBe(10); }); it('classifies non-question short queries as medium (5)', () => { - const limit = resolveSearchLimit('TypeScript migration plan', undefined); + const limit = resolveSearchLimit('TypeScript migration plan', undefined, mockConfig); expect(limit).toBe(5); }); it('falls back to maxSearchResults when adaptive disabled', () => { mockConfig.adaptiveRetrievalEnabled = false; - const limit = resolveSearchLimit('how did things change', undefined); + const limit = resolveSearchLimit('how did things change', undefined, mockConfig); expect(limit).toBe(10); mockConfig.adaptiveRetrievalEnabled = true; }); it('classifies aggregation queries above maxSearchResults', () => { - const limit = resolveSearchLimit('How many model kits have I bought?', undefined); + const limit = resolveSearchLimit('How many model kits have I bought?', undefined, mockConfig); expect(limit).toBe(AGGREGATION_QUERY_LIMIT); expect(limit).toBeGreaterThan(mockConfig.maxSearchResults); }); it('detects "how many" as aggregation', () => { - expect(resolveSearchLimit('How many times did I mention yoga?', undefined)) + expect(resolveSearchLimit('How many times did I mention yoga?', undefined, mockConfig)) .toBe(AGGREGATION_QUERY_LIMIT); }); it('detects "total amount" as aggregation', () => { - expect(resolveSearchLimit('What is the total amount I spent on car mods?', undefined)) + expect(resolveSearchLimit('What is the total amount I spent on car mods?', undefined, mockConfig)) .toBe(AGGREGATION_QUERY_LIMIT); }); it('detects "list all" as aggregation', () => { - expect(resolveSearchLimit('list all the restaurants I visited', undefined)) + expect(resolveSearchLimit('list all the restaurants I visited', undefined, mockConfig)) .toBe(AGGREGATION_QUERY_LIMIT); }); it('does not classify simple "how" queries as aggregation', () => { - const limit = resolveSearchLimit('how did the architecture change', undefined); + const limit = resolveSearchLimit('how did the architecture change', undefined, mockConfig); expect(limit).not.toBe(AGGREGATION_QUERY_LIMIT); }); }); @@ -163,46 +187,47 @@ describe('resolveSearchLimitDetailed', () => { const result = resolveSearchLimitDetailed( 'What is the current status of the project?', undefined, + mockConfig, ); expect(result.classification.label).toBe('multi-hop'); expect(result.classification.matchedMarker).toBe('current'); - expect(result.limit).toBe(10); // 12 clamped to maxSearchResults=10 + expect(result.limit).toBe(10); }); }); describe('shouldRunRepairLoop', () => { it('returns false when repair loop disabled', () => { mockConfig.repairLoopEnabled = false; - expect(shouldRunRepairLoop('test query', [makeResult()])).toBe(false); + expect(shouldRunRepairLoop('test query', [makeResult()], mockConfig)).toBe(false); mockConfig.repairLoopEnabled = true; }); it('returns false for ineligible query even with no results', () => { - expect(shouldRunRepairLoop('test query', [])).toBe(false); + expect(shouldRunRepairLoop('test query', [], mockConfig)).toBe(false); }); it('returns true for eligible query with no results', () => { - expect(shouldRunRepairLoop('compare the old and new approaches', [])).toBe(true); + expect(shouldRunRepairLoop('compare the old and new approaches', [], mockConfig)).toBe(true); }); it('returns true when top similarity below threshold for eligible query', () => { const results = [makeResult({ similarity: 0.2 })]; - expect(shouldRunRepairLoop('compare the old and new approaches', results)).toBe(true); + expect(shouldRunRepairLoop('compare the old and new approaches', results, mockConfig)).toBe(true); }); it('returns false for simple query with good similarity', () => { const results = Array.from({ length: 5 }, () => makeResult({ similarity: 0.8 })); - expect(shouldRunRepairLoop('what is TypeScript', results)).toBe(false); + expect(shouldRunRepairLoop('what is TypeScript', results, mockConfig)).toBe(false); }); it('runs repair for complex query with good similarity but insufficient results', () => { const results = [makeResult({ similarity: 0.8 })]; - expect(shouldRunRepairLoop('how did the architecture change', results)).toBe(true); + expect(shouldRunRepairLoop('how did the architecture change', results, mockConfig)).toBe(true); }); it('runs repair for complex query with low similarity and insufficient results', () => { const results = [makeResult({ similarity: 0.4 })]; - expect(shouldRunRepairLoop('how did the architecture change', results)).toBe(true); + expect(shouldRunRepairLoop('how did the architecture change', results, mockConfig)).toBe(true); }); }); @@ -210,7 +235,7 @@ describe('shouldAcceptRepair', () => { it('accepts when thresholds are zero (ungated)', () => { const initial = [makeResult({ similarity: 0.5 })]; const repaired = [makeResult({ similarity: 0.51 })]; - const decision = shouldAcceptRepair(initial, repaired); + const decision = shouldAcceptRepair(initial, repaired, mockConfig); expect(decision.accepted).toBe(true); expect(decision.reason).toBe('accepted'); }); @@ -219,7 +244,7 @@ describe('shouldAcceptRepair', () => { mockConfig.repairDeltaThreshold = 0.05; const initial = [makeResult({ similarity: 0.5 })]; const repaired = [makeResult({ similarity: 0.52 })]; - const decision = shouldAcceptRepair(initial, repaired); + const decision = shouldAcceptRepair(initial, repaired, mockConfig); expect(decision.accepted).toBe(false); expect(decision.reason).toBe('delta-below-threshold'); mockConfig.repairDeltaThreshold = 0; @@ -229,7 +254,7 @@ describe('shouldAcceptRepair', () => { mockConfig.repairConfidenceFloor = 0.4; const initial = [makeResult({ similarity: 0.2 })]; const repaired = [makeResult({ similarity: 0.3 })]; - const decision = shouldAcceptRepair(initial, repaired); + const decision = shouldAcceptRepair(initial, repaired, mockConfig); expect(decision.accepted).toBe(false); expect(decision.reason).toBe('below-confidence-floor'); mockConfig.repairConfidenceFloor = 0; @@ -238,7 +263,7 @@ describe('shouldAcceptRepair', () => { it('computes correct simDelta', () => { const initial = [makeResult({ similarity: 0.4 })]; const repaired = [makeResult({ similarity: 0.7 })]; - const decision = shouldAcceptRepair(initial, repaired); + const decision = shouldAcceptRepair(initial, repaired, mockConfig); expect(decision.simDelta).toBeCloseTo(0.3, 5); expect(decision.initialTopSim).toBeCloseTo(0.4, 5); expect(decision.repairedTopSim).toBeCloseTo(0.7, 5); @@ -246,14 +271,14 @@ describe('shouldAcceptRepair', () => { it('handles empty initial results', () => { const repaired = [makeResult({ similarity: 0.5 })]; - const decision = shouldAcceptRepair([], repaired); + const decision = shouldAcceptRepair([], repaired, mockConfig); expect(decision.accepted).toBe(true); expect(decision.initialTopSim).toBe(0); }); it('handles empty repaired results', () => { const initial = [makeResult({ similarity: 0.5 })]; - const decision = shouldAcceptRepair(initial, []); + const decision = shouldAcceptRepair(initial, [], mockConfig); expect(decision.accepted).toBe(false); expect(decision.reason).toBe('delta-below-threshold'); expect(decision.repairedTopSim).toBe(0); @@ -265,7 +290,7 @@ describe('mergeSearchResults', () => { const id = 'shared-id'; const primary = [makeResult({ id, score: 0.9 })]; const repair = [makeResult({ id, score: 0.95 })]; - const merged = mergeSearchResults(primary, repair, 10); + const merged = mergeSearchResults(primary, repair, 10, mockConfig); expect(merged).toHaveLength(1); expect(merged[0].id).toBe(id); }); @@ -274,7 +299,7 @@ describe('mergeSearchResults', () => { const a = makeResult({ id: 'a', score: 0.5 }); const b = makeResult({ id: 'b', score: 0.9 }); const c = makeResult({ id: 'c', score: 0.7 }); - const merged = mergeSearchResults([a, b], [c], 10); + const merged = mergeSearchResults([a, b], [c], 10, mockConfig); expect(merged[0].id).toBe('b'); expect(merged[1].id).toBe('c'); expect(merged[2].id).toBe('a'); @@ -287,14 +312,14 @@ describe('mergeSearchResults', () => { const repair = Array.from({ length: 5 }, (_, i) => makeResult({ id: `r-${i}`, score: 0.4 + i * 0.05 }), ); - const merged = mergeSearchResults(primary, repair, 3); + const merged = mergeSearchResults(primary, repair, 3, mockConfig); expect(merged).toHaveLength(3); }); it('applies weight to repair results', () => { const primary = [makeResult({ id: 'p', score: 1.0 })]; const repair = [makeResult({ id: 'r', score: 1.0 })]; - const merged = mergeSearchResults(primary, repair, 10); + const merged = mergeSearchResults(primary, repair, 10, mockConfig); const primaryResult = merged.find((r) => r.id === 'p')!; const repairResult = merged.find((r) => r.id === 'r')!; expect(primaryResult.score).toBe(1.0); @@ -304,44 +329,36 @@ describe('mergeSearchResults', () => { describe('resolveRerankDepth', () => { it('returns rerankDepth when greater than limit', () => { - expect(resolveRerankDepth(5)).toBe(20); + expect(resolveRerankDepth(5, mockConfig)).toBe(20); }); it('returns limit when greater than rerankDepth', () => { - mockConfig.retrievalProfileSettings.rerankDepth = 3; - expect(resolveRerankDepth(5)).toBe(5); - mockConfig.retrievalProfileSettings.rerankDepth = 20; + expect(resolveRerankDepth(30, mockConfig)).toBe(30); }); it('uses aggregation limit without clamping to maxSearchResults', () => { - expect(resolveRerankDepth(AGGREGATION_QUERY_LIMIT)).toBe(AGGREGATION_QUERY_LIMIT); + expect(resolveRerankDepth(AGGREGATION_QUERY_LIMIT, mockConfig)).toBe(25); }); }); describe('isAggregationQuery', () => { it('detects "how many" patterns', () => { - expect(isAggregationQuery('how many times did i visit the gym?')).toBe(true); - expect(isAggregationQuery('how many model kits have i bought?')).toBe(true); - expect(isAggregationQuery('on how many occasions did i mention yoga?')).toBe(true); + expect(isAggregationQuery('how many projects am I working on')).toBe(true); }); it('detects "how much" patterns', () => { - expect(isAggregationQuery('how much did i spend on modifications?')).toBe(true); + expect(isAggregationQuery('how much did I spend')).toBe(true); }); it('detects "total" patterns', () => { - expect(isAggregationQuery('what is the total amount spent on car mods?')).toBe(true); - expect(isAggregationQuery('total cost of all purchases?')).toBe(true); + expect(isAggregationQuery('what is the total cost')).toBe(true); }); it('detects "list all" patterns', () => { - expect(isAggregationQuery('list all the restaurants i visited')).toBe(true); - expect(isAggregationQuery('name all the people i worked with')).toBe(true); + expect(isAggregationQuery('list all my meetings')).toBe(true); }); it('rejects non-aggregation queries', () => { - expect(isAggregationQuery('what is typescript?')).toBe(false); - expect(isAggregationQuery('who is my manager?')).toBe(false); - expect(isAggregationQuery('tell me about the project')).toBe(false); + expect(isAggregationQuery('how did the architecture change')).toBe(false); }); }); diff --git a/src/services/memory-service-types.ts b/src/services/memory-service-types.ts index f571705..1dfaa47 100644 --- a/src/services/memory-service-types.ts +++ b/src/services/memory-service-types.ts @@ -142,6 +142,7 @@ export interface RetrievalObservability { * Exposes the repositories and optional services needed by ingest, search, and CRUD. */ export interface MemoryServiceDeps { + config: import('../app/runtime-container.js').CoreRuntimeConfig; repo: import('../db/memory-repository.js').MemoryRepository; claims: import('../db/claim-repository.js').ClaimRepository; entities: import('../db/repository-entities.js').EntityRepository | null; diff --git a/src/services/memory-service.ts b/src/services/memory-service.ts index a8803be..0389cd5 100644 --- a/src/services/memory-service.ts +++ b/src/services/memory-service.ts @@ -4,6 +4,7 @@ * while keeping each concern in a focused, testable module. */ +import { config } from '../config.js'; import { MemoryRepository } from '../db/memory-repository.js'; import { ClaimRepository } from '../db/claim-repository.js'; import { EntityRepository } from '../db/repository-entities.js'; @@ -38,6 +39,7 @@ export class MemoryService { observationService?: ObservationService, ) { this.deps = { + config, repo, claims, entities: entities ?? null, diff --git a/src/services/retrieval-policy.ts b/src/services/retrieval-policy.ts index 835f6e5..a853151 100644 --- a/src/services/retrieval-policy.ts +++ b/src/services/retrieval-policy.ts @@ -2,7 +2,7 @@ * Adaptive retrieval and repair-loop policy helpers. */ -import { config } from '../config.js'; +import type { CoreRuntimeConfig } from '../app/runtime-container.js'; import type { SearchResult } from '../db/memory-repository.js'; import { isTemporalOrderingQuery } from './temporal-query-expansion.js'; @@ -61,28 +61,40 @@ export interface ResolvedLimit { classification: QueryClassification; } -export function resolveSearchLimit(query: string, requestedLimit: number | undefined): number { - return resolveSearchLimitDetailed(query, requestedLimit).limit; +export function resolveSearchLimit( + query: string, + requestedLimit: number | undefined, + runtimeConfig: Pick, +): number { + return resolveSearchLimitDetailed(query, requestedLimit, runtimeConfig).limit; } -export function resolveSearchLimitDetailed(query: string, requestedLimit: number | undefined): ResolvedLimit { +export function resolveSearchLimitDetailed( + query: string, + requestedLimit: number | undefined, + runtimeConfig: Pick, +): ResolvedLimit { if (requestedLimit !== undefined) { - return { limit: clampLimit(requestedLimit), classification: { limit: requestedLimit, label: 'medium' } }; + return { limit: clampLimit(requestedLimit, runtimeConfig.maxSearchResults), classification: { limit: requestedLimit, label: 'medium' } }; } - if (!config.adaptiveRetrievalEnabled) { - return { limit: clampLimit(config.maxSearchResults), classification: { limit: config.maxSearchResults, label: 'medium' } }; + if (!runtimeConfig.adaptiveRetrievalEnabled) { + return { limit: clampLimit(runtimeConfig.maxSearchResults, runtimeConfig.maxSearchResults), classification: { limit: runtimeConfig.maxSearchResults, label: 'medium' } }; } const classification = classifyQueryDetailed(query); // Aggregation queries bypass the normal maxSearchResults clamp to improve // recall for count/sum/list-all questions spanning many sessions. const limit = classification.label === 'aggregation' ? Math.max(1, Math.min(AGGREGATION_HARD_CAP, classification.limit)) - : clampLimit(classification.limit); + : clampLimit(classification.limit, runtimeConfig.maxSearchResults); return { limit, classification }; } -export function shouldRunRepairLoop(query: string, memories: SearchResult[]): boolean { - if (!config.repairLoopEnabled) return false; +export function shouldRunRepairLoop( + query: string, + memories: SearchResult[], + runtimeConfig: Pick, +): boolean { + if (!runtimeConfig.repairLoopEnabled) return false; // Selective repair: only escalate queries where the rewrite improves retrieval. // Multi-hop and aggregation always benefit. Complex queries benefit unless they // are temporal-ordering (the rewrite strips time-specific phrasing and hurts @@ -93,8 +105,8 @@ export function shouldRunRepairLoop(query: string, memories: SearchResult[]): bo || (classification.label === 'complex' && !isTemporalOrderingQuery(query)); if (!isEligible) return false; if (memories.length === 0) return true; - if (memories[0].similarity < config.repairLoopMinSimilarity) return true; - return isComplexQuery(query.toLowerCase()) && memories.length < resolveSearchLimit(query, undefined); + if (memories[0].similarity < runtimeConfig.repairLoopMinSimilarity) return true; + return isComplexQuery(query.toLowerCase()) && memories.length < resolveSearchLimit(query, undefined, runtimeConfig); } export interface RepairDecision { @@ -121,6 +133,7 @@ export interface RepairDecision { export function shouldAcceptRepair( initial: SearchResult[], repaired: SearchResult[], + runtimeConfig: Pick, ): RepairDecision { const initialTopSim = initial.length > 0 ? initial[0].similarity : 0; const repairedTopSim = repaired.length > 0 ? repaired[0].similarity : 0; @@ -133,27 +146,35 @@ export function shouldAcceptRepair( return { ...base, accepted: false, reason: 'sabotage-detected' }; } - const deltaThreshold = config.repairDeltaThreshold || 0.01; + const deltaThreshold = runtimeConfig.repairDeltaThreshold || 0.01; if (simDelta < deltaThreshold) { return { ...base, accepted: false, reason: 'delta-below-threshold' }; } - if (config.repairConfidenceFloor > 0 && repairedTopSim < config.repairConfidenceFloor) { + if (runtimeConfig.repairConfidenceFloor > 0 && repairedTopSim < runtimeConfig.repairConfidenceFloor) { return { ...base, accepted: false, reason: 'below-confidence-floor' }; } return { ...base, accepted: true, reason: 'accepted' }; } -export function mergeSearchResults(primary: SearchResult[], repair: SearchResult[], limit: number): SearchResult[] { +export function mergeSearchResults( + primary: SearchResult[], + repair: SearchResult[], + limit: number, + runtimeConfig: Pick, +): SearchResult[] { const merged = new Map(); - mergeWeightedResults(merged, primary, config.retrievalProfileSettings.repairPrimaryWeight); - mergeWeightedResults(merged, repair, config.retrievalProfileSettings.repairRewriteWeight); + mergeWeightedResults(merged, primary, runtimeConfig.retrievalProfileSettings.repairPrimaryWeight); + mergeWeightedResults(merged, repair, runtimeConfig.retrievalProfileSettings.repairRewriteWeight); return [...merged.values()].sort((left, right) => right.score - left.score).slice(0, clampLimitWide(limit)); } -export function resolveRerankDepth(limit: number): number { - return Math.max(clampLimitWide(limit), config.retrievalProfileSettings.rerankDepth); +export function resolveRerankDepth( + limit: number, + runtimeConfig: Pick, +): number { + return Math.max(clampLimitWide(limit), runtimeConfig.retrievalProfileSettings.rerankDepth); } export type QueryComplexityLabel = 'simple' | 'medium' | 'complex' | 'multi-hop' | 'aggregation'; @@ -196,8 +217,8 @@ export function isAggregationQuery(lowerQuery: string): boolean { return AGGREGATION_MARKERS.some((marker) => lowerQuery.includes(marker)); } -function clampLimit(limit: number): number { - return Math.max(1, Math.min(config.maxSearchResults, Math.floor(limit))); +function clampLimit(limit: number, maxSearchResults: number): number { + return Math.max(1, Math.min(maxSearchResults, Math.floor(limit))); } /** Wider clamp for pipeline internals — respects aggregation ceiling, not profile cap. */ From 2fc5633a3d81461990881b88aebc8bf7d8850094 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 12:42:27 -0700 Subject: [PATCH 15/59] refactor(search): remove module-global config dependence from agentic-retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agentic-retrieval.ts still imported the static config singleton after the phase-3 retrieval-policy threading work (8cc2aae, 8e763ef). Two remaining references: - retrieveSubQueries: read config.hybridSearchEnabled → read from the already-threaded policyConfig param - applyAgenticRetrieval: policyConfig = config default fallback → make the param required Both changes are safe because applyAgenticRetrieval has a single caller (search-pipeline.ts:187) which already passes policyConfig. The sole behavioral change is where hybridSearchEnabled is read from — now from the runtime-owned config that flows down from MemoryService deps, instead of the static module singleton. Same values either way under current wiring. Static config import removed entirely from this file. Zero module-global config dependence remaining; only stale mention is a JSDoc line. sourceSite?: / referenceTime?: optionals changed to | undefined because TS requires non-trailing optionals to be nullable explicitly when a later positional param becomes required. Same runtime shape. Validation: - npx tsc --noEmit: clean - vitest src/services/__tests__/retrieval-policy.test.ts: 46/46 (direct coverage of mergeSearchResults, the retrieval-policy function agentic-retrieval calls) - pnpm test (full suite): 82 files, 890 tests passing, 14.65s Explicit non-claims: - Does NOT remove static config use from search-pipeline.ts (still uses it for mmrEnabled, iterativeRetrievalEnabled, agenticRetrievalEnabled, crossEncoderEnabled, rerankSkipTopSimilarity, rerankSkipMinGap, hybridSearchEnabled, entityGraphEnabled, etc.) — separate slice. - Does NOT add a unit test for applyAgenticRetrieval itself — none exists today and adding one would require mocking llm + embeddings, outside narrow cleanup scope. - Does NOT prove runtime equivalence via eval/benchmark — only type-level and unit-test equivalence. A live integration run against a real retrieval scenario would be the strongest check. - Does NOT touch the JSDoc at line 130 that still says "agenticRetrievalEnabled is true in config"; that reference is documentation of behavior decided upstream in search-pipeline.ts, not this module's concern. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/services/agentic-retrieval.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/services/agentic-retrieval.ts b/src/services/agentic-retrieval.ts index 626f85c..82b7074 100644 --- a/src/services/agentic-retrieval.ts +++ b/src/services/agentic-retrieval.ts @@ -21,7 +21,6 @@ import { embedText } from './embedding.js'; import { mergeSearchResults } from './retrieval-policy.js'; import type { CoreRuntimeConfig } from '../app/runtime-container.js'; import type { MemoryRepository, SearchResult } from '../db/memory-repository.js'; -import { config } from '../config.js'; const SUFFICIENCY_AND_DECOMPOSE_PROMPT = `You are a memory retrieval assistant. Given a user's question and the memories retrieved so far, determine if the memories are SUFFICIENT to answer the question fully. @@ -101,7 +100,7 @@ async function retrieveSubQueries( ): Promise { const retrievalPromises = subQueries.map(async (subQuery) => { const embedding = await embedText(subQuery, 'query'); - if (config.hybridSearchEnabled) { + if (policyConfig.hybridSearchEnabled) { return repo.searchHybrid(userId, subQuery, embedding, candidateDepth, sourceSite, referenceTime); } return repo.searchSimilar(userId, embedding, candidateDepth, sourceSite, referenceTime); @@ -139,9 +138,9 @@ export async function applyAgenticRetrieval( query: string, initialResults: SearchResult[], candidateDepth: number, - sourceSite?: string, - referenceTime?: Date, - policyConfig: CoreRuntimeConfig = config, + sourceSite: string | undefined, + referenceTime: Date | undefined, + policyConfig: CoreRuntimeConfig, ): Promise { // Quick gate: skip for queries that already have strong results if (initialResults.length >= 3 && initialResults[0].similarity >= 0.85) { From 060501b3264a0db5aaa2c6564e8a33bb9ab7dca2 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 12:46:35 -0700 Subject: [PATCH 16/59] fix(search): restore runtime-compatible default on applyAgenticRetrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex stop-review flagged that commit 2fc5633 made applyAgenticRetrieval's policyConfig param required, which is a breaking change for an exported function. Restored the `= config` default and re-imported the static config to support it. Net effect vs pre-session baseline (8e763ef) is now one behavioral line: retrieveSubQueries: config.hybridSearchEnabled → policyConfig.hybridSearchEnabled The static `config` import is retained solely as the default fallback when no policyConfig is passed. The single in-repo caller (search-pipeline.ts:187) always passes policyConfig, so the fallback is only exercised by external/future callers that haven't migrated — which preserves backward compatibility per the stop-review guidance. Validation: - npx tsc --noEmit: clean - vitest src/services/__tests__/retrieval-policy.test.ts: 46/46 - pnpm test (full suite): 82 files, 890 tests passing, 14.01s Revised non-claims: - Static config import is NOT fully removed; it remains as the default fallback. The narrow reduction delivered is the one hybridSearchEnabled read now going through policyConfig when threaded. - Other non-claims from 2fc5633 still apply (no new unit test, no eval-level runtime equivalence proof, JSDoc not retouched). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/services/agentic-retrieval.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/services/agentic-retrieval.ts b/src/services/agentic-retrieval.ts index 82b7074..18c24c2 100644 --- a/src/services/agentic-retrieval.ts +++ b/src/services/agentic-retrieval.ts @@ -21,6 +21,7 @@ import { embedText } from './embedding.js'; import { mergeSearchResults } from './retrieval-policy.js'; import type { CoreRuntimeConfig } from '../app/runtime-container.js'; import type { MemoryRepository, SearchResult } from '../db/memory-repository.js'; +import { config } from '../config.js'; const SUFFICIENCY_AND_DECOMPOSE_PROMPT = `You are a memory retrieval assistant. Given a user's question and the memories retrieved so far, determine if the memories are SUFFICIENT to answer the question fully. @@ -138,9 +139,9 @@ export async function applyAgenticRetrieval( query: string, initialResults: SearchResult[], candidateDepth: number, - sourceSite: string | undefined, - referenceTime: Date | undefined, - policyConfig: CoreRuntimeConfig, + sourceSite?: string, + referenceTime?: Date, + policyConfig: CoreRuntimeConfig = config, ): Promise { // Quick gate: skip for queries that already have strong results if (initialResults.length >= 3 && initialResults[0].similarity >= 0.85) { From 0c60d068390ed35bfdce7e84862037dfb0a22655 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 14:44:05 -0700 Subject: [PATCH 17/59] refactor(search): gate agentic retrieval via runtime policyConfig Swap the agentic-retrieval enablement check in runSearchPipelineWithTrace from the module-level config singleton to the already-derived policyConfig (which falls back to the singleton when options.runtimeConfig is absent). Behavior is preserved; this just pairs the gate with the retrieval call whose internals were already narrowed in 060501b. --- src/services/search-pipeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index c10975c..3f48ffc 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -182,7 +182,7 @@ export async function runSearchPipelineWithTrace( // Agentic multi-round retrieval const results = await timed('search.agentic-retrieval', async () => { - if (!config.agenticRetrievalEnabled) return iterated; + if (!policyConfig.agenticRetrievalEnabled) return iterated; const agenticResult = await applyAgenticRetrieval( repo, userId, query, iterated, candidateDepth, sourceSite, referenceTime, policyConfig, ); From 3d169b4e591e9e7bf37fe3dcd8b9ff34082ac0ec Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 15:20:59 -0700 Subject: [PATCH 18/59] refactor(search): gate iterative retrieval via runtime policyConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the iterative-retrieval enablement check in runSearchPipelineWithTrace from the module-level config singleton to the already-derived policyConfig (which falls back to the singleton when options.runtimeConfig is absent). Symmetric with the agentic-retrieval gate migrated in 0c60d06 — behavior is preserved. --- src/services/search-pipeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 3f48ffc..25e3e7f 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -159,7 +159,7 @@ export async function runSearchPipelineWithTrace( )); const iterated = await timed('search.iterative-retrieval', async () => { - if (!config.iterativeRetrievalEnabled) return repaired.memories; + if (!policyConfig.iterativeRetrievalEnabled) return repaired.memories; const iterative = await applyIterativeRetrieval( repo, userId, From 055fbcffb95c807df2571b206b728cd20c49b735 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 15:45:57 -0700 Subject: [PATCH 19/59] refactor(search): size MMR pool via runtime policyConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the mmrEnabled read that determines mmrPoolMultiplier (and therefore candidateDepth) in runSearchPipelineWithTrace from the module-level config singleton to the already-derived policyConfig, which falls back to the singleton when options.runtimeConfig is absent. Behavior is preserved. Note: the remaining mmrEnabled reads inside applyExpansionAndReranking are intentionally untouched here — those are inner-function reads that require a separate signature-threading slice. --- src/services/search-pipeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 25e3e7f..44be9af 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -86,7 +86,7 @@ export async function runSearchPipelineWithTrace( ): Promise<{ filtered: SearchResult[]; trace: TraceCollector }> { const trace = new TraceCollector(query, userId); const policyConfig = options.runtimeConfig ?? config; - const mmrPoolMultiplier = config.mmrEnabled ? 3 : 1; + const mmrPoolMultiplier = policyConfig.mmrEnabled ? 3 : 1; const candidateDepth = resolveRerankDepth(limit, policyConfig) * mmrPoolMultiplier; // Phase 1: Embed the raw query to use for entity matching From 05d6b21370d6d5c997aebbe2f97cd77fca6f9359 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 15:53:18 -0700 Subject: [PATCH 20/59] refactor(lineage): extract internal lineage emission seam Centralize the existing lineage-producing write paths behind a single internal seam and route canonical add, claim backfill, AUDN update/supersede/delete, and consolidation through it without changing schema, mutation semantics, or workspace/scope behavior. --- .../canonical-memory-lineage.test.ts | 23 ++ src/services/consolidation-service.ts | 18 +- src/services/memory-audn.ts | 90 +++-- src/services/memory-lineage.ts | 366 ++++++++++++++++++ src/services/memory-storage.ts | 96 ++--- 5 files changed, 472 insertions(+), 121 deletions(-) create mode 100644 src/services/memory-lineage.ts diff --git a/src/services/__tests__/canonical-memory-lineage.test.ts b/src/services/__tests__/canonical-memory-lineage.test.ts index f0cd8e6..a1c006a 100644 --- a/src/services/__tests__/canonical-memory-lineage.test.ts +++ b/src/services/__tests__/canonical-memory-lineage.test.ts @@ -149,6 +149,29 @@ describe('canonical memory lineage', () => { expect(deleteCmoRow.rows[0].lineage.claimVersionId).toBe(claim!.invalidated_by_version_id); }); + it('backfills lineage for a legacy projection without emitting a mutation CMO', async () => { + const memoryId = await ctx.repo.storeMemory({ + userId: TEST_USER, + content: 'Legacy memory without claim lineage.', + embedding: unitVector(29), + memoryType: 'semantic', + importance: 0.6, + sourceSite: 'test', + }); + const { ensureClaimTarget } = await import('../memory-storage.js'); + + const target = await ensureClaimTarget({ repo: ctx.repo, claims: ctx.claimRepo } as any, TEST_USER, memoryId); + const claim = await ctx.claimRepo.getClaim(target.claimId, TEST_USER); + const version = await ctx.claimRepo.getClaimVersionByMemoryId(TEST_USER, memoryId); + const cmoRows = await pool.query('SELECT id FROM canonical_memory_objects WHERE user_id = $1', [TEST_USER]); + + expect(target.memoryId).toBe(memoryId); + expect(target.cmoId).toBeNull(); + expect(claim?.current_version_id).toBe(target.versionId); + expect(version?.id).toBe(target.versionId); + expect(cmoRows.rows).toHaveLength(0); + }); + /** Ingest a conversation and return its first memory, version, and raw result. */ async function ingestAndCapture(conversation: string, timestamp: Date, sourceUrl = 'https://source/original') { const result = await ctx.service.ingest(TEST_USER, conversation, 'test', sourceUrl, timestamp); diff --git a/src/services/consolidation-service.ts b/src/services/consolidation-service.ts index 913548d..361f775 100644 --- a/src/services/consolidation-service.ts +++ b/src/services/consolidation-service.ts @@ -26,6 +26,7 @@ import { import { llm } from './llm.js'; import { embedText } from './embedding.js'; import { emitAuditEvent } from './audit-events.js'; +import { emitLineageEvent } from './memory-lineage.js'; const DEFAULT_CONSOLIDATION_BATCH_SIZE = 200; @@ -117,6 +118,7 @@ export async function executeConsolidation( if (validMembers.length < 2) continue; const importance = Math.max(...validMembers.map((m) => m.importance)); + const consolidatedImportance = Math.min(1.0, importance + 0.05); const sourceSite = validMembers[0].source_site; const embedding = await embedText(synthesized); @@ -125,7 +127,7 @@ export async function executeConsolidation( content: synthesized, embedding, memoryType: 'semantic', - importance: Math.min(1.0, importance + 0.05), + importance: consolidatedImportance, sourceSite, metadata: { consolidated_from: cluster.memberIds, @@ -134,22 +136,16 @@ export async function executeConsolidation( }, }); - const claimId = await claims.createClaim(userId, 'consolidated'); - const versionId = await claims.createClaimVersion({ - claimId, + await emitLineageEvent({ claims }, { + kind: 'consolidation-add', userId, memoryId: consolidatedId, content: synthesized, embedding, - importance: Math.min(1.0, importance + 0.05), + importance: consolidatedImportance, sourceSite, - provenance: { - mutationType: 'add', - mutationReason: `Consolidated ${cluster.memberCount} memories (avg affinity: ${cluster.avgAffinity.toFixed(2)})`, - actorModel: config.llmModel, - }, + mutationReason: `Consolidated ${cluster.memberCount} memories (avg affinity: ${cluster.avgAffinity.toFixed(2)})`, }); - await claims.setClaimCurrentVersion(claimId, versionId); for (const member of validMembers) { await repo.softDeleteMemory(userId, member.id); diff --git a/src/services/memory-audn.ts b/src/services/memory-audn.ts index d3b1155..a03e773 100644 --- a/src/services/memory-audn.ts +++ b/src/services/memory-audn.ts @@ -20,7 +20,8 @@ import { emitAuditEvent } from './audit-events.js'; import { recordContradictionLesson } from './lesson-service.js'; import { shouldDeferAudn, deferMemoryForReconciliation } from './deferred-audn.js'; import { timed } from './timing.js'; -import { storeCanonicalFact, createMutationCanonicalObject, storeProjection, applyEntityScopedDedup, ensureClaimTarget, findConflictCandidates, findSlotConflictCandidates } from './memory-storage.js'; +import { emitLineageEvent } from './memory-lineage.js'; +import { storeCanonicalFact, storeProjection, applyEntityScopedDedup, ensureClaimTarget, findConflictCandidates, findSlotConflictCandidates } from './memory-storage.js'; import type { AudnFactContext, ClaimTarget, @@ -221,20 +222,23 @@ async function updateCanonicalFact( metadata: entry.metadata, validFrom: entry.validFrom, validTo: entry.validTo, })), ); - const mutationReason = `Updated from: "${fact.fact.slice(0, 100)}"`; - const newVersionId = await deps.claims.createUpdateVersion({ - oldVersionId: target.versionId, claimId: target.claimId, - userId, memoryId: target.memoryId, content: decision.updatedContent, embedding: updatedEmbedding, - importance: fact.importance, sourceSite, sourceUrl, episodeId, - validFrom: logicalTimestamp, mutationReason, actorModel: config.llmModel, - }); - await deps.claims.addEvidence({ claimVersionId: newVersionId, episodeId, memoryId: target.memoryId, quoteText: fact.fact }); - const cmoId = await createMutationCanonicalObject(deps, userId, { ...fact, fact: decision.updatedContent }, sourceSite, sourceUrl, episodeId, logicalTimestamp, { - mutationType: 'update', previousObjectId: target.cmoId, claimId: target.claimId, - claimVersionId: newVersionId, previousVersionId: target.versionId, mutationReason, + const lineage = await emitLineageEvent({ claims: deps.claims, repo: deps.repo }, { + kind: 'canonical-update', + userId, + fact, + updatedContent: decision.updatedContent, + updatedEmbedding, + sourceSite, + sourceUrl, + episodeId, + logicalTimestamp, + target, contradictionConfidence: decision.contradictionConfidence, }); - await deps.repo.updateMemoryMetadata(userId, target.memoryId, { cmo_id: cmoId }); + if (!lineage?.cmoId) { + throw new Error(`AUDN UPDATE failed: missing successor canonical object for "${target.memoryId}"`); + } + await deps.repo.updateMemoryMetadata(userId, target.memoryId, { cmo_id: lineage.cmoId }); return { outcome: 'updated', memoryId: target.memoryId }; } @@ -255,24 +259,23 @@ async function supersedeCanonicalFact( await deps.repo.expireMemory(userId, target.memoryId); const newMemoryId = await storeProjection(deps, userId, fact, embedding, sourceSite, sourceUrl, episodeId, trustScore ?? 1.0); if (!newMemoryId) return { outcome: 'skipped', memoryId: null }; - const mutationReason = `Superseded memory "${target.memoryId}" with new fact`; - const newVersionId = await deps.claims.createClaimVersion({ - claimId: target.claimId, userId, memoryId: newMemoryId, content: fact.fact, embedding, - importance: fact.importance, sourceSite, sourceUrl, episodeId, - validFrom: logicalTimestamp, - provenance: { - mutationType: 'supersede', mutationReason, previousVersionId: target.versionId, - actorModel: config.llmModel, contradictionConfidence: contradictionConfidence ?? undefined, - }, - }); - await deps.claims.supersedeClaimVersion(userId, target.versionId, newVersionId, logicalTimestamp ?? new Date()); - await deps.claims.setClaimCurrentVersion(target.claimId, newVersionId, 'active', logicalTimestamp); - await deps.claims.addEvidence({ claimVersionId: newVersionId, episodeId, memoryId: newMemoryId, quoteText: fact.fact }); - const cmoId = await createMutationCanonicalObject(deps, userId, fact, sourceSite, sourceUrl, episodeId, logicalTimestamp, { - mutationType: 'supersede', previousObjectId: target.cmoId, claimId: target.claimId, - claimVersionId: newVersionId, previousVersionId: target.versionId, mutationReason, contradictionConfidence, + const lineage = await emitLineageEvent({ claims: deps.claims, repo: deps.repo }, { + kind: 'canonical-supersede', + userId, + fact, + embedding, + sourceSite, + sourceUrl, + episodeId, + logicalTimestamp, + target, + newMemoryId, + contradictionConfidence, }); - await deps.repo.updateMemoryMetadata(userId, newMemoryId, { cmo_id: cmoId }); + if (!lineage?.cmoId) { + throw new Error(`AUDN SUPERSEDE failed: missing successor canonical object for "${target.memoryId}"`); + } + await deps.repo.updateMemoryMetadata(userId, newMemoryId, { cmo_id: lineage.cmoId }); if (config.lessonsEnabled && deps.lessons && contradictionConfidence) { recordContradictionLesson(deps.lessons, { userId, content: fact.fact, sourceSite, @@ -298,21 +301,17 @@ async function deleteCanonicalFact( const targetMemory = await deps.repo.getMemoryIncludingDeleted(target.memoryId, userId); if (!targetMemory) return { outcome: 'skipped', memoryId: null }; await deps.repo.softDeleteMemory(userId, target.memoryId); - const mutationReason = `Deleted memory "${target.memoryId}" — fact: "${fact.fact.slice(0, 100)}"`; - const deleteVersionId = await deps.claims.createClaimVersion({ - claimId: target.claimId, userId, memoryId: undefined, content: `[DELETED] ${fact.fact}`, embedding: targetMemory.embedding, - importance: 0, sourceSite: '', sourceUrl: '', episodeId, - validFrom: logicalTimestamp, - provenance: { - mutationType: 'delete', mutationReason, previousVersionId: target.versionId, - actorModel: config.llmModel, contradictionConfidence: contradictionConfidence ?? undefined, - }, - }); - await deps.claims.supersedeClaimVersion(userId, target.versionId, deleteVersionId, logicalTimestamp ?? new Date()); - await deps.claims.invalidateClaim(userId, target.claimId, logicalTimestamp ?? new Date(), deleteVersionId); - await createMutationCanonicalObject(deps, userId, fact, sourceSite, sourceUrl, episodeId, logicalTimestamp, { - mutationType: 'delete', previousObjectId: target.cmoId, claimId: target.claimId, - claimVersionId: deleteVersionId, previousVersionId: target.versionId, mutationReason, contradictionConfidence, + await emitLineageEvent({ claims: deps.claims, repo: deps.repo }, { + kind: 'canonical-delete', + userId, + fact, + sourceSite, + sourceUrl, + episodeId, + logicalTimestamp, + target, + targetEmbedding: targetMemory.embedding, + contradictionConfidence, }); if (config.auditLoggingEnabled) { emitAuditEvent('memory:delete', userId, { @@ -389,4 +388,3 @@ function tryFastAUDN(factText: string, candidates: CandidateMemory[]): AUDNDecis return null; } - diff --git a/src/services/memory-lineage.ts b/src/services/memory-lineage.ts new file mode 100644 index 0000000..172e32c --- /dev/null +++ b/src/services/memory-lineage.ts @@ -0,0 +1,366 @@ +/** + * Internal claim-lineage emission seam for the existing lineage-producing write + * paths only. + * + * This module centralizes the current claim/version/evidence/canonical-object + * write sequences without changing their semantics. It deliberately models the + * current consolidation anomaly as its own variant: consolidation creates a + * claim/version pair but does not emit a mutation canonical memory object. + * + * Out of scope: + * - schema changes + * - new mutation types + * - workspace/scope behavior changes + * - routing lineage-bypassing paths through claim versions + */ + +import { config } from '../config.js'; +import type { ClaimSlotInput } from '../db/claim-repository.js'; +import type { ClaimTarget, FactInput } from './memory-service-types.js'; + +type MutationType = 'add' | 'update' | 'supersede' | 'delete'; +type MutationProvenanceType = MutationType | 'clarify'; + +type MutationCanonicalObjectRepo = { + storeCanonicalMemoryObject(input: { + userId: string; + objectFamily: 'ingested_fact'; + canonicalPayload: ReturnType; + provenance: { episodeId: string; sourceSite: string; sourceUrl: string }; + observedAt: Date | undefined; + lineage: { + mutationType: MutationType; + previousObjectId: string | null; + claimId?: string; + claimVersionId?: string; + previousVersionId?: string; + mutationReason?: string; + contradictionConfidence?: number | null; + actorModel?: string | null; + }; + }): Promise; +}; + +type LineageClaimsPort = { + createClaim(userId: string, claimType: string, validAt?: Date, claimSlot?: ClaimSlotInput | null): Promise; + createClaimVersion(input: { + claimId: string; + userId: string; + memoryId?: string; + content: string; + embedding: number[]; + importance: number; + sourceSite: string; + sourceUrl?: string; + episodeId?: string; + validFrom?: Date; + provenance?: { + mutationType?: MutationProvenanceType; + mutationReason?: string; + previousVersionId?: string; + actorModel?: string; + contradictionConfidence?: number; + }; + }): Promise; + setClaimCurrentVersion(claimId: string, versionId: string | null, status?: string, validAt?: Date): Promise; + addEvidence(input: { claimVersionId: string; episodeId?: string; memoryId?: string; quoteText?: string; speaker?: string }): Promise; + createUpdateVersion(input: { + oldVersionId: string; + claimId: string; + userId: string; + memoryId: string; + content: string; + embedding: number[]; + importance: number; + sourceSite: string; + sourceUrl?: string; + episodeId?: string; + validFrom?: Date; + mutationReason?: string; + actorModel?: string; + }): Promise; + supersedeClaimVersion(userId: string, versionId: string, supersededByVersionId: string | null, validTo?: Date): Promise; + invalidateClaim(userId: string, claimId: string, invalidAt?: Date, invalidatedByVersionId?: string | null, status?: string): Promise; +}; + +type LineageDeps = { claims: LineageClaimsPort; repo?: MutationCanonicalObjectRepo }; + +type BackfillMemory = { + id: string; + content: string; + embedding: number[]; + importance: number; + sourceSite: string; + sourceUrl: string; + episodeId?: string; + createdAt: Date; + memoryType: string; + cmoId: string | null; +}; + +type LineageEvent = + | { kind: 'canonical-add'; userId: string; fact: FactInput; embedding: number[]; sourceSite: string; sourceUrl: string; episodeId: string; logicalTimestamp: Date | undefined; claimSlot: ClaimSlotInput | null; createProjection: (cmoId: string) => Promise } + | { kind: 'claim-backfill'; userId: string; memory: BackfillMemory } + | { kind: 'consolidation-add'; userId: string; memoryId: string; content: string; embedding: number[]; importance: number; sourceSite: string; mutationReason: string } + | { kind: 'canonical-update'; userId: string; fact: FactInput; updatedContent: string; updatedEmbedding: number[]; sourceSite: string; sourceUrl: string; episodeId: string; logicalTimestamp: Date | undefined; target: ClaimTarget; contradictionConfidence?: number | null } + | { kind: 'canonical-supersede'; userId: string; fact: FactInput; embedding: number[]; sourceSite: string; sourceUrl: string; episodeId: string; logicalTimestamp: Date | undefined; target: ClaimTarget; newMemoryId: string; contradictionConfidence?: number | null } + | { kind: 'canonical-delete'; userId: string; fact: FactInput; sourceSite: string; sourceUrl: string; episodeId: string; logicalTimestamp: Date | undefined; target: ClaimTarget; targetEmbedding: number[]; contradictionConfidence?: number | null }; + +export type LineageEmission = { claimId: string; versionId: string; memoryId: string | null; cmoId: string | null }; + +export async function emitLineageEvent( + deps: LineageDeps, + event: LineageEvent, +): Promise { + switch (event.kind) { + case 'canonical-add': + return emitCanonicalAdd(deps, event); + case 'claim-backfill': + return emitBackfill(deps, event); + case 'consolidation-add': + return emitConsolidationAdd(deps, event); + case 'canonical-update': + return emitCanonicalUpdate(deps, event); + case 'canonical-supersede': + return emitCanonicalSupersede(deps, event); + case 'canonical-delete': + return emitCanonicalDelete(deps, event); + } +} + +function buildCanonicalPayload(fact: FactInput) { + return { + factText: fact.fact, + factType: fact.type, + headline: fact.headline, + keywords: fact.keywords, + }; +} + +async function emitCanonicalAdd( + deps: LineageDeps, + event: Extract, +): Promise { + const cmoId = await requireRepo(deps).storeCanonicalMemoryObject({ + userId: event.userId, + objectFamily: 'ingested_fact', + canonicalPayload: buildCanonicalPayload(event.fact), + provenance: { episodeId: event.episodeId, sourceSite: event.sourceSite, sourceUrl: event.sourceUrl }, + observedAt: event.logicalTimestamp, + lineage: { mutationType: 'add', previousObjectId: null }, + }); + const memoryId = await event.createProjection(cmoId); + if (!memoryId) return null; + + const claimId = await deps.claims.createClaim( + event.userId, + event.fact.type, + event.logicalTimestamp, + event.claimSlot, + ); + const versionId = await deps.claims.createClaimVersion({ + claimId, + userId: event.userId, + memoryId, + content: event.fact.fact, + embedding: event.embedding, + importance: event.fact.importance, + sourceSite: event.sourceSite, + sourceUrl: event.sourceUrl, + episodeId: event.episodeId, + validFrom: event.logicalTimestamp, + provenance: { mutationType: 'add', actorModel: config.llmModel }, + }); + await deps.claims.setClaimCurrentVersion(claimId, versionId, 'active', event.logicalTimestamp); + await deps.claims.addEvidence({ claimVersionId: versionId, episodeId: event.episodeId, memoryId, quoteText: event.fact.fact }); + return { claimId, versionId, memoryId, cmoId }; +} + +async function emitBackfill( + deps: LineageDeps, + event: Extract, +): Promise { + const claimId = await deps.claims.createClaim(event.userId, event.memory.memoryType, event.memory.createdAt); + const versionId = await deps.claims.createClaimVersion({ + claimId, + userId: event.userId, + memoryId: event.memory.id, + content: event.memory.content, + embedding: event.memory.embedding, + importance: event.memory.importance, + sourceSite: event.memory.sourceSite, + sourceUrl: event.memory.sourceUrl, + episodeId: event.memory.episodeId, + validFrom: event.memory.createdAt, + }); + await deps.claims.setClaimCurrentVersion(claimId, versionId, 'active', event.memory.createdAt); + await deps.claims.addEvidence({ + claimVersionId: versionId, + episodeId: event.memory.episodeId, + memoryId: event.memory.id, + quoteText: event.memory.content, + }); + return { claimId, versionId, memoryId: event.memory.id, cmoId: event.memory.cmoId }; +} + +async function emitConsolidationAdd( + deps: LineageDeps, + event: Extract, +): Promise { + const claimId = await deps.claims.createClaim(event.userId, 'consolidated'); + const versionId = await deps.claims.createClaimVersion({ + claimId, + userId: event.userId, + memoryId: event.memoryId, + content: event.content, + embedding: event.embedding, + importance: event.importance, + sourceSite: event.sourceSite, + provenance: { + mutationType: 'add', + mutationReason: event.mutationReason, + actorModel: config.llmModel, + }, + }); + await deps.claims.setClaimCurrentVersion(claimId, versionId); + return { claimId, versionId, memoryId: event.memoryId, cmoId: null }; +} + +async function emitCanonicalUpdate( + deps: LineageDeps, + event: Extract, +): Promise { + const mutationReason = `Updated from: "${event.fact.fact.slice(0, 100)}"`; + const versionId = await deps.claims.createUpdateVersion({ + oldVersionId: event.target.versionId, + claimId: event.target.claimId, + userId: event.userId, + memoryId: event.target.memoryId, + content: event.updatedContent, + embedding: event.updatedEmbedding, + importance: event.fact.importance, + sourceSite: event.sourceSite, + sourceUrl: event.sourceUrl, + episodeId: event.episodeId, + validFrom: event.logicalTimestamp, + mutationReason, + actorModel: config.llmModel, + }); + await deps.claims.addEvidence({ + claimVersionId: versionId, + episodeId: event.episodeId, + memoryId: event.target.memoryId, + quoteText: event.fact.fact, + }); + const cmoId = await createMutationCanonicalObject(deps, event, versionId, mutationReason, { + ...event.fact, + fact: event.updatedContent, + }); + return { claimId: event.target.claimId, versionId, memoryId: event.target.memoryId, cmoId }; +} + +async function emitCanonicalSupersede( + deps: LineageDeps, + event: Extract, +): Promise { + const mutationReason = `Superseded memory "${event.target.memoryId}" with new fact`; + const versionId = await deps.claims.createClaimVersion({ + claimId: event.target.claimId, + userId: event.userId, + memoryId: event.newMemoryId, + content: event.fact.fact, + embedding: event.embedding, + importance: event.fact.importance, + sourceSite: event.sourceSite, + sourceUrl: event.sourceUrl, + episodeId: event.episodeId, + validFrom: event.logicalTimestamp, + provenance: { + mutationType: 'supersede', + mutationReason, + previousVersionId: event.target.versionId, + actorModel: config.llmModel, + contradictionConfidence: event.contradictionConfidence ?? undefined, + }, + }); + await deps.claims.supersedeClaimVersion(event.userId, event.target.versionId, versionId, event.logicalTimestamp ?? new Date()); + await deps.claims.setClaimCurrentVersion(event.target.claimId, versionId, 'active', event.logicalTimestamp); + await deps.claims.addEvidence({ + claimVersionId: versionId, + episodeId: event.episodeId, + memoryId: event.newMemoryId, + quoteText: event.fact.fact, + }); + const cmoId = await createMutationCanonicalObject(deps, event, versionId, mutationReason, event.fact); + return { claimId: event.target.claimId, versionId, memoryId: event.newMemoryId, cmoId }; +} + +async function emitCanonicalDelete( + deps: LineageDeps, + event: Extract, +): Promise { + const mutationReason = `Deleted memory "${event.target.memoryId}" — fact: "${event.fact.fact.slice(0, 100)}"`; + const versionId = await deps.claims.createClaimVersion({ + claimId: event.target.claimId, + userId: event.userId, + content: `[DELETED] ${event.fact.fact}`, + embedding: event.targetEmbedding, + importance: 0, + sourceSite: '', + sourceUrl: '', + episodeId: event.episodeId, + validFrom: event.logicalTimestamp, + provenance: { + mutationType: 'delete', + mutationReason, + previousVersionId: event.target.versionId, + actorModel: config.llmModel, + contradictionConfidence: event.contradictionConfidence ?? undefined, + }, + }); + await deps.claims.supersedeClaimVersion(event.userId, event.target.versionId, versionId, event.logicalTimestamp ?? new Date()); + await deps.claims.invalidateClaim(event.userId, event.target.claimId, event.logicalTimestamp ?? new Date(), versionId); + const cmoId = await createMutationCanonicalObject(deps, event, versionId, mutationReason, event.fact); + return { claimId: event.target.claimId, versionId, memoryId: null, cmoId }; +} + +async function createMutationCanonicalObject( + deps: LineageDeps, + event: Extract, + claimVersionId: string, + mutationReason: string, + fact: FactInput, +): Promise { + return requireRepo(deps).storeCanonicalMemoryObject({ + userId: event.userId, + objectFamily: 'ingested_fact', + canonicalPayload: buildCanonicalPayload(fact), + provenance: { episodeId: event.episodeId, sourceSite: event.sourceSite, sourceUrl: event.sourceUrl }, + observedAt: event.logicalTimestamp, + lineage: { + mutationType: mutationTypeFor(event.kind), + previousObjectId: event.target.cmoId, + claimId: event.target.claimId, + claimVersionId, + previousVersionId: event.target.versionId, + mutationReason, + contradictionConfidence: event.contradictionConfidence ?? undefined, + actorModel: config.llmModel, + }, + }); +} + +function mutationTypeFor( + kind: 'canonical-update' | 'canonical-supersede' | 'canonical-delete', +): 'update' | 'supersede' | 'delete' { + if (kind === 'canonical-update') return 'update'; + if (kind === 'canonical-supersede') return 'supersede'; + return 'delete'; +} + +function requireRepo(deps: LineageDeps): MutationCanonicalObjectRepo { + if (!deps.repo) { + throw new Error('Lineage event requires canonical object repository access'); + } + return deps.repo; +} diff --git a/src/services/memory-storage.ts b/src/services/memory-storage.ts index 44d0a23..5dc4f93 100644 --- a/src/services/memory-storage.ts +++ b/src/services/memory-storage.ts @@ -15,6 +15,7 @@ import { inferNamespace, classifyNamespace } from './namespace-retrieval.js'; import { generateL1Overview } from './tiered-context.js'; import { emitAuditEvent } from './audit-events.js'; import { derivePersistedClaimSlot } from './memory-crud.js'; +import { emitLineageEvent } from './memory-lineage.js'; import type { AudnFactContext, ClaimTarget, @@ -29,31 +30,27 @@ export async function storeCanonicalFact( ctx: AudnFactContext, ): Promise<{ outcome: Outcome; memoryId: string | null }> { const { userId, fact, embedding, sourceSite, sourceUrl, episodeId, trustScore, claimSlot, logicalTimestamp } = ctx; - const cmoId = await deps.repo.storeCanonicalMemoryObject({ + const lineage = await emitLineageEvent({ claims: deps.claims, repo: deps.repo }, { + kind: 'canonical-add', userId, - objectFamily: 'ingested_fact', - canonicalPayload: buildCanonicalPayload(fact), - provenance: { episodeId, sourceSite, sourceUrl }, - observedAt: logicalTimestamp, - lineage: { mutationType: 'add', previousObjectId: null }, + fact, + embedding, + sourceSite, + sourceUrl, + episodeId, + logicalTimestamp, + claimSlot: claimSlot ?? null, + createProjection: async (cmoId) => + storeProjection(deps, userId, fact, embedding, sourceSite, sourceUrl, episodeId, trustScore, cmoId), }); - const memoryId = await storeProjection(deps, userId, fact, embedding, sourceSite, sourceUrl, episodeId, trustScore, cmoId); - if (!memoryId) return { outcome: 'skipped', memoryId: null }; - const claimId = await deps.claims.createClaim(userId, fact.type, logicalTimestamp, claimSlot); - const versionId = await deps.claims.createClaimVersion({ - claimId, userId, memoryId, content: fact.fact, embedding, - importance: fact.importance, sourceSite, sourceUrl, episodeId, - validFrom: logicalTimestamp, - provenance: { mutationType: 'add', actorModel: config.llmModel }, - }); - await deps.claims.setClaimCurrentVersion(claimId, versionId, 'active', logicalTimestamp); - await deps.claims.addEvidence({ claimVersionId: versionId, episodeId, memoryId, quoteText: fact.fact }); + if (!lineage?.memoryId) return { outcome: 'skipped', memoryId: null }; + const memoryId = lineage.memoryId; if (config.entityGraphEnabled && deps.entities) { await resolveAndLinkEntities(deps, userId, memoryId, fact.entities, fact.relations, embedding); if (!claimSlot) { const persistedSlot = await derivePersistedClaimSlot(deps, userId, memoryId); if (persistedSlot) { - await deps.claims.updateClaimSlot(userId, claimId, persistedSlot); + await deps.claims.updateClaimSlot(userId, lineage.claimId, persistedSlot); } } } @@ -66,44 +63,6 @@ export async function storeCanonicalFact( return { outcome: 'stored', memoryId }; } -function buildCanonicalPayload(fact: FactInput) { - return { - factText: fact.fact, - factType: fact.type, - headline: fact.headline, - keywords: fact.keywords, - }; -} - -export async function createMutationCanonicalObject( - deps: MemoryServiceDeps, - userId: string, - fact: FactInput, - sourceSite: string, - sourceUrl: string, - episodeId: string, - logicalTimestamp: Date | undefined, - lineage: { - mutationType: 'update' | 'supersede' | 'delete'; - previousObjectId: string | null; - claimId: string; - claimVersionId: string; - previousVersionId: string; - mutationReason: string; - contradictionConfidence?: number | null; - actorModel?: string | null; - }, -): Promise { - return deps.repo.storeCanonicalMemoryObject({ - userId, - objectFamily: 'ingested_fact', - canonicalPayload: buildCanonicalPayload(fact), - provenance: { episodeId, sourceSite, sourceUrl }, - observedAt: logicalTimestamp, - lineage: { ...lineage, actorModel: config.llmModel }, - }); -} - export async function storeProjection( deps: MemoryServiceDeps, userId: string, @@ -302,15 +261,24 @@ export async function ensureClaimTarget(deps: MemoryServiceDeps, userId: string, const version = await deps.claims.getClaimVersionByMemoryId(userId, memoryId); if (version) return { claimId: version.claim_id, versionId: version.id, memoryId, cmoId }; - const claimId = await deps.claims.createClaim(userId, memory.memory_type, memory.created_at); - const versionId = await deps.claims.createClaimVersion({ - claimId, userId, memoryId: memory.id, content: memory.content, embedding: memory.embedding, - importance: memory.importance, sourceSite: memory.source_site, sourceUrl: memory.source_url, - episodeId: memory.episode_id ?? undefined, validFrom: memory.created_at, + const lineage = await emitLineageEvent({ claims: deps.claims }, { + kind: 'claim-backfill', + userId, + memory: { + id: memory.id, + content: memory.content, + embedding: memory.embedding, + importance: memory.importance, + sourceSite: memory.source_site, + sourceUrl: memory.source_url, + episodeId: memory.episode_id ?? undefined, + createdAt: memory.created_at, + memoryType: memory.memory_type, + cmoId, + }, }); - await deps.claims.setClaimCurrentVersion(claimId, versionId, 'active', memory.created_at); - await deps.claims.addEvidence({ claimVersionId: versionId, episodeId: memory.episode_id ?? undefined, memoryId: memory.id, quoteText: memory.content }); - return { claimId, versionId, memoryId: memory.id, cmoId }; + if (!lineage) throw new Error(`Claim backfill unexpectedly skipped for memory: ${memory.id}`); + return { claimId: lineage.claimId, versionId: lineage.versionId, memoryId: memory.id, cmoId }; } export async function findConflictCandidates(deps: MemoryServiceDeps, userId: string, factText: string, embedding: number[]): Promise { From 8809a39543f8e46e17f0dbe5f38b2b1134684417 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 16:02:56 -0700 Subject: [PATCH 21/59] refactor(search): report hybrid flag via runtime policyConfig in trace Swap the hybridSearchEnabled read in the 'initial' trace.stage metadata from the module-level config singleton to the already-derived policyConfig, which falls back to the singleton when options.runtimeConfig is absent. This is trace-reporting only; retrieval behavior is unaffected and the behavioral hybrid reads in runInitialRetrieval / maybeApplyAbstractHybridFallback / applyRepairLoop remain on the static singleton. --- src/services/search-pipeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 44be9af..85d5553 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -111,7 +111,7 @@ export async function runSearchPipelineWithTrace( trace.stage('initial', seededResults, { candidateDepth, - hybrid: config.hybridSearchEnabled, + hybrid: policyConfig.hybridSearchEnabled, augmentation: { searchQuery, matched: searchQuery !== query, From 8c37530e10f4c19de92696d310a55ebc6abbb833 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 16:03:14 -0700 Subject: [PATCH 22/59] test(lineage): lock consolidation no-cmo seam behavior Add a narrow integration assertion that consolidation still emits claim lineage without creating a mutation canonical memory object or attaching `cmo_id` to the consolidated projection. --- .../__tests__/consolidation-execution.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/db/__tests__/consolidation-execution.test.ts b/src/db/__tests__/consolidation-execution.test.ts index e924dcd..db0fe7a 100644 --- a/src/db/__tests__/consolidation-execution.test.ts +++ b/src/db/__tests__/consolidation-execution.test.ts @@ -119,6 +119,32 @@ describe('consolidation execution', () => { expect(version!.actor_model).toBe(config.llmModel); }); + it('preserves the current no-CMO consolidation behavior', async () => { + await seedRelatedMemories(repo); + + const result = await executeConsolidation(repo, claimRepo, TEST_USER, { + affinity: { threshold: 0.5, minClusterSize: 3, beta: 1.0, temporalLambda: 0 }, + }); + + const consolidatedId = result.consolidatedMemoryIds[0]; + const consolidatedMemory = await repo.getMemory(consolidatedId, TEST_USER); + const cmoRows = await pool.query( + `SELECT id + FROM canonical_memory_objects + WHERE user_id = $1 + AND lineage->>'claimVersionId' = ( + SELECT id::text + FROM memory_claim_versions + WHERE user_id = $1 AND memory_id = $2 + )`, + [TEST_USER, consolidatedId], + ); + + expect(consolidatedMemory).not.toBeNull(); + expect(consolidatedMemory!.metadata.cmo_id).toBeUndefined(); + expect(cmoRows.rows).toHaveLength(0); + }); + it('consolidated memory has metadata with source member IDs', async () => { const mem = await seedAndConsolidateFirst(repo, claimRepo); expect(mem!.metadata.consolidated_from).toBeDefined(); From ce36f2bdff115d3f9379f0fe6394af5f14d3f4ef Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 16:17:16 -0700 Subject: [PATCH 23/59] refactor(search): thread runtime policyConfig into runInitialRetrieval Add an optional policyConfig parameter (defaulting to the module-level config singleton) to runInitialRetrieval, and pass the already-derived policyConfig from runSearchPipelineWithTrace. The behavioral hybridSearchEnabled read inside runInitialRetrieval now reads from policyConfig instead of the module singleton. Behavior is preserved: when callers don't pass policyConfig, the default argument falls through to the same static config that was being read directly before. The lone call site in runSearchPipelineWithTrace now passes the runtime-owned policyConfig explicitly. Unchanged by this slice: - maybeApplyAbstractHybridFallback still reads config.hybridSearchEnabled and config.entityGraphEnabled directly - applyRepairLoop still reads config.hybridSearchEnabled at its inner runMemoryRrfRetrieval call - All query-expansion / entity-graph / link-expansion / reranker / cross-encoder / PPR / MMR-reranker reads remain on the static config --- src/services/search-pipeline.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 85d5553..257a5f2 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -100,7 +100,7 @@ export async function runSearchPipelineWithTrace( const searchQuery = augmentation.searchQuery; const initialResults = await timed('search.vector', () => runInitialRetrieval( - repo, entityRepo, userId, searchQuery, queryEmbedding, candidateDepth, sourceSite, referenceTime, options.searchStrategy, + repo, entityRepo, userId, searchQuery, queryEmbedding, candidateDepth, sourceSite, referenceTime, options.searchStrategy, policyConfig, )); const seededResults = await timed('search.hybrid-fallback', () => maybeApplyAbstractHybridFallback( repo, entityRepo, userId, query, searchQuery, queryEmbedding, candidateDepth, sourceSite, referenceTime, @@ -236,6 +236,7 @@ async function runInitialRetrieval( sourceSite?: string, referenceTime?: Date, searchStrategy: SearchStrategy = 'memory', + policyConfig: CoreRuntimeConfig = config, ): Promise { if (searchStrategy === 'fact-hybrid') { return repo.searchAtomicFactsHybrid( @@ -256,7 +257,7 @@ async function runInitialRetrieval( candidateDepth, sourceSite, referenceTime, - config.hybridSearchEnabled, + policyConfig.hybridSearchEnabled, ); } From 2d11a2d3189a8ccdadcfb2a10b01cb4f994dd478 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 16:17:41 -0700 Subject: [PATCH 24/59] test(lineage): lock audn delete tombstone invariants Add a focused AUDN delete integration test that asserts the preserved delete-tombstone claim-version row fields directly, including null memory_id, deleted-prefix content, zero importance, blank source fields, prior-version linkage, and reused embedding. --- .../canonical-memory-lineage.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/services/__tests__/canonical-memory-lineage.test.ts b/src/services/__tests__/canonical-memory-lineage.test.ts index a1c006a..a8dbf88 100644 --- a/src/services/__tests__/canonical-memory-lineage.test.ts +++ b/src/services/__tests__/canonical-memory-lineage.test.ts @@ -149,6 +149,43 @@ describe('canonical memory lineage', () => { expect(deleteCmoRow.rows[0].lineage.claimVersionId).toBe(claim!.invalidated_by_version_id); }); + it('preserves delete tombstone claim-version invariants', async () => { + const originalConversation = 'original-employer'; + const deleteConversation = 'delete-employer'; + const originalFact = 'User works at OpenAI.'; + const deleteFact = 'User no longer works at OpenAI.'; + const originalAt = new Date('2026-01-02T00:00:00.000Z'); + const deleteAt = new Date('2026-04-02T00:00:00.000Z'); + const baseEmbedding = unitVector(31); + + registerConversation(originalConversation, originalFact, baseEmbedding, 'Works at OpenAI'); + registerConversation(deleteConversation, deleteFact, offsetVector(baseEmbedding, 13, 0.01), 'No longer works at OpenAI'); + + const { memory: originalMemory, version: originalVersion } = await ingestAndCapture(originalConversation, originalAt); + + decisionPlans.set(deleteFact, { + action: 'DELETE', + targetMemoryId: originalMemory!.id, + updatedContent: null, + contradictionConfidence: 0.94, + clarificationNote: null, + }); + + await ctx.service.ingest(TEST_USER, deleteConversation, 'test', 'https://source/delete-employer', deleteAt); + + const claim = await ctx.claimRepo.getClaim(originalVersion!.claim_id, TEST_USER); + const tombstoneVersion = await ctx.claimRepo.getClaimVersion(claim!.invalidated_by_version_id!, TEST_USER); + + expect(tombstoneVersion).not.toBeNull(); + expect(tombstoneVersion!.memory_id).toBeNull(); + expect(tombstoneVersion!.content).toBe(`[DELETED] ${deleteFact}`); + expect(tombstoneVersion!.importance).toBe(0); + expect(tombstoneVersion!.source_site).toBe(''); + expect(tombstoneVersion!.source_url).toBe(''); + expect(tombstoneVersion!.previous_version_id).toBe(originalVersion!.id); + expect(tombstoneVersion!.embedding).toEqual(originalVersion!.embedding); + }); + it('backfills lineage for a legacy projection without emitting a mutation CMO', async () => { const memoryId = await ctx.repo.storeMemory({ userId: TEST_USER, From ed7af66dd06396cc745bd4ba976e822aa49513a2 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 16:44:09 -0700 Subject: [PATCH 25/59] refactor(search): thread runtime policyConfig into abstract-hybrid fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional policyConfig parameter (defaulting to the module-level config singleton) to maybeApplyAbstractHybridFallback, and pass the already-derived policyConfig from runSearchPipelineWithTrace. The two short-circuit reads inside the helper — hybridSearchEnabled and entityGraphEnabled — now read from policyConfig instead of the module singleton. Behavior is preserved: when callers don't pass policyConfig, the default argument falls through to the same static config that was being read directly before. Symmetric with ce36f2b, which threaded policyConfig into runInitialRetrieval. Unchanged by this slice: - applyRepairLoop still reads config.hybridSearchEnabled at its inner runMemoryRrfRetrieval call - All query-expansion / entity-graph co-retrieval / link-expansion / reranker / cross-encoder / PPR / MMR-reranker reads remain on the static config --- src/services/search-pipeline.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 257a5f2..eb81411 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -104,7 +104,7 @@ export async function runSearchPipelineWithTrace( )); const seededResults = await timed('search.hybrid-fallback', () => maybeApplyAbstractHybridFallback( repo, entityRepo, userId, query, searchQuery, queryEmbedding, candidateDepth, sourceSite, referenceTime, - options.retrievalMode, options.searchStrategy, initialResults, trace, + options.retrievalMode, options.searchStrategy, initialResults, trace, policyConfig, )); console.log(`[search] Query: "${query}", Results: ${seededResults.length}`); @@ -275,9 +275,10 @@ async function maybeApplyAbstractHybridFallback( searchStrategy: SearchStrategy | undefined, initialResults: SearchResult[], trace: TraceCollector, + policyConfig: CoreRuntimeConfig = config, ): Promise { if (searchStrategy === 'fact-hybrid') return initialResults; - if (config.hybridSearchEnabled || config.entityGraphEnabled) return initialResults; + if (policyConfig.hybridSearchEnabled || policyConfig.entityGraphEnabled) return initialResults; if (!shouldUseAbstractHybridFallback(retrievalMode, rawQuery, initialResults.length)) { return initialResults; } From b9df8468490de5d0428a102d2c7345d8f153cf76 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 16:51:31 -0700 Subject: [PATCH 26/59] refactor(search): thread runtime policyConfig into entity-name co-retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional policyConfig parameter (defaulting to the module-level config singleton) to applyEntityNameCoRetrieval, and pass the already-derived policyConfig from runSearchPipelineWithTrace. The two config reads inside the helper — entityGraphEnabled (gating) and linkExpansionMax (budget) — now read from policyConfig instead of the module singleton. Behavior is preserved: when callers don't pass policyConfig, the default argument falls through to the same static config that was being read directly before. Symmetric with ce36f2b (runInitialRetrieval) and ed7af66 (maybeApplyAbstractHybridFallback). Unchanged by this slice: - applyRepairLoop inner config reads (hybridSearchEnabled, repair profile weights) remain on the static singleton - applyQueryExpansion, applyQueryAugmentation, applyTemporalQueryExpansion, applyLiteralQueryExpansion, applySubjectQueryExpansion still read static config - All reranker, cross-encoder, link-expansion, PPR, MMR-reranker, and generateLinks reads remain on the static singleton --- src/services/search-pipeline.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index eb81411..291242b 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -120,7 +120,7 @@ export async function runSearchPipelineWithTrace( // Entity name co-retrieval const withCoRetrieval = await timed('search.co-retrieval', () => applyEntityNameCoRetrieval( - repo, entityRepo, userId, query, queryEmbedding, seededResults, candidateDepth, trace, + repo, entityRepo, userId, query, queryEmbedding, seededResults, candidateDepth, trace, policyConfig, )); const withSubjectExpansion = await timed('search.subject-query-expansion', () => applySubjectQueryExpansion( @@ -467,12 +467,13 @@ async function applyEntityNameCoRetrieval( initialResults: SearchResult[], candidateDepth: number, trace: TraceCollector, + policyConfig: CoreRuntimeConfig = config, ): Promise { - if (!config.entityGraphEnabled || !entityRepo) return initialResults; + if (!policyConfig.entityGraphEnabled || !entityRepo) return initialResults; const excludeIds = new Set(initialResults.map((r) => r.id)); const { memories, matchedNames } = await coRetrieveByEntityNames( - entityRepo, repo, userId, query, queryEmbedding, excludeIds, config.linkExpansionMax, + entityRepo, repo, userId, query, queryEmbedding, excludeIds, policyConfig.linkExpansionMax, ); if (memories.length === 0) { From 788e5fce9343fe2b6e6cdc7cfb10943edac4cc16 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 16:51:44 -0700 Subject: [PATCH 27/59] test(lineage): lock backfill provenance-null invariants Add a focused lineage test that asserts ensureClaimTarget backfill creates a claim-version row with the preserved null provenance fields, without changing the existing tombstone-first behavior or any production logic. --- .../canonical-memory-lineage.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/services/__tests__/canonical-memory-lineage.test.ts b/src/services/__tests__/canonical-memory-lineage.test.ts index a8dbf88..11f54c7 100644 --- a/src/services/__tests__/canonical-memory-lineage.test.ts +++ b/src/services/__tests__/canonical-memory-lineage.test.ts @@ -209,6 +209,28 @@ describe('canonical memory lineage', () => { expect(cmoRows.rows).toHaveLength(0); }); + it('leaves backfilled claim-version provenance fields null', async () => { + const memoryId = await ctx.repo.storeMemory({ + userId: TEST_USER, + content: 'Legacy fact with no prior claim version.', + embedding: unitVector(37), + memoryType: 'semantic', + importance: 0.55, + sourceSite: 'test', + }); + const { ensureClaimTarget } = await import('../memory-storage.js'); + + const target = await ensureClaimTarget({ repo: ctx.repo, claims: ctx.claimRepo } as any, TEST_USER, memoryId); + const version = await ctx.claimRepo.getClaimVersion(target.versionId, TEST_USER); + + expect(version).not.toBeNull(); + expect(version!.mutation_type).toBeNull(); + expect(version!.mutation_reason).toBeNull(); + expect(version!.previous_version_id).toBeNull(); + expect(version!.actor_model).toBeNull(); + expect(version!.contradiction_confidence).toBeNull(); + }); + /** Ingest a conversation and return its first memory, version, and raw result. */ async function ingestAndCapture(conversation: string, timestamp: Date, sourceUrl = 'https://source/original') { const result = await ctx.service.ingest(TEST_USER, conversation, 'test', sourceUrl, timestamp); From 22df9298e969011a78ea57a938f39bb07f822528 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 17:12:25 -0700 Subject: [PATCH 28/59] refactor(search): thread runtime policyConfig into query augmentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional policyConfig parameter (defaulting to the module-level config singleton) to applyQueryAugmentation, and pass the already-derived policyConfig from runSearchPipelineWithTrace. The two gate reads inside the helper — queryAugmentationEnabled and entityGraphEnabled — now read from policyConfig instead of the module singleton. Behavior is preserved: when callers don't pass policyConfig, the default argument falls through to the same static config that was being read directly before. Symmetric with ce36f2b (runInitialRetrieval), ed7af66 (maybeApplyAbstractHybridFallback), and b9df846 (applyEntityNameCoRetrieval). Unchanged by this slice: - applyRepairLoop inner config reads (hybridSearchEnabled, repair profile weights) remain on the static singleton — deliberately out of scope - applyQueryExpansion, applyTemporalQueryExpansion, applyLiteralQueryExpansion, applySubjectQueryExpansion still read static config where applicable - applyExpansionAndReranking, runMemoryRrfRetrieval, expandViaPPR, expandViaEntities, expandWithLinks, generateLinks still read static config --- src/services/search-pipeline.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 291242b..f7a7d3a 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -94,7 +94,7 @@ export async function runSearchPipelineWithTrace( // Phase 2: Entity-grounded query augmentation (zero-LLM) const augmentation = await timed('search.augmentation', () => applyQueryAugmentation( - entityRepo, userId, query, rawQueryEmbedding, trace, + entityRepo, userId, query, rawQueryEmbedding, trace, policyConfig, )); const queryEmbedding = augmentation.augmentedEmbedding; const searchQuery = augmentation.searchQuery; @@ -426,8 +426,9 @@ async function applyQueryAugmentation( query: string, queryEmbedding: number[], trace: TraceCollector, + policyConfig: CoreRuntimeConfig = config, ): Promise<{ searchQuery: string; augmentedEmbedding: number[] }> { - if (!config.queryAugmentationEnabled || !config.entityGraphEnabled || !entityRepo) { + if (!policyConfig.queryAugmentationEnabled || !policyConfig.entityGraphEnabled || !entityRepo) { return { searchQuery: query, augmentedEmbedding: queryEmbedding }; } From ea7bac439a1f5221a18a97315405c0a4303c1f2c Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 17:14:14 -0700 Subject: [PATCH 29/59] test(lineage): extract legacy backfill helper Keep the lineage seam tests focused by collapsing the repeated legacy backfill setup into one helper. This is test-only and preserves the existing tombstone/backfill assertions. --- .../canonical-memory-lineage.test.ts | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/services/__tests__/canonical-memory-lineage.test.ts b/src/services/__tests__/canonical-memory-lineage.test.ts index 11f54c7..2bd1c9b 100644 --- a/src/services/__tests__/canonical-memory-lineage.test.ts +++ b/src/services/__tests__/canonical-memory-lineage.test.ts @@ -187,17 +187,11 @@ describe('canonical memory lineage', () => { }); it('backfills lineage for a legacy projection without emitting a mutation CMO', async () => { - const memoryId = await ctx.repo.storeMemory({ - userId: TEST_USER, - content: 'Legacy memory without claim lineage.', - embedding: unitVector(29), - memoryType: 'semantic', - importance: 0.6, - sourceSite: 'test', - }); - const { ensureClaimTarget } = await import('../memory-storage.js'); - - const target = await ensureClaimTarget({ repo: ctx.repo, claims: ctx.claimRepo } as any, TEST_USER, memoryId); + const { memoryId, target } = await backfillLegacyProjection( + 'Legacy memory without claim lineage.', + unitVector(29), + 0.6, + ); const claim = await ctx.claimRepo.getClaim(target.claimId, TEST_USER); const version = await ctx.claimRepo.getClaimVersionByMemoryId(TEST_USER, memoryId); const cmoRows = await pool.query('SELECT id FROM canonical_memory_objects WHERE user_id = $1', [TEST_USER]); @@ -210,17 +204,11 @@ describe('canonical memory lineage', () => { }); it('leaves backfilled claim-version provenance fields null', async () => { - const memoryId = await ctx.repo.storeMemory({ - userId: TEST_USER, - content: 'Legacy fact with no prior claim version.', - embedding: unitVector(37), - memoryType: 'semantic', - importance: 0.55, - sourceSite: 'test', - }); - const { ensureClaimTarget } = await import('../memory-storage.js'); - - const target = await ensureClaimTarget({ repo: ctx.repo, claims: ctx.claimRepo } as any, TEST_USER, memoryId); + const { target } = await backfillLegacyProjection( + 'Legacy fact with no prior claim version.', + unitVector(37), + 0.55, + ); const version = await ctx.claimRepo.getClaimVersion(target.versionId, TEST_USER); expect(version).not.toBeNull(); @@ -239,6 +227,21 @@ describe('canonical memory lineage', () => { return { result, memory, version }; } + /** Create a legacy projection and force the claim-version backfill seam to run. */ + async function backfillLegacyProjection(content: string, embedding: number[], importance: number) { + const memoryId = await ctx.repo.storeMemory({ + userId: TEST_USER, + content, + embedding, + memoryType: 'semantic', + importance, + sourceSite: 'test', + }); + const { ensureClaimTarget } = await import('../memory-storage.js'); + const target = await ensureClaimTarget({ repo: ctx.repo, claims: ctx.claimRepo } as any, TEST_USER, memoryId); + return { memoryId, target }; + } + /** Query a canonical_memory_objects row by id. */ async function queryCmoById(cmoId: string) { return pool.query( From c8595a087c4d9050fdcf987859f5f540f4ef0b00 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 17:28:03 -0700 Subject: [PATCH 30/59] Thread runtime search policy config through retrieval seam --- .../memory-search-runtime-config.test.ts | 105 +++++++++++++++ .../__tests__/query-augmentation.test.ts | 22 ++++ src/services/memory-search.ts | 9 +- src/services/query-expansion.ts | 23 +++- src/services/search-pipeline.ts | 122 +++++++++++++----- 5 files changed, 238 insertions(+), 43 deletions(-) create mode 100644 src/services/__tests__/memory-search-runtime-config.test.ts diff --git a/src/services/__tests__/memory-search-runtime-config.test.ts b/src/services/__tests__/memory-search-runtime-config.test.ts new file mode 100644 index 0000000..c8da8bd --- /dev/null +++ b/src/services/__tests__/memory-search-runtime-config.test.ts @@ -0,0 +1,105 @@ +/** + * Runtime config seam tests for memory-search. + * + * Verifies that performSearch threads deps.config into the search pipeline + * and uses that same runtime-owned config to gate request-time lessons, + * consensus validation, and audit side effects. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSearchResult } from './test-fixtures.js'; + +const { + mockCheckLessons, + mockValidateConsensus, + mockEmitAuditEvent, + mockRunSearchPipelineWithTrace, +} = vi.hoisted(() => ({ + mockCheckLessons: vi.fn(), + mockValidateConsensus: vi.fn(), + mockEmitAuditEvent: vi.fn(), + mockRunSearchPipelineWithTrace: vi.fn(), +})); + +vi.mock('../lesson-service.js', () => ({ + checkLessons: mockCheckLessons, + recordContradictionLesson: vi.fn(), +})); +vi.mock('../consensus-validation.js', () => ({ validateConsensus: mockValidateConsensus })); +vi.mock('../audit-events.js', () => ({ emitAuditEvent: mockEmitAuditEvent })); +vi.mock('../retrieval-policy.js', () => ({ + resolveSearchLimitDetailed: vi.fn(() => ({ + limit: 5, + classification: { label: 'simple', matchedMarker: null }, + })), + classifyQueryDetailed: vi.fn(() => ({ label: 'simple' })), +})); +vi.mock('../search-pipeline.js', () => ({ + runSearchPipelineWithTrace: mockRunSearchPipelineWithTrace, +})); +vi.mock('../composite-staleness.js', () => ({ + excludeStaleComposites: vi.fn(async (_repo, _userId, memories) => ({ + filtered: memories, + removedCompositeIds: [], + })), +})); + +const { performSearch } = await import('../memory-search.js'); + +function createTrace() { + return { + event: vi.fn(), + stage: vi.fn(), + finalize: vi.fn(), + setPackagingSummary: vi.fn(), + setAssemblySummary: vi.fn(), + setRetrievalSummary: vi.fn(), + getRetrievalSummary: vi.fn(() => undefined), + }; +} + +function createDeps(runtimeConfig: { + lessonsEnabled: boolean; + consensusValidationEnabled: boolean; + consensusMinMemories: number; + auditLoggingEnabled: boolean; +}) { + return { + config: runtimeConfig, + repo: { touchMemory: vi.fn().mockResolvedValue(undefined) }, + claims: {}, + entities: null, + lessons: {}, + observationService: null, + uriResolver: { resolve: vi.fn().mockResolvedValue(null), format: vi.fn() }, + } as any; +} + +describe('performSearch runtime config seam', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCheckLessons.mockResolvedValue({ safe: true }); + mockValidateConsensus.mockResolvedValue({ removedMemoryIds: [], judgments: [] }); + mockRunSearchPipelineWithTrace.mockResolvedValue({ + filtered: [createSearchResult({ id: 'memory-1', content: 'alpha result', score: 0.9 })], + trace: createTrace(), + }); + }); + + it('threads deps.config into the pipeline and gates request-time side effects from it', async () => { + const runtimeConfig = { + lessonsEnabled: false, + consensusValidationEnabled: false, + consensusMinMemories: 2, + auditLoggingEnabled: false, + }; + + const result = await performSearch(createDeps(runtimeConfig), 'user-1', 'find alpha'); + + expect(result.memories).toHaveLength(1); + expect(mockRunSearchPipelineWithTrace.mock.calls[0]?.[7]?.runtimeConfig).toBe(runtimeConfig); + expect(mockCheckLessons).not.toHaveBeenCalled(); + expect(mockValidateConsensus).not.toHaveBeenCalled(); + expect(mockEmitAuditEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/__tests__/query-augmentation.test.ts b/src/services/__tests__/query-augmentation.test.ts index 4b3569b..c172a36 100644 --- a/src/services/__tests__/query-augmentation.test.ts +++ b/src/services/__tests__/query-augmentation.test.ts @@ -89,6 +89,28 @@ describe('augmentQueryWithEntities', () => { ); }); + it('prefers explicit runtime config over module config thresholds', async () => { + mockConfig.queryAugmentationMaxEntities = 1; + mockConfig.queryAugmentationMinSimilarity = 0.95; + const entityRepo = createMockEntityRepo([]); + + await augmentQueryWithEntities( + entityRepo, + 'user-1', + 'override query', + [0.4, 0.4], + { + queryExpansionMinSimilarity: 0.5, + queryAugmentationMaxEntities: 4, + queryAugmentationMinSimilarity: 0.25, + }, + ); + + expect(entityRepo.searchEntities).toHaveBeenCalledWith( + 'user-1', [0.4, 0.4], 4, 0.25, + ); + }); + it('includes entity type and similarity in metadata', async () => { const entityRepo = createMockEntityRepo([ { name: 'Redis', entity_type: 'tool', similarity: 0.85 }, diff --git a/src/services/memory-search.ts b/src/services/memory-search.ts index cbc897f..c8ef8e5 100644 --- a/src/services/memory-search.ts +++ b/src/services/memory-search.ts @@ -3,7 +3,6 @@ * Contains search, fastSearch, workspaceSearch, and all private search helpers. */ -import { config } from '../config.js'; import { type SearchResult } from '../db/memory-repository.js'; import { checkLessons, recordContradictionLesson, type LessonCheckResult } from './lesson-service.js'; import { validateConsensus, type ConsensusResult } from './consensus-validation.js'; @@ -24,7 +23,7 @@ import type { MemoryServiceDeps, RetrievalMode, RetrievalOptions, RetrievalResul /** Check lessons safety gate; returns undefined if lessons disabled. */ async function checkSearchLessons(deps: MemoryServiceDeps, userId: string, query: string): Promise { - if (!config.lessonsEnabled || !deps.lessons) return undefined; + if (!deps.config.lessonsEnabled || !deps.lessons) return undefined; return checkLessons(deps.lessons, userId, query); } @@ -103,7 +102,7 @@ async function postProcessResults( } } - if (!config.consensusValidationEnabled || memories.length < config.consensusMinMemories) { + if (!deps.config.consensusValidationEnabled || memories.length < deps.config.consensusMinMemories) { return { memories }; } @@ -115,7 +114,7 @@ async function postProcessResults( removedCount: consensusResult.removedMemoryIds.length, removedIds: consensusResult.removedMemoryIds, }); - if (config.lessonsEnabled && deps.lessons) { + if (deps.config.lessonsEnabled && deps.lessons) { recordConsensusLessons(deps, userId, consensusResult, memories).catch( (err) => console.error('Consensus lesson recording failed:', err), ); @@ -244,7 +243,7 @@ function recordSearchSideEffects( if (!asOf) { for (const memory of outputMemories) deps.repo.touchMemory(memory.id).catch(() => {}); } - if (config.auditLoggingEnabled) { + if (deps.config.auditLoggingEnabled) { emitAuditEvent('memory:retrieve', userId, { query: query.slice(0, 200), resultCount: outputMemories.length, diff --git a/src/services/query-expansion.ts b/src/services/query-expansion.ts index 0d3b47b..a8a7b11 100644 --- a/src/services/query-expansion.ts +++ b/src/services/query-expansion.ts @@ -18,11 +18,17 @@ */ import { config } from '../config.js'; +import type { CoreRuntimeConfig } from '../app/runtime-container.js'; import type { EntityRepository } from '../db/repository-entities.js'; import type { MemoryRepository, SearchResult } from '../db/memory-repository.js'; import { llm } from './llm.js'; import { embedText } from './embedding.js'; +type SearchExpansionRuntimeConfig = Pick< + CoreRuntimeConfig, + 'queryExpansionMinSimilarity' | 'queryAugmentationMaxEntities' | 'queryAugmentationMinSimilarity' +>; + const ENTITY_EXTRACTION_PROMPT = 'Extract entity names and conceptual topics from this search query. ' + 'Return a JSON object with two arrays: ' + @@ -82,6 +88,7 @@ async function findEntitiesByTerms( userId: string, terms: string[], limit: number, + runtimeConfig: SearchExpansionRuntimeConfig = config, ): Promise { if (terms.length === 0) return []; @@ -89,7 +96,7 @@ async function findEntitiesByTerms( for (const term of terms) { const embedding = await embedText(term); const matches = await entityRepo.searchEntities( - userId, embedding, limit, config.queryExpansionMinSimilarity, + userId, embedding, limit, runtimeConfig.queryExpansionMinSimilarity, ); for (const match of matches) { allIds.add(match.id); @@ -110,6 +117,7 @@ export async function expandQueryViaEntities( queryEmbedding: number[], excludeIds: Set, budget: number, + runtimeConfig: SearchExpansionRuntimeConfig = config, ): Promise<{ memories: SearchResult[]; expansion: QueryExpansionResult }> { const { entities, concepts } = await extractQueryTerms(query); const allTerms = [...entities, ...concepts]; @@ -121,7 +129,13 @@ export async function expandQueryViaEntities( }; } - const matchedEntityIds = await findEntitiesByTerms(entityRepo, userId, allTerms, 10); + const matchedEntityIds = await findEntitiesByTerms( + entityRepo, + userId, + allTerms, + 10, + runtimeConfig, + ); if (matchedEntityIds.length === 0) { return { @@ -183,12 +197,13 @@ export async function augmentQueryWithEntities( userId: string, query: string, queryEmbedding: number[], + runtimeConfig: SearchExpansionRuntimeConfig = config, ): Promise { const matches = await entityRepo.searchEntities( userId, queryEmbedding, - config.queryAugmentationMaxEntities, - config.queryAugmentationMinSimilarity, + runtimeConfig.queryAugmentationMaxEntities, + runtimeConfig.queryAugmentationMinSimilarity, ); const matchedEntities = matches.map((e) => ({ diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index f7a7d3a..1d815be 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -47,11 +47,15 @@ const KEYWORD_RRF_WEIGHT = 1.0; * Thresholds are configurable via RERANK_SKIP_TOP_SIMILARITY (default 0.85) * and RERANK_SKIP_MIN_GAP (default 0.05). Saves ~150ms per query on CPU. */ -function shouldAutoSkipReranking(results: SearchResult[]): boolean { +function shouldAutoSkipReranking( + results: SearchResult[], + policyConfig: Pick = config, +): boolean { if (results.length < 2) return true; const topSim = results[0]?.score ?? 0; const secondSim = results[1]?.score ?? 0; - return topSim >= config.rerankSkipTopSimilarity && (topSim - secondSim) >= config.rerankSkipMinGap; + return topSim >= policyConfig.rerankSkipTopSimilarity + && (topSim - secondSim) >= policyConfig.rerankSkipMinGap; } export interface SearchPipelineOptions { @@ -137,7 +141,7 @@ export async function runSearchPipelineWithTrace( // Query expansion const withExpansion = await timed('search.query-expansion', () => applyQueryExpansion( - repo, entityRepo, userId, query, queryEmbedding, temporalExpansion.memories, candidateDepth, trace, + repo, entityRepo, userId, query, queryEmbedding, temporalExpansion.memories, candidateDepth, trace, policyConfig, )); const repaired = options.skipRepairLoop @@ -207,6 +211,7 @@ export async function runSearchPipelineWithTrace( temporalExpansion.temporalAnchorFingerprints, trace, options.skipReranking, + policyConfig, )); const namespaceScope = options.namespaceScope ?? null; @@ -258,6 +263,7 @@ async function runInitialRetrieval( sourceSite, referenceTime, policyConfig.hybridSearchEnabled, + policyConfig, ); } @@ -292,6 +298,7 @@ async function maybeApplyAbstractHybridFallback( sourceSite, referenceTime, true, + policyConfig, ); trace.stage('abstract-hybrid-fallback', fallbackResults, { candidateDepth }); return fallbackResults; @@ -337,18 +344,19 @@ async function applyRepairLoop( candidateDepth, sourceSite, referenceTime, - config.hybridSearchEnabled, + policyConfig.hybridSearchEnabled, + policyConfig, ); const decision = shouldAcceptRepair(initialResults, repairedResults, policyConfig); if (decision.accepted) { - const mergedPool = mergeStageResults( - initialResults, - repairedResults, - initialResults.length + repairedResults.length, - config.retrievalProfileSettings.repairPrimaryWeight, - config.retrievalProfileSettings.repairRewriteWeight, - ); + const mergedPool = mergeStageResults( + initialResults, + repairedResults, + initialResults.length + repairedResults.length, + policyConfig.retrievalProfileSettings.repairPrimaryWeight, + policyConfig.retrievalProfileSettings.repairRewriteWeight, + ); const merged = preserveProtectedResults( mergedPool.slice(0, candidateDepth), mergedPool, @@ -385,14 +393,15 @@ async function applyQueryExpansion( initialResults: SearchResult[], candidateDepth: number, trace: TraceCollector, + policyConfig: CoreRuntimeConfig = config, ): Promise { - if (!config.queryExpansionEnabled || !config.entityGraphEnabled || !entityRepo) { + if (!policyConfig.queryExpansionEnabled || !policyConfig.entityGraphEnabled || !entityRepo) { return initialResults; } const excludeIds = new Set(initialResults.map((r) => r.id)); const { memories, expansion } = await expandQueryViaEntities( - entityRepo, repo, userId, query, queryEmbedding, excludeIds, config.linkExpansionMax, + entityRepo, repo, userId, query, queryEmbedding, excludeIds, policyConfig.linkExpansionMax, policyConfig, ); if (memories.length === 0) { @@ -433,7 +442,7 @@ async function applyQueryAugmentation( } const result = await augmentQueryWithEntities( - entityRepo, userId, query, queryEmbedding, + entityRepo, userId, query, queryEmbedding, policyConfig, ); if (result.augmentedQuery === query) { @@ -633,11 +642,12 @@ async function applyExpansionAndReranking( temporalAnchorFingerprints: string[], trace: TraceCollector, skipReranking?: boolean, + policyConfig: CoreRuntimeConfig = config, ): Promise { // Cross-encoder reranking: re-score candidates before MMR let candidates = results; let protectedFingerprints = [...temporalAnchorFingerprints]; - const shouldSkipRerank = skipReranking || shouldAutoSkipReranking(results); + const shouldSkipRerank = skipReranking || shouldAutoSkipReranking(results, policyConfig); if (config.crossEncoderEnabled && !shouldSkipRerank) { candidates = await rerankCandidates(query, results); trace.stage('cross-encoder', candidates, { @@ -665,34 +675,58 @@ async function applyExpansionAndReranking( candidates = applyConcisenessPenalty(candidates); - if (config.linkExpansionBeforeMMR && config.linkExpansionEnabled && config.mmrEnabled) { - const preExpanded = await expandWithLinks(repo, entityRepo, userId, candidates.slice(0, limit), queryEmbedding, referenceTime); + if (policyConfig.linkExpansionBeforeMMR && policyConfig.linkExpansionEnabled && policyConfig.mmrEnabled) { + const preExpanded = await expandWithLinks( + repo, + entityRepo, + userId, + candidates.slice(0, limit), + queryEmbedding, + referenceTime, + policyConfig, + ); trace.stage('link-expansion', preExpanded, { order: 'before-mmr' }); const selected = preserveProtectedResults( - applyMMR(preExpanded, queryEmbedding, limit, config.mmrLambda), + applyMMR(preExpanded, queryEmbedding, limit, policyConfig.mmrLambda), preExpanded, protectedFingerprints, limit, ); - trace.stage('mmr', selected, { lambda: config.mmrLambda }); + trace.stage('mmr', selected, { lambda: policyConfig.mmrLambda }); return selected; } - if (config.mmrEnabled) { + if (policyConfig.mmrEnabled) { const mmrResults = preserveProtectedResults( - applyMMR(candidates, queryEmbedding, limit, config.mmrLambda), + applyMMR(candidates, queryEmbedding, limit, policyConfig.mmrLambda), candidates, protectedFingerprints, limit, ); - trace.stage('mmr', mmrResults, { lambda: config.mmrLambda }); - const expanded = await expandWithLinks(repo, entityRepo, userId, mmrResults, queryEmbedding, referenceTime); + trace.stage('mmr', mmrResults, { lambda: policyConfig.mmrLambda }); + const expanded = await expandWithLinks( + repo, + entityRepo, + userId, + mmrResults, + queryEmbedding, + referenceTime, + policyConfig, + ); trace.stage('link-expansion', expanded, { order: 'after-mmr' }); return expanded; } const sliced = preserveProtectedResults(candidates.slice(0, limit), candidates, protectedFingerprints, limit); - const expanded = await expandWithLinks(repo, entityRepo, userId, sliced, queryEmbedding, referenceTime); + const expanded = await expandWithLinks( + repo, + entityRepo, + userId, + sliced, + queryEmbedding, + referenceTime, + policyConfig, + ); trace.stage('link-expansion', expanded, { order: 'no-mmr' }); return expanded; } @@ -708,15 +742,16 @@ async function expandWithLinks( results: SearchResult[], queryEmbedding: number[], referenceTime?: Date, + policyConfig: CoreRuntimeConfig = config, ): Promise { - if (!config.linkExpansionEnabled || config.linkExpansionMax <= 0) return results; + if (!policyConfig.linkExpansionEnabled || policyConfig.linkExpansionMax <= 0) return results; const resultIds = results.map((r) => r.id); const excludeIds = new Set(resultIds); - const budget = config.linkExpansionMax; + const budget = policyConfig.linkExpansionMax; - const linkedIds = config.pprEnabled - ? await expandViaPPR(repo, results, excludeIds, budget) + const linkedIds = policyConfig.pprEnabled + ? await expandViaPPR(repo, results, excludeIds, budget, policyConfig) : await repo.findLinkedMemoryIds(resultIds, excludeIds, budget); const temporalNeighbors = await repo.findTemporalNeighbors( @@ -737,7 +772,15 @@ async function expandWithLinks( const dedupedTemporal = temporalNeighbors.filter((m) => !seen.has(m.id)); // Entity graph expansion: find entities matching the query and pull in their linked memories - const entityMemories = await expandViaEntities(repo, entityRepo, userId, queryEmbedding, seen, budget); + const entityMemories = await expandViaEntities( + repo, + entityRepo, + userId, + queryEmbedding, + seen, + budget, + policyConfig, + ); const expansions = [...linkedMemories, ...dedupedTemporal, ...entityMemories] .sort((a, b) => b.score - a.score) @@ -756,6 +799,7 @@ async function runMemoryRrfRetrieval( sourceSite: string | undefined, referenceTime: Date | undefined, includeKeywordChannel: boolean, + policyConfig: CoreRuntimeConfig = config, ): Promise { const semanticResults = await repo.searchSimilar( userId, @@ -768,8 +812,16 @@ async function runMemoryRrfRetrieval( { name: 'semantic', weight: SEMANTIC_RRF_WEIGHT, results: semanticResults }, ]; - if (config.entityGraphEnabled && entityRepo) { - const entityResults = await expandViaEntities(repo, entityRepo, userId, queryEmbedding, new Set(), limit); + if (policyConfig.entityGraphEnabled && entityRepo) { + const entityResults = await expandViaEntities( + repo, + entityRepo, + userId, + queryEmbedding, + new Set(), + limit, + policyConfig, + ); if (entityResults.length > 0) { channels.push({ name: 'entity', weight: ENTITY_RRF_WEIGHT, results: entityResults }); } @@ -797,6 +849,7 @@ async function expandViaPPR( results: SearchResult[], excludeIds: Set, budget: number, + policyConfig: Pick = config, ): Promise { const seedScores = new Map(); for (const r of results) { @@ -806,7 +859,7 @@ async function expandViaPPR( const { scores } = await personalizedPageRank( repo.getPool(), seedScores, - { damping: config.pprDamping }, + { damping: policyConfig.pprDamping }, ); return [...scores.entries()] @@ -893,11 +946,12 @@ async function expandViaEntities( queryEmbedding: number[], excludeIds: Set, budget: number, + policyConfig: Pick = config, ): Promise { - if (!config.entityGraphEnabled || !entityRepo) return []; + if (!policyConfig.entityGraphEnabled || !entityRepo) return []; const matchingEntities = await entityRepo.searchEntities( - userId, queryEmbedding, 5, config.entitySearchMinSimilarity, + userId, queryEmbedding, 5, policyConfig.entitySearchMinSimilarity, ); if (matchingEntities.length === 0) return []; From 59b716ee7f2d0cecaec8fe75372796501725750f Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 17:40:44 -0700 Subject: [PATCH 31/59] Thread runtime rerank enable gate through search policy config --- .../search-pipeline-runtime-config.test.ts | 128 ++++++++++++++++++ src/services/search-pipeline.ts | 4 +- 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/services/__tests__/search-pipeline-runtime-config.test.ts diff --git a/src/services/__tests__/search-pipeline-runtime-config.test.ts b/src/services/__tests__/search-pipeline-runtime-config.test.ts new file mode 100644 index 0000000..a45bfeb --- /dev/null +++ b/src/services/__tests__/search-pipeline-runtime-config.test.ts @@ -0,0 +1,128 @@ +/** + * Runtime config seam tests for search-pipeline. + * + * Verifies that request-time runtime config can disable cross-encoder + * reranking even when the module singleton still enables it. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSearchResult } from './test-fixtures.js'; + +const mockConfig = { + rerankSkipTopSimilarity: 0.85, + rerankSkipMinGap: 0.05, + mmrEnabled: false, + queryAugmentationEnabled: false, + entityGraphEnabled: false, + hybridSearchEnabled: false, + iterativeRetrievalEnabled: false, + agenticRetrievalEnabled: false, + queryExpansionEnabled: false, + linkExpansionEnabled: false, + linkExpansionMax: 0, + linkExpansionBeforeMMR: false, + mmrLambda: 0.5, + crossEncoderEnabled: true, + crossEncoderModel: 'module-cross-encoder', + crossEncoderDtype: 'q8', + retrievalProfileSettings: { + repairPrimaryWeight: 1, + repairRewriteWeight: 1, + }, +}; + +const { mockRerankCandidates } = vi.hoisted(() => ({ + mockRerankCandidates: vi.fn(), +})); + +vi.mock('../../config.js', () => ({ config: mockConfig })); +vi.mock('../embedding.js', () => ({ embedText: vi.fn().mockResolvedValue([0.1, 0.2]) })); +vi.mock('../extraction.js', () => ({ rewriteQuery: vi.fn() })); +vi.mock('../retrieval-policy.js', () => ({ + resolveRerankDepth: vi.fn((limit: number) => limit), + shouldRunRepairLoop: vi.fn(() => false), + shouldAcceptRepair: vi.fn(), +})); +vi.mock('../query-expansion.js', () => ({ + expandQueryViaEntities: vi.fn(), + augmentQueryWithEntities: vi.fn(), + coRetrieveByEntityNames: vi.fn(), +})); +vi.mock('../reranker.js', () => ({ + rerankCandidates: mockRerankCandidates, +})); +vi.mock('../abstract-query-policy.js', () => ({ + shouldUseAbstractHybridFallback: vi.fn(() => false), +})); +vi.mock('../agentic-retrieval.js', () => ({ + applyAgenticRetrieval: vi.fn(), +})); +vi.mock('../timing.js', () => ({ + timed: vi.fn(async (_name: string, fn: () => unknown) => fn()), +})); +vi.mock('../temporal-query-expansion.js', () => ({ + expandTemporalQuery: vi.fn(async () => ({ memories: [], keywords: [], anchorIds: [] })), +})); +vi.mock('../literal-query-expansion.js', () => ({ + expandLiteralQuery: vi.fn(async () => ({ memories: [], keywords: [] })), + isLiteralDetailQuery: vi.fn(() => false), +})); +vi.mock('../subject-aware-ranking.js', () => ({ + expandSubjectQuery: vi.fn(async () => ({ memories: [], anchors: [] })), + applySubjectAwareRanking: vi.fn((_query: string, results: unknown[]) => ({ + results, + subjects: [], + keywords: [], + protectedFingerprints: [], + })), +})); +vi.mock('../iterative-retrieval.js', () => ({ + applyIterativeRetrieval: vi.fn(), +})); +vi.mock('../current-state-ranking.js', () => ({ + applyCurrentStateRanking: vi.fn((_query: string, results: unknown[]) => ({ + triggered: false, + results, + })), +})); +vi.mock('../conciseness-preference.js', () => ({ + applyConcisenessPenalty: vi.fn((results: unknown[]) => results), +})); + +const { runSearchPipelineWithTrace } = await import('../search-pipeline.js'); + +describe('runSearchPipelineWithTrace runtime config', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRerankCandidates.mockResolvedValue([]); + }); + + it('uses runtime config to disable cross-encoder reranking', async () => { + const initialResults = [ + createSearchResult({ id: 'memory-1', score: 0.4, similarity: 0.4 }), + createSearchResult({ id: 'memory-2', score: 0.39, similarity: 0.39 }), + ]; + const repo = { + searchSimilar: vi.fn().mockResolvedValue(initialResults), + } as any; + + const result = await runSearchPipelineWithTrace( + repo, + null, + 'user-1', + 'runtime config query', + 2, + undefined, + undefined, + { + runtimeConfig: { + ...mockConfig, + crossEncoderEnabled: false, + } as any, + }, + ); + + expect(result.filtered).toHaveLength(2); + expect(mockRerankCandidates).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 1d815be..75ac1d0 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -648,13 +648,13 @@ async function applyExpansionAndReranking( let candidates = results; let protectedFingerprints = [...temporalAnchorFingerprints]; const shouldSkipRerank = skipReranking || shouldAutoSkipReranking(results, policyConfig); - if (config.crossEncoderEnabled && !shouldSkipRerank) { + if (policyConfig.crossEncoderEnabled && !shouldSkipRerank) { candidates = await rerankCandidates(query, results); trace.stage('cross-encoder', candidates, { model: config.crossEncoderModel, dtype: config.crossEncoderDtype, }); - } else if (config.crossEncoderEnabled && shouldSkipRerank) { + } else if (policyConfig.crossEncoderEnabled && shouldSkipRerank) { console.log(`[reranker] Skipped: ${skipReranking ? 'explicit' : 'auto-skip (high-confidence results)'}`); } const subjectRanked = applySubjectAwareRanking(query, candidates); From 112c732664bca6fc148f27cbe32f7b533b324801 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 17:41:16 -0700 Subject: [PATCH 32/59] test(search): prove runtimeConfig override reaches expandQueryViaEntities Add one narrow test in query-expansion.test.ts asserting that an explicit runtimeConfig override's queryExpansionMinSimilarity actually reaches entityRepo.searchEntities via findEntitiesByTerms, rather than the static module config. Mirrors the existing "prefers explicit runtime config over module config" test codex2 added for augmentQueryWithEntities in query-augmentation.test.ts, closing the coverage gap on the sibling freshly-threaded helper. Narrow scope: one test, reuses existing file-level mocks (config, llm, embedding), adds only the expandQueryViaEntities import and a direct llm.chat mock return. No source changes, no new test file, no new suite. --- .../__tests__/query-expansion.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/services/__tests__/query-expansion.test.ts b/src/services/__tests__/query-expansion.test.ts index d378bec..4464b1e 100644 --- a/src/services/__tests__/query-expansion.test.ts +++ b/src/services/__tests__/query-expansion.test.ts @@ -9,7 +9,8 @@ vi.mock('../../config.js', () => ({ config: { queryExpansionMinSimilarity: 0.5 } vi.mock('../llm.js', () => ({ llm: { chat: vi.fn() } })); vi.mock('../embedding.js', () => ({ embedText: vi.fn().mockResolvedValue([0.1, 0.2]) })); -const { parseQueryTerms } = await import('../query-expansion.js'); +const { parseQueryTerms, expandQueryViaEntities } = await import('../query-expansion.js'); +const { llm } = await import('../llm.js'); describe('parseQueryTerms', () => { it('parses valid JSON with entities and concepts', () => { @@ -66,3 +67,25 @@ describe('parseQueryTerms', () => { expect(result.concepts).toEqual(['caching']); }); }); + +describe('expandQueryViaEntities runtime config', () => { + it('prefers explicit runtime config over module config for expansion similarity threshold', async () => { + (llm.chat as any).mockResolvedValue('{"entities":["Acme"],"concepts":[]}'); + const searchEntities = vi.fn().mockResolvedValue([]); + const entityRepo = { searchEntities } as any; + const repo = {} as any; + + await expandQueryViaEntities( + entityRepo, repo, 'user-1', 'Acme question', [0.1, 0.2], new Set(), 20, + { + queryExpansionMinSimilarity: 0.88, + queryAugmentationMaxEntities: 5, + queryAugmentationMinSimilarity: 0.4, + }, + ); + + expect(searchEntities).toHaveBeenCalledWith( + 'user-1', expect.any(Array), expect.any(Number), 0.88, + ); + }); +}); From 37e27ecbd7a0c461163d4246f9fb8276c41ec5ef Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 17:51:08 -0700 Subject: [PATCH 33/59] refactor(search): thread reranker runtime config subset Carry crossEncoderModel and crossEncoderDtype through the request-time search pipeline seam into reranker loading and cross-encoder trace metadata. Add a focused runtime-config test for that branch. --- commit-message.txt | 5 ++ .../search-pipeline-runtime-config.test.ts | 60 +++++++++++++++++++ src/services/reranker.ts | 37 ++++++++---- src/services/search-pipeline.ts | 10 +++- 4 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 commit-message.txt diff --git a/commit-message.txt b/commit-message.txt new file mode 100644 index 0000000..e9e5101 --- /dev/null +++ b/commit-message.txt @@ -0,0 +1,5 @@ +refactor(search): thread reranker runtime config subset + +Carry crossEncoderModel and crossEncoderDtype through the request-time +search pipeline seam into reranker loading and cross-encoder trace +metadata. Add a focused runtime-config test for that branch. diff --git a/src/services/__tests__/search-pipeline-runtime-config.test.ts b/src/services/__tests__/search-pipeline-runtime-config.test.ts index a45bfeb..d348995 100644 --- a/src/services/__tests__/search-pipeline-runtime-config.test.ts +++ b/src/services/__tests__/search-pipeline-runtime-config.test.ts @@ -34,6 +34,9 @@ const mockConfig = { const { mockRerankCandidates } = vi.hoisted(() => ({ mockRerankCandidates: vi.fn(), })); +const { mockTraceStage } = vi.hoisted(() => ({ + mockTraceStage: vi.fn(), +})); vi.mock('../../config.js', () => ({ config: mockConfig })); vi.mock('../embedding.js', () => ({ embedText: vi.fn().mockResolvedValue([0.1, 0.2]) })); @@ -51,6 +54,17 @@ vi.mock('../query-expansion.js', () => ({ vi.mock('../reranker.js', () => ({ rerankCandidates: mockRerankCandidates, })); +vi.mock('../retrieval-trace.js', () => ({ + TraceCollector: class { + stage = mockTraceStage; + event = vi.fn(); + finalize = vi.fn(); + setRetrievalSummary = vi.fn(); + setPackagingSummary = vi.fn(); + setAssemblySummary = vi.fn(); + getRetrievalSummary = vi.fn(() => undefined); + }, +})); vi.mock('../abstract-query-policy.js', () => ({ shouldUseAbstractHybridFallback: vi.fn(() => false), })); @@ -125,4 +139,50 @@ describe('runSearchPipelineWithTrace runtime config', () => { expect(result.filtered).toHaveLength(2); expect(mockRerankCandidates).not.toHaveBeenCalled(); }); + + it('threads runtime reranker model and dtype through rerank and trace metadata', async () => { + const initialResults = [ + createSearchResult({ id: 'memory-1', score: 0.4, similarity: 0.4 }), + createSearchResult({ id: 'memory-2', score: 0.39, similarity: 0.39 }), + ]; + const rerankedResults = [...initialResults].reverse(); + mockRerankCandidates.mockResolvedValue(rerankedResults); + const repo = { + searchSimilar: vi.fn().mockResolvedValue(initialResults), + } as any; + + const runtimeConfig = { + ...mockConfig, + crossEncoderModel: 'runtime-cross-encoder', + crossEncoderDtype: 'fp16', + } as any; + + await runSearchPipelineWithTrace( + repo, + null, + 'user-1', + 'runtime config query', + 2, + undefined, + undefined, + { runtimeConfig }, + ); + + expect(mockRerankCandidates).toHaveBeenCalledWith( + 'runtime config query', + initialResults, + { + crossEncoderModel: 'runtime-cross-encoder', + crossEncoderDtype: 'fp16', + }, + ); + expect(mockTraceStage).toHaveBeenCalledWith( + 'cross-encoder', + rerankedResults, + { + model: 'runtime-cross-encoder', + dtype: 'fp16', + }, + ); + }); }); diff --git a/src/services/reranker.ts b/src/services/reranker.ts index 907b7b4..f38d5d5 100644 --- a/src/services/reranker.ts +++ b/src/services/reranker.ts @@ -9,37 +9,47 @@ */ import type { SearchResult } from '../db/memory-repository.js'; -import { config } from '../config.js'; +import { config, type CrossEncoderDtype } from '../config.js'; let tokenizer: Awaited> | null = null; let model: Awaited> | null = null; -let loadedModelId: string | null = null; +let loadedModelKey: string | null = null; let loadPromise: Promise | null = null; /** Serialize ONNX inference to prevent mutex corruption (see onnx-stability-issue.md). */ let inferenceQueue: Promise = Promise.resolve(); +export interface RerankerRuntimeConfig { + crossEncoderModel: string; + crossEncoderDtype: CrossEncoderDtype; +} + async function loadTokenizer(modelId: string) { const { AutoTokenizer } = await import('@huggingface/transformers'); return AutoTokenizer.from_pretrained(modelId); } -async function loadModel(modelId: string) { +async function loadModel(modelId: string, runtimeConfig: RerankerRuntimeConfig) { const { AutoModelForSequenceClassification } = await import('@huggingface/transformers'); return AutoModelForSequenceClassification.from_pretrained(modelId, { - dtype: config.crossEncoderDtype, + dtype: runtimeConfig.crossEncoderDtype, }); } -async function ensureLoaded(): Promise { - const modelId = config.crossEncoderModel; - if (tokenizer && model && loadedModelId === modelId) return; +function buildRerankerConfigKey(runtimeConfig: RerankerRuntimeConfig): string { + return `${runtimeConfig.crossEncoderModel}:${runtimeConfig.crossEncoderDtype}`; +} + +async function ensureLoaded(runtimeConfig: RerankerRuntimeConfig = config): Promise { + const modelId = runtimeConfig.crossEncoderModel; + const modelKey = buildRerankerConfigKey(runtimeConfig); + if (tokenizer && model && loadedModelKey === modelKey) return; if (loadPromise) { await loadPromise; return; } loadPromise = (async () => { - console.log(`[reranker] Loading ${modelId}...`); + console.log(`[reranker] Loading ${modelId} (${runtimeConfig.crossEncoderDtype})...`); const start = Date.now(); - [tokenizer, model] = await Promise.all([loadTokenizer(modelId), loadModel(modelId)]); - loadedModelId = modelId; - console.log(`[reranker] Loaded ${modelId} in ${Date.now() - start}ms`); + [tokenizer, model] = await Promise.all([loadTokenizer(modelId), loadModel(modelId, runtimeConfig)]); + loadedModelKey = modelKey; + console.log(`[reranker] Loaded ${modelId} (${runtimeConfig.crossEncoderDtype}) in ${Date.now() - start}ms`); })(); try { await loadPromise; @@ -59,10 +69,11 @@ function sigmoid(x: number): number { export async function rerankCandidates( query: string, candidates: SearchResult[], + runtimeConfig: RerankerRuntimeConfig = config, ): Promise { if (candidates.length === 0) return candidates; - await ensureLoaded(); + await ensureLoaded(runtimeConfig); const start = Date.now(); const queries = candidates.map(() => query); @@ -102,7 +113,7 @@ export async function rerankCandidates( const ms = Date.now() - start; console.log( - `[reranker] Scored ${candidates.length} candidates with ${loadedModelId} in ${ms}ms (top: ${reranked[0]?.score.toFixed(3)})`, + `[reranker] Scored ${candidates.length} candidates with ${runtimeConfig.crossEncoderModel} (${runtimeConfig.crossEncoderDtype}) in ${ms}ms (top: ${reranked[0]?.score.toFixed(3)})`, ); return reranked; diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 75ac1d0..bf9aa5d 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -649,10 +649,14 @@ async function applyExpansionAndReranking( let protectedFingerprints = [...temporalAnchorFingerprints]; const shouldSkipRerank = skipReranking || shouldAutoSkipReranking(results, policyConfig); if (policyConfig.crossEncoderEnabled && !shouldSkipRerank) { - candidates = await rerankCandidates(query, results); + const rerankerConfig = { + crossEncoderModel: policyConfig.crossEncoderModel, + crossEncoderDtype: policyConfig.crossEncoderDtype, + }; + candidates = await rerankCandidates(query, results, rerankerConfig); trace.stage('cross-encoder', candidates, { - model: config.crossEncoderModel, - dtype: config.crossEncoderDtype, + model: rerankerConfig.crossEncoderModel, + dtype: rerankerConfig.crossEncoderDtype, }); } else if (policyConfig.crossEncoderEnabled && shouldSkipRerank) { console.log(`[reranker] Skipped: ${skipReranking ? 'explicit' : 'auto-skip (high-confidence results)'}`); From 9d41a072388d37b259301138a47bab2cfb4a7adf Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 17:52:06 -0700 Subject: [PATCH 34/59] chore: remove temporary commit message file Drop the accidentally tracked temporary commit-message.txt file from the previous commit. No functional code changes. --- commit-message.txt | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 commit-message.txt diff --git a/commit-message.txt b/commit-message.txt deleted file mode 100644 index e9e5101..0000000 --- a/commit-message.txt +++ /dev/null @@ -1,5 +0,0 @@ -refactor(search): thread reranker runtime config subset - -Carry crossEncoderModel and crossEncoderDtype through the request-time -search pipeline seam into reranker loading and cross-encoder trace -metadata. Add a focused runtime-config test for that branch. From 12ae16c92e5cc5517d8bdb912f71dcac5d4822dd Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 17:53:09 -0700 Subject: [PATCH 35/59] test(search): prove runtime config can enable agentic retrieval Add one narrow test in search-pipeline-runtime-config.test.ts asserting that a request-time runtimeConfig with agenticRetrievalEnabled: true causes applyAgenticRetrieval to be called even when the module singleton has agenticRetrievalEnabled: false. Uses the existing file's mock scaffolding (applyAgenticRetrieval was already mocked but never exercised by a prior test). Pure test-only. Narrow scope: - One new test in an existing file - No source changes - No new mocks added; reuses existing - Complements the existing disable-reranker override test with a different gate (agentic retrieval, my slice 0c60d06) instead of duplicating the reranker subset --- .../search-pipeline-runtime-config.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/services/__tests__/search-pipeline-runtime-config.test.ts b/src/services/__tests__/search-pipeline-runtime-config.test.ts index d348995..784336a 100644 --- a/src/services/__tests__/search-pipeline-runtime-config.test.ts +++ b/src/services/__tests__/search-pipeline-runtime-config.test.ts @@ -140,6 +140,42 @@ describe('runSearchPipelineWithTrace runtime config', () => { expect(mockRerankCandidates).not.toHaveBeenCalled(); }); + it('uses runtime config to enable agentic retrieval even when module config disables it', async () => { + const initialResults = [ + createSearchResult({ id: 'memory-1', score: 0.4, similarity: 0.4 }), + createSearchResult({ id: 'memory-2', score: 0.39, similarity: 0.39 }), + ]; + const repo = { + searchSimilar: vi.fn().mockResolvedValue(initialResults), + } as any; + const agentic = await import('../agentic-retrieval.js'); + vi.mocked(agentic.applyAgenticRetrieval).mockResolvedValue({ + memories: initialResults, + triggered: false, + subQueries: [], + reason: 'strong-initial-results', + }); + + await runSearchPipelineWithTrace( + repo, + null, + 'user-1', + 'runtime config agentic query', + 2, + undefined, + undefined, + { + runtimeConfig: { + ...mockConfig, + agenticRetrievalEnabled: true, + crossEncoderEnabled: false, + } as any, + }, + ); + + expect(agentic.applyAgenticRetrieval).toHaveBeenCalled(); + }); + it('threads runtime reranker model and dtype through rerank and trace metadata', async () => { const initialResults = [ createSearchResult({ id: 'memory-1', score: 0.4, similarity: 0.4 }), From 2f31c193d5eee9edfbe4e7f23c398a2a253a1f56 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 20:30:07 -0700 Subject: [PATCH 36/59] Allow explicit staged-loading override in retrieval formatting --- .../__tests__/retrieval-format.test.ts | 12 +++++ .../search-pipeline-runtime-config.test.ts | 46 ++++++++++++++++++- src/services/retrieval-format.ts | 12 ++++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/services/__tests__/retrieval-format.test.ts b/src/services/__tests__/retrieval-format.test.ts index 6f914ee..74c4992 100644 --- a/src/services/__tests__/retrieval-format.test.ts +++ b/src/services/__tests__/retrieval-format.test.ts @@ -93,6 +93,18 @@ describe('formatInjection', () => { mockConfig.stagedLoadingEnabled = false; }); + it('prefers explicit staged-loading option over module config', () => { + mockConfig.stagedLoadingEnabled = false; + const result = formatInjection( + [makeResult({ summary: 'short summary' })], + { stagedLoadingEnabled: true }, + ); + + expect(result).toContain('mode="staged"'); + expect(result).toContain('short summary'); + expect(result).toContain('expand_hint'); + }); + it('staged mode truncates content when no summary', () => { mockConfig.stagedLoadingEnabled = true; const longContent = 'A'.repeat(100); diff --git a/src/services/__tests__/search-pipeline-runtime-config.test.ts b/src/services/__tests__/search-pipeline-runtime-config.test.ts index 784336a..2a9cc50 100644 --- a/src/services/__tests__/search-pipeline-runtime-config.test.ts +++ b/src/services/__tests__/search-pipeline-runtime-config.test.ts @@ -1,8 +1,8 @@ /** * Runtime config seam tests for search-pipeline. * - * Verifies that request-time runtime config can disable cross-encoder - * reranking even when the module singleton still enables it. + * Verifies that request-time runtime config can override cross-encoder + * reranking even when the module singleton differs. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -108,6 +108,9 @@ const { runSearchPipelineWithTrace } = await import('../search-pipeline.js'); describe('runSearchPipelineWithTrace runtime config', () => { beforeEach(() => { vi.clearAllMocks(); + mockConfig.crossEncoderEnabled = true; + mockConfig.crossEncoderModel = 'module-cross-encoder'; + mockConfig.crossEncoderDtype = 'q8'; mockRerankCandidates.mockResolvedValue([]); }); @@ -140,6 +143,45 @@ describe('runSearchPipelineWithTrace runtime config', () => { expect(mockRerankCandidates).not.toHaveBeenCalled(); }); + it('uses runtime config to enable cross-encoder reranking even when module config disables it', async () => { + mockConfig.crossEncoderEnabled = false; + const initialResults = [ + createSearchResult({ id: 'memory-1', score: 0.4, similarity: 0.4 }), + createSearchResult({ id: 'memory-2', score: 0.39, similarity: 0.39 }), + ]; + const rerankedResults = [...initialResults].reverse(); + mockRerankCandidates.mockResolvedValue(rerankedResults); + const repo = { + searchSimilar: vi.fn().mockResolvedValue(initialResults), + } as any; + + const result = await runSearchPipelineWithTrace( + repo, + null, + 'user-1', + 'runtime config rerank query', + 2, + undefined, + undefined, + { + runtimeConfig: { + ...mockConfig, + crossEncoderEnabled: true, + } as any, + }, + ); + + expect(result.filtered).toEqual(rerankedResults); + expect(mockRerankCandidates).toHaveBeenCalledWith( + 'runtime config rerank query', + initialResults, + { + crossEncoderModel: 'module-cross-encoder', + crossEncoderDtype: 'q8', + }, + ); + }); + it('uses runtime config to enable agentic retrieval even when module config disables it', async () => { const initialResults = [ createSearchResult({ id: 'memory-1', score: 0.4, similarity: 0.4 }), diff --git a/src/services/retrieval-format.ts b/src/services/retrieval-format.ts index 9ce6619..ef45763 100644 --- a/src/services/retrieval-format.ts +++ b/src/services/retrieval-format.ts @@ -76,6 +76,10 @@ export interface RetrievalCitation { importance: number; } +export interface RetrievalFormatOptions { + stagedLoadingEnabled?: boolean; +} + export function buildCitations(memories: SearchResult[]): RetrievalCitation[] { return memories.map((memory) => ({ memory_id: memory.id, @@ -203,9 +207,13 @@ function formatDuration(days: number): string { return `~${months} month${months !== 1 ? 's' : ''} (${days} days)`; } -export function formatInjection(memories: SearchResult[]): string { +export function formatInjection( + memories: SearchResult[], + options: RetrievalFormatOptions = {}, +): string { if (memories.length === 0) return ''; - if (config.stagedLoadingEnabled) return formatStagedInjection(memories); + const stagedLoadingEnabled = options.stagedLoadingEnabled ?? config.stagedLoadingEnabled; + if (stagedLoadingEnabled) return formatStagedInjection(memories); return formatFullInjection(memories); } From 9da3d5733306d6a21b892fc5145210fc7822a05a Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 20:30:16 -0700 Subject: [PATCH 37/59] docs(search): update runtimeConfig JSDoc to reflect current threading scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SearchPipelineOptions.runtimeConfig JSDoc still claimed the field was used "for resolveRerankDepth / shouldRunRepairLoop / shouldAcceptRepair / mergeSearchResults calls" — a list from the original Phase 3 baseline. After the multi-slice threading work, runtimeConfig now flows through nearly every retrieval helper (augmentation, expansion, reranking, MMR, link expansion, agentic/iterative retrieval gates). Update the JSDoc to accurately describe the current scope and explicitly note the remaining deferred static-config reads (generateLinks, which is on the ingest path, not the search path). No behavior change. Pure documentation truthfulness. --- src/services/search-pipeline.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index bf9aa5d..0f1d2ce 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -67,10 +67,17 @@ export interface SearchPipelineOptions { /** Skip cross-encoder reranking for latency-critical paths. */ skipReranking?: boolean; /** - * Runtime-owned config for retrieval-policy helpers. When present, used for - * resolveRerankDepth / shouldRunRepairLoop / shouldAcceptRepair / - * mergeSearchResults calls. Falls back to the static module-level config - * import if omitted (for callers that haven't migrated yet). + * Runtime-owned config threaded through all search-pipeline helpers. + * When present, gates and thresholds across the entire retrieval path + * (retrieval-policy, reranking, augmentation, expansion, MMR, link + * expansion, agentic/iterative retrieval) read from this instead of + * the static module-level config singleton. + * + * Falls back to the static config import if omitted (backward compat). + * + * Remaining deferred static reads: generateLinks (ingest path, not + * search path) still reads config.linkExpansionEnabled and + * config.linkSimilarityThreshold directly. */ runtimeConfig?: CoreRuntimeConfig; } From f80d7935fcfaaf0c168d1cef9f94c1e558d100bd Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 22:41:22 -0700 Subject: [PATCH 38/59] test(format): prove explicit full-loading override Add the symmetric retrieval-format fence proving an explicit stagedLoadingEnabled=false option overrides an enabled module config. Keep the slice test-only and scoped to packaging truthfulness. --- src/services/__tests__/retrieval-format.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/services/__tests__/retrieval-format.test.ts b/src/services/__tests__/retrieval-format.test.ts index 74c4992..8a3a161 100644 --- a/src/services/__tests__/retrieval-format.test.ts +++ b/src/services/__tests__/retrieval-format.test.ts @@ -105,6 +105,19 @@ describe('formatInjection', () => { expect(result).toContain('expand_hint'); }); + it('prefers explicit full-loading option over enabled module config', () => { + mockConfig.stagedLoadingEnabled = true; + const result = formatInjection( + [makeResult({ content: 'full content', summary: 'short summary' })], + { stagedLoadingEnabled: false }, + ); + + expect(result).not.toContain('mode="staged"'); + expect(result).not.toContain('expand_hint'); + expect(result).toContain('full content'); + mockConfig.stagedLoadingEnabled = false; + }); + it('staged mode truncates content when no summary', () => { mockConfig.stagedLoadingEnabled = true; const longContent = 'A'.repeat(100); From 19b985ff1cfdd58f58af6a901fa7d83dc89c68a9 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 22:42:52 -0700 Subject: [PATCH 39/59] Thread ingest-time link generation config through runtime seam --- .../search-pipeline-runtime-config.test.ts | 35 ++++++++++++++++++- src/services/memory-ingest.ts | 15 ++++++-- src/services/search-pipeline.ts | 13 +++---- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/services/__tests__/search-pipeline-runtime-config.test.ts b/src/services/__tests__/search-pipeline-runtime-config.test.ts index 2a9cc50..aee5383 100644 --- a/src/services/__tests__/search-pipeline-runtime-config.test.ts +++ b/src/services/__tests__/search-pipeline-runtime-config.test.ts @@ -103,7 +103,7 @@ vi.mock('../conciseness-preference.js', () => ({ applyConcisenessPenalty: vi.fn((results: unknown[]) => results), })); -const { runSearchPipelineWithTrace } = await import('../search-pipeline.js'); +const { runSearchPipelineWithTrace, generateLinks } = await import('../search-pipeline.js'); describe('runSearchPipelineWithTrace runtime config', () => { beforeEach(() => { @@ -263,4 +263,37 @@ describe('runSearchPipelineWithTrace runtime config', () => { }, ); }); + + it('uses runtime config to enable link generation even when module config disables it', async () => { + mockConfig.linkExpansionEnabled = false; + const repo = { + getMemory: vi.fn().mockResolvedValue({ id: 'memory-1' }), + findLinkCandidates: vi.fn().mockResolvedValue([ + { id: 'linked-1', similarity: 0.77 }, + ]), + createLinks: vi.fn().mockResolvedValue(1), + } as any; + + const created = await generateLinks( + repo, + 'user-1', + ['memory-1'], + new Map([['memory-1', [0.1, 0.2]]]), + { + linkExpansionEnabled: true, + linkSimilarityThreshold: 0.42, + }, + ); + + expect(created).toBe(1); + expect(repo.findLinkCandidates).toHaveBeenCalledWith( + 'user-1', + [0.1, 0.2], + 0.42, + 'memory-1', + ); + expect(repo.createLinks).toHaveBeenCalledWith([ + { sourceId: 'memory-1', targetId: 'linked-1', similarity: 0.77 }, + ]); + }); }); diff --git a/src/services/memory-ingest.ts b/src/services/memory-ingest.ts index 029d086..bc3de0e 100644 --- a/src/services/memory-ingest.ts +++ b/src/services/memory-ingest.ts @@ -90,7 +90,10 @@ export async function performIngest( await timed('ingest.backdate', () => deps.repo.backdateMemories(acc.memoryIds, sessionTimestamp)); } - const linksCreated = await timed('ingest.links', () => generateLinks(deps.repo, userId, acc.memoryIds, acc.embeddingCache)); + const linksCreated = await timed( + 'ingest.links', + () => generateLinks(deps.repo, userId, acc.memoryIds, acc.embeddingCache, deps.config), + ); let compositesCreated = 0; if (config.compositeGroupingEnabled && storedFacts.length >= config.compositeMinClusterSize) { @@ -132,7 +135,10 @@ export async function performQuickIngest( await deps.repo.backdateMemories(acc.memoryIds, sessionTimestamp); } - const linksCreated = await timed('quick-ingest.links', () => generateLinks(deps.repo, userId, acc.memoryIds, acc.embeddingCache)); + const linksCreated = await timed( + 'quick-ingest.links', + () => generateLinks(deps.repo, userId, acc.memoryIds, acc.embeddingCache, deps.config), + ); console.log(`[timing] quick-ingest.total: ${(performance.now() - ingestStart).toFixed(1)}ms (${extractedFacts.length} facts, ${acc.counters.stored} stored, ${acc.counters.skipped} skipped)`); return buildIngestResult(episodeId, extractedFacts.length, acc, linksCreated, 0); } @@ -215,7 +221,10 @@ export async function performWorkspaceIngest( await timed('ws-ingest.backdate', () => deps.repo.backdateMemories(acc.memoryIds, sessionTimestamp)); } - const linksCreated = await timed('ws-ingest.links', () => generateLinks(deps.repo, userId, acc.memoryIds, acc.embeddingCache)); + const linksCreated = await timed( + 'ws-ingest.links', + () => generateLinks(deps.repo, userId, acc.memoryIds, acc.embeddingCache, deps.config), + ); console.log(`[timing] ws-ingest.total: ${(performance.now() - ingestStart).toFixed(1)}ms (${facts.length} facts, workspace=${workspace.workspaceId})`); return buildIngestResult(episodeId, facts.length, acc, linksCreated, 0); } diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 0f1d2ce..ed05a11 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -70,14 +70,10 @@ export interface SearchPipelineOptions { * Runtime-owned config threaded through all search-pipeline helpers. * When present, gates and thresholds across the entire retrieval path * (retrieval-policy, reranking, augmentation, expansion, MMR, link - * expansion, agentic/iterative retrieval) read from this instead of - * the static module-level config singleton. + * expansion, agentic/iterative retrieval, and ingest-time link generation) + * read from this instead of the static module-level config singleton. * * Falls back to the static config import if omitted (backward compat). - * - * Remaining deferred static reads: generateLinks (ingest path, not - * search path) still reads config.linkExpansionEnabled and - * config.linkSimilarityThreshold directly. */ runtimeConfig?: CoreRuntimeConfig; } @@ -918,8 +914,9 @@ export async function generateLinks( userId: string, memoryIds: string[], embeddingCache: Map, + runtimeConfig: Pick = config, ): Promise { - if (!config.linkExpansionEnabled || memoryIds.length === 0) return 0; + if (!runtimeConfig.linkExpansionEnabled || memoryIds.length === 0) return 0; const activeMemoryIds: string[] = []; for (const id of memoryIds) { @@ -935,7 +932,7 @@ export async function generateLinks( if (!embedding) continue; const candidates = await repo.findLinkCandidates( - userId, embedding, config.linkSimilarityThreshold, memoryId, + userId, embedding, runtimeConfig.linkSimilarityThreshold, memoryId, ); for (const candidate of candidates) { allLinks.push({ sourceId: memoryId, targetId: candidate.id, similarity: candidate.similarity }); From 75c670ed086f511b120658b1dca806e9f9e0dc04 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 22:44:32 -0700 Subject: [PATCH 40/59] refactor(service): allow explicit config injection Let MemoryService accept an optional runtime config for its delegated modules while preserving the singleton default. Add a focused unit test covering both the explicit override and current default behavior. --- .../__tests__/memory-service-config.test.ts | 105 ++++++++++++++++++ src/services/memory-service.ts | 3 +- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/services/__tests__/memory-service-config.test.ts diff --git a/src/services/__tests__/memory-service-config.test.ts b/src/services/__tests__/memory-service-config.test.ts new file mode 100644 index 0000000..f806baa --- /dev/null +++ b/src/services/__tests__/memory-service-config.test.ts @@ -0,0 +1,105 @@ +/** + * MemoryService config seam tests. + * + * Verifies that the service can thread an explicit runtime config into its + * delegated modules while preserving the current singleton default when no + * override is provided. + */ + +import { describe, expect, it, vi } from 'vitest'; + +const { mockPerformSearch } = vi.hoisted(() => ({ + mockPerformSearch: vi.fn(), +})); + +const moduleConfig = { + lessonsEnabled: true, + consensusValidationEnabled: true, + consensusMinMemories: 2, + auditLoggingEnabled: true, +}; + +vi.mock('../../config.js', () => ({ config: moduleConfig })); +vi.mock('../memory-ingest.js', () => ({ + performIngest: vi.fn(), + performQuickIngest: vi.fn(), + performStoreVerbatim: vi.fn(), + performWorkspaceIngest: vi.fn(), +})); +vi.mock('../memory-search.js', () => ({ + performSearch: mockPerformSearch, + performFastSearch: vi.fn(), + performWorkspaceSearch: vi.fn(), +})); +vi.mock('../memory-crud.js', () => ({})); +vi.mock('../atomicmem-uri.js', () => ({ + URIResolver: class { + resolve = vi.fn(); + format = vi.fn(); + }, +})); + +const { MemoryService } = await import('../memory-service.js'); + +describe('MemoryService config seam', () => { + it('threads an explicit runtime config into delegated search deps', async () => { + const runtimeConfig = { + lessonsEnabled: false, + consensusValidationEnabled: false, + consensusMinMemories: 5, + auditLoggingEnabled: false, + }; + mockPerformSearch.mockResolvedValue({ + memories: [], + injectionText: '', + citations: [], + retrievalMode: 'flat', + }); + const service = new MemoryService( + {} as any, + {} as any, + undefined, + undefined, + undefined, + runtimeConfig as any, + ); + + await service.search('user-1', 'config seam query'); + + expect(mockPerformSearch).toHaveBeenCalledWith( + expect.objectContaining({ config: runtimeConfig }), + 'user-1', + 'config seam query', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + }); + + it('defaults delegated search deps to the module config singleton', async () => { + mockPerformSearch.mockResolvedValue({ + memories: [], + injectionText: '', + citations: [], + retrievalMode: 'flat', + }); + const service = new MemoryService({} as any, {} as any); + + await service.search('user-1', 'default config query'); + + expect(mockPerformSearch).toHaveBeenCalledWith( + expect.objectContaining({ config: moduleConfig }), + 'user-1', + 'default config query', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + }); +}); diff --git a/src/services/memory-service.ts b/src/services/memory-service.ts index 0389cd5..188a347 100644 --- a/src/services/memory-service.ts +++ b/src/services/memory-service.ts @@ -37,9 +37,10 @@ export class MemoryService { entities?: EntityRepository, lessons?: LessonRepository, observationService?: ObservationService, + runtimeConfig?: MemoryServiceDeps['config'], ) { this.deps = { - config, + config: runtimeConfig ?? config, repo, claims, entities: entities ?? null, From f3c3e7660e8130790ef68f9a81cb3ece6de4c9c0 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 23:14:57 -0700 Subject: [PATCH 41/59] Test ingest runtime config forwarding into generateLinks --- .../memory-ingest-runtime-config.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/services/__tests__/memory-ingest-runtime-config.test.ts diff --git a/src/services/__tests__/memory-ingest-runtime-config.test.ts b/src/services/__tests__/memory-ingest-runtime-config.test.ts new file mode 100644 index 0000000..026cf8a --- /dev/null +++ b/src/services/__tests__/memory-ingest-runtime-config.test.ts @@ -0,0 +1,123 @@ +/** + * Runtime config seam tests for memory-ingest. + * + * Verifies that performQuickIngest forwards deps.config into generateLinks + * after accumulating stored memory IDs and embeddings. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGenerateLinks } = vi.hoisted(() => ({ + mockGenerateLinks: vi.fn(), +})); + +const moduleConfig = { + audnCandidateThreshold: 0.7, + fastAudnDuplicateThreshold: 0.95, +}; + +vi.mock('../../config.js', () => ({ config: moduleConfig })); +vi.mock('../search-pipeline.js', () => ({ generateLinks: mockGenerateLinks })); +vi.mock('../quick-extraction.js', () => ({ + quickExtractFacts: vi.fn(() => [ + { + fact: 'User prefers Rust', + headline: 'Prefers Rust', + importance: 0.8, + type: 'preference', + keywords: ['rust'], + entities: [], + relations: [], + }, + ]), +})); +vi.mock('../embedding.js', () => ({ + embedText: vi.fn().mockResolvedValue([0.1, 0.2]), +})); +vi.mock('../write-security.js', () => ({ + assessWriteSecurity: vi.fn(() => ({ + allowed: true, + trust: { score: 0.9 }, + })), + recordRejectedWrite: vi.fn(), +})); +vi.mock('../memory-storage.js', () => ({ + resolveDeterministicClaimSlot: vi.fn().mockResolvedValue(null), + findSlotConflictCandidates: vi.fn().mockResolvedValue([]), + storeCanonicalFact: vi.fn().mockResolvedValue({ outcome: 'stored', memoryId: 'memory-1' }), +})); +vi.mock('../conflict-policy.js', () => ({ + mergeCandidates: vi.fn((_vectorCandidates: unknown[], _slotCandidates: unknown[]) => []), + applyClarificationOverrides: vi.fn(), +})); +vi.mock('../timing.js', () => ({ + timed: vi.fn(async (_name: string, fn: () => unknown) => fn()), +})); +vi.mock('../consensus-extraction.js', () => ({ + consensusExtractFacts: vi.fn(), +})); +vi.mock('../extraction-cache.js', () => ({ + cachedResolveAUDN: vi.fn(), +})); +vi.mock('../memory-network.js', () => ({ + classifyNetwork: vi.fn(), +})); +vi.mock('../namespace-retrieval.js', () => ({ + inferNamespace: vi.fn(), + deriveMajorityNamespace: vi.fn(), +})); +vi.mock('../entropy-gate.js', () => ({ + computeEntropyScore: vi.fn(), +})); +vi.mock('../composite-grouping.js', () => ({ + buildComposites: vi.fn(), +})); +vi.mock('../memory-audn.js', () => ({ + findFilteredCandidates: vi.fn(), + resolveAndExecuteAudn: vi.fn(), +})); + +const { performQuickIngest } = await import('../memory-ingest.js'); + +describe('performQuickIngest runtime config seam', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateLinks.mockResolvedValue(1); + }); + + it('passes deps.config into generateLinks', async () => { + const runtimeConfig = { + linkExpansionEnabled: true, + linkSimilarityThreshold: 0.42, + }; + const repo = { + storeEpisode: vi.fn().mockResolvedValue('episode-1'), + findNearDuplicates: vi.fn().mockResolvedValue([]), + }; + const deps = { + config: runtimeConfig, + repo, + claims: {}, + entities: null, + lessons: null, + observationService: null, + uriResolver: {}, + } as any; + + const result = await performQuickIngest( + deps, + 'user-1', + 'User: I prefer Rust', + 'chat', + ); + + expect(result.linksCreated).toBe(1); + expect(mockGenerateLinks).toHaveBeenCalledWith( + repo, + 'user-1', + ['memory-1'], + new Map([['memory-1', [0.1, 0.2]]]), + runtimeConfig, + ); + }); +}); From 8953420420c69d157cb386c6267f7b623b570d63 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 23:15:04 -0700 Subject: [PATCH 42/59] test(service): prove explicit config reaches ingest path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one test in memory-service-config.test.ts asserting that a MemoryService constructed with an explicit runtimeConfig threads that config into performIngest via deps.config. This closes the search→ingest confidence gap: the existing tests proved the override reaches performSearch (75c670e), and 19b985f proved performIngest threads deps.config into generateLinks. This new test proves the middle link — MemoryService passes the explicit config into the ingest deps, not just the search deps. Reuses the existing mock scaffolding: promotes performIngest to a hoisted mock (mockPerformIngest) so the test can assert against it, matching the existing mockPerformSearch pattern. No source changes. --- .../__tests__/memory-service-config.test.ts | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/services/__tests__/memory-service-config.test.ts b/src/services/__tests__/memory-service-config.test.ts index f806baa..60c31f0 100644 --- a/src/services/__tests__/memory-service-config.test.ts +++ b/src/services/__tests__/memory-service-config.test.ts @@ -8,8 +8,9 @@ import { describe, expect, it, vi } from 'vitest'; -const { mockPerformSearch } = vi.hoisted(() => ({ +const { mockPerformSearch, mockPerformIngest } = vi.hoisted(() => ({ mockPerformSearch: vi.fn(), + mockPerformIngest: vi.fn(), })); const moduleConfig = { @@ -21,7 +22,7 @@ const moduleConfig = { vi.mock('../../config.js', () => ({ config: moduleConfig })); vi.mock('../memory-ingest.js', () => ({ - performIngest: vi.fn(), + performIngest: mockPerformIngest, performQuickIngest: vi.fn(), performStoreVerbatim: vi.fn(), performWorkspaceIngest: vi.fn(), @@ -79,6 +80,42 @@ describe('MemoryService config seam', () => { ); }); + it('threads an explicit runtime config into delegated ingest deps', async () => { + const runtimeConfig = { + lessonsEnabled: false, + consensusValidationEnabled: false, + consensusMinMemories: 5, + auditLoggingEnabled: false, + }; + mockPerformIngest.mockResolvedValue({ + episodeId: 'ep-1', + factsExtracted: 0, + stored: 0, + skipped: 0, + linksCreated: 0, + compositesCreated: 0, + }); + const service = new MemoryService( + {} as any, + {} as any, + undefined, + undefined, + undefined, + runtimeConfig as any, + ); + + await service.ingest('user-1', 'text', 'site'); + + expect(mockPerformIngest).toHaveBeenCalledWith( + expect.objectContaining({ config: runtimeConfig }), + 'user-1', + 'text', + 'site', + '', + undefined, + ); + }); + it('defaults delegated search deps to the module config singleton', async () => { mockPerformSearch.mockResolvedValue({ memories: [], From 5e36d7ec19a8cdd1a3b76966990dc9438a35808a Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 23:15:35 -0700 Subject: [PATCH 43/59] refactor(runtime): narrow CoreRuntimeConfig interface Replace the broad CoreRuntimeConfig alias with an explicit interface covering the runtime-container, startup-check, search/runtime, and MemoryService config seams already threaded today. Keep the contract honest without widening route behavior or claiming full runtime-wide config injection. --- src/app/runtime-container.ts | 51 ++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index ec2f2bd..0e18de5 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -11,19 +11,23 @@ */ import pg from 'pg'; -import { config, updateRuntimeConfig } from '../config.js'; +import { config, updateRuntimeConfig, type CrossEncoderDtype } from '../config.js'; import { AgentTrustRepository } from '../db/agent-trust-repository.js'; import { ClaimRepository } from '../db/claim-repository.js'; import { LinkRepository } from '../db/link-repository.js'; import { MemoryRepository } from '../db/memory-repository.js'; import { EntityRepository } from '../db/repository-entities.js'; import { LessonRepository } from '../db/repository-lessons.js'; +import type { RetrievalProfile } from '../services/retrieval-profiles.js'; import { MemoryService } from '../services/memory-service.js'; /** - * Public runtime configuration subset. Phase 1A exposes the full config - * object for compatibility; later phases will split public runtime config - * from internal policy flags. + * Explicit runtime configuration subset currently needed by the runtime + * container, startup checks, search/runtime seams, and MemoryService deps. + * + * This is intentionally narrower than the module-level config singleton: + * it describes the config surface already threaded through those seams + * today, without claiming full runtime-wide configurability yet. * * NOTE (phase 1a.5): `runtime.config` currently references the same * module-level singleton that routes, services, and the search pipeline @@ -33,7 +37,44 @@ import { MemoryService } from '../services/memory-service.js'; * routes/, services/) reads the module singleton regardless. Phase 1B * will thread config through properly and reintroduce a genuine override. */ -export type CoreRuntimeConfig = typeof config; +export interface CoreRuntimeConfig { + adaptiveRetrievalEnabled: boolean; + agenticRetrievalEnabled: boolean; + auditLoggingEnabled: boolean; + consensusMinMemories: number; + consensusValidationEnabled: boolean; + crossEncoderDtype: CrossEncoderDtype; + crossEncoderEnabled: boolean; + crossEncoderModel: string; + embeddingDimensions: number; + entityGraphEnabled: boolean; + entitySearchMinSimilarity: number; + hybridSearchEnabled: boolean; + iterativeRetrievalEnabled: boolean; + lessonsEnabled: boolean; + linkExpansionBeforeMMR: boolean; + linkExpansionEnabled: boolean; + linkExpansionMax: number; + linkSimilarityThreshold: number; + maxSearchResults: number; + mmrEnabled: boolean; + mmrLambda: number; + pprDamping: number; + pprEnabled: boolean; + port: number; + queryAugmentationEnabled: boolean; + queryAugmentationMaxEntities: number; + queryAugmentationMinSimilarity: number; + queryExpansionEnabled: boolean; + queryExpansionMinSimilarity: number; + repairConfidenceFloor: number; + repairDeltaThreshold: number; + repairLoopEnabled: boolean; + repairLoopMinSimilarity: number; + rerankSkipMinGap: number; + rerankSkipTopSimilarity: number; + retrievalProfileSettings: RetrievalProfile; +} /** Repositories constructed by the runtime container. */ export interface CoreRuntimeRepos { From 31fe6f46003e85e80d7b8e1011c824477577a801 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 23:40:09 -0700 Subject: [PATCH 44/59] Test quick-ingest config forwarding in MemoryService seam --- .../__tests__/memory-service-config.test.ts | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/services/__tests__/memory-service-config.test.ts b/src/services/__tests__/memory-service-config.test.ts index 60c31f0..e94ae3e 100644 --- a/src/services/__tests__/memory-service-config.test.ts +++ b/src/services/__tests__/memory-service-config.test.ts @@ -8,9 +8,10 @@ import { describe, expect, it, vi } from 'vitest'; -const { mockPerformSearch, mockPerformIngest } = vi.hoisted(() => ({ +const { mockPerformSearch, mockPerformIngest, mockPerformQuickIngest } = vi.hoisted(() => ({ mockPerformSearch: vi.fn(), mockPerformIngest: vi.fn(), + mockPerformQuickIngest: vi.fn(), })); const moduleConfig = { @@ -23,7 +24,7 @@ const moduleConfig = { vi.mock('../../config.js', () => ({ config: moduleConfig })); vi.mock('../memory-ingest.js', () => ({ performIngest: mockPerformIngest, - performQuickIngest: vi.fn(), + performQuickIngest: mockPerformQuickIngest, performStoreVerbatim: vi.fn(), performWorkspaceIngest: vi.fn(), })); @@ -116,6 +117,42 @@ describe('MemoryService config seam', () => { ); }); + it('threads an explicit runtime config into delegated quick-ingest deps', async () => { + const runtimeConfig = { + lessonsEnabled: false, + consensusValidationEnabled: false, + consensusMinMemories: 5, + auditLoggingEnabled: false, + }; + mockPerformQuickIngest.mockResolvedValue({ + episodeId: 'ep-1', + factsExtracted: 0, + stored: 0, + skipped: 0, + linksCreated: 0, + compositesCreated: 0, + }); + const service = new MemoryService( + {} as any, + {} as any, + undefined, + undefined, + undefined, + runtimeConfig as any, + ); + + await service.quickIngest('user-1', 'text', 'site'); + + expect(mockPerformQuickIngest).toHaveBeenCalledWith( + expect.objectContaining({ config: runtimeConfig }), + 'user-1', + 'text', + 'site', + '', + undefined, + ); + }); + it('defaults delegated search deps to the module config singleton', async () => { mockPerformSearch.mockResolvedValue({ memories: [], From cd6e8ceb5f47b68f75295b32fa2aadf543b57a33 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 23:41:04 -0700 Subject: [PATCH 45/59] refactor(search): narrow runtime config seam types Replace broad CoreRuntimeConfig usage in the already-threaded search runtime seam with narrower local config contracts for search-pipeline and agentic-retrieval. Preserve behavior and keep the slice type-only. --- src/services/agentic-retrieval.ts | 9 ++++- src/services/search-pipeline.ts | 64 +++++++++++++++++++++++-------- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/services/agentic-retrieval.ts b/src/services/agentic-retrieval.ts index 18c24c2..ca0f341 100644 --- a/src/services/agentic-retrieval.ts +++ b/src/services/agentic-retrieval.ts @@ -46,6 +46,11 @@ interface SufficiencyResult { subQueries: string[]; } +type AgenticRetrievalRuntimeConfig = Pick< + CoreRuntimeConfig, + 'hybridSearchEnabled' | 'retrievalProfileSettings' | 'maxSearchResults' +>; + /** * Check if retrieved memories are sufficient and decompose if not. * Returns null if sufficient (no additional retrieval needed). @@ -95,7 +100,7 @@ async function retrieveSubQueries( userId: string, subQueries: string[], candidateDepth: number, - policyConfig: CoreRuntimeConfig, + policyConfig: AgenticRetrievalRuntimeConfig, sourceSite?: string, referenceTime?: Date, ): Promise { @@ -141,7 +146,7 @@ export async function applyAgenticRetrieval( candidateDepth: number, sourceSite?: string, referenceTime?: Date, - policyConfig: CoreRuntimeConfig = config, + policyConfig: AgenticRetrievalRuntimeConfig = config, ): Promise { // Quick gate: skip for queries that already have strong results if (initialResults.length >= 3 && initialResults[0].similarity >= 0.85) { diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index ed05a11..1fe895d 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -40,6 +40,40 @@ const TEMPORAL_NEIGHBOR_WINDOW_MINUTES = 30; const SEMANTIC_RRF_WEIGHT = 1.2; const ENTITY_RRF_WEIGHT = 1.3; const KEYWORD_RRF_WEIGHT = 1.0; + +export type SearchPipelineRuntimeConfig = Pick< + CoreRuntimeConfig, + | 'adaptiveRetrievalEnabled' + | 'agenticRetrievalEnabled' + | 'crossEncoderDtype' + | 'crossEncoderEnabled' + | 'crossEncoderModel' + | 'entityGraphEnabled' + | 'entitySearchMinSimilarity' + | 'hybridSearchEnabled' + | 'iterativeRetrievalEnabled' + | 'linkExpansionBeforeMMR' + | 'linkExpansionEnabled' + | 'linkExpansionMax' + | 'linkSimilarityThreshold' + | 'maxSearchResults' + | 'mmrEnabled' + | 'mmrLambda' + | 'pprDamping' + | 'pprEnabled' + | 'queryAugmentationEnabled' + | 'queryAugmentationMaxEntities' + | 'queryAugmentationMinSimilarity' + | 'queryExpansionEnabled' + | 'queryExpansionMinSimilarity' + | 'repairConfidenceFloor' + | 'repairDeltaThreshold' + | 'repairLoopEnabled' + | 'repairLoopMinSimilarity' + | 'rerankSkipMinGap' + | 'rerankSkipTopSimilarity' + | 'retrievalProfileSettings' +>; /** * Decide whether to auto-skip cross-encoder reranking. * Skip when the top vector result is high-confidence and well-separated @@ -49,7 +83,7 @@ const KEYWORD_RRF_WEIGHT = 1.0; */ function shouldAutoSkipReranking( results: SearchResult[], - policyConfig: Pick = config, + policyConfig: Pick = config, ): boolean { if (results.length < 2) return true; const topSim = results[0]?.score ?? 0; @@ -75,7 +109,7 @@ export interface SearchPipelineOptions { * * Falls back to the static config import if omitted (backward compat). */ - runtimeConfig?: CoreRuntimeConfig; + runtimeConfig?: SearchPipelineRuntimeConfig; } /** @@ -92,7 +126,7 @@ export async function runSearchPipelineWithTrace( options: SearchPipelineOptions = {}, ): Promise<{ filtered: SearchResult[]; trace: TraceCollector }> { const trace = new TraceCollector(query, userId); - const policyConfig = options.runtimeConfig ?? config; + const policyConfig: SearchPipelineRuntimeConfig = options.runtimeConfig ?? config; const mmrPoolMultiplier = policyConfig.mmrEnabled ? 3 : 1; const candidateDepth = resolveRerankDepth(limit, policyConfig) * mmrPoolMultiplier; @@ -244,7 +278,7 @@ async function runInitialRetrieval( sourceSite?: string, referenceTime?: Date, searchStrategy: SearchStrategy = 'memory', - policyConfig: CoreRuntimeConfig = config, + policyConfig: SearchPipelineRuntimeConfig = config, ): Promise { if (searchStrategy === 'fact-hybrid') { return repo.searchAtomicFactsHybrid( @@ -284,7 +318,7 @@ async function maybeApplyAbstractHybridFallback( searchStrategy: SearchStrategy | undefined, initialResults: SearchResult[], trace: TraceCollector, - policyConfig: CoreRuntimeConfig = config, + policyConfig: SearchPipelineRuntimeConfig = config, ): Promise { if (searchStrategy === 'fact-hybrid') return initialResults; if (policyConfig.hybridSearchEnabled || policyConfig.entityGraphEnabled) return initialResults; @@ -321,7 +355,7 @@ async function applyRepairLoop( sourceSite: string | undefined, referenceTime: Date | undefined, trace: TraceCollector, - policyConfig: CoreRuntimeConfig, + policyConfig: SearchPipelineRuntimeConfig, searchStrategy: SearchStrategy = 'memory', protectedIds: string[] = [], ): Promise<{ memories: SearchResult[]; queryText: string }> { @@ -396,7 +430,7 @@ async function applyQueryExpansion( initialResults: SearchResult[], candidateDepth: number, trace: TraceCollector, - policyConfig: CoreRuntimeConfig = config, + policyConfig: SearchPipelineRuntimeConfig = config, ): Promise { if (!policyConfig.queryExpansionEnabled || !policyConfig.entityGraphEnabled || !entityRepo) { return initialResults; @@ -438,7 +472,7 @@ async function applyQueryAugmentation( query: string, queryEmbedding: number[], trace: TraceCollector, - policyConfig: CoreRuntimeConfig = config, + policyConfig: SearchPipelineRuntimeConfig = config, ): Promise<{ searchQuery: string; augmentedEmbedding: number[] }> { if (!policyConfig.queryAugmentationEnabled || !policyConfig.entityGraphEnabled || !entityRepo) { return { searchQuery: query, augmentedEmbedding: queryEmbedding }; @@ -480,7 +514,7 @@ async function applyEntityNameCoRetrieval( initialResults: SearchResult[], candidateDepth: number, trace: TraceCollector, - policyConfig: CoreRuntimeConfig = config, + policyConfig: SearchPipelineRuntimeConfig = config, ): Promise { if (!policyConfig.entityGraphEnabled || !entityRepo) return initialResults; @@ -645,7 +679,7 @@ async function applyExpansionAndReranking( temporalAnchorFingerprints: string[], trace: TraceCollector, skipReranking?: boolean, - policyConfig: CoreRuntimeConfig = config, + policyConfig: SearchPipelineRuntimeConfig = config, ): Promise { // Cross-encoder reranking: re-score candidates before MMR let candidates = results; @@ -749,7 +783,7 @@ async function expandWithLinks( results: SearchResult[], queryEmbedding: number[], referenceTime?: Date, - policyConfig: CoreRuntimeConfig = config, + policyConfig: SearchPipelineRuntimeConfig = config, ): Promise { if (!policyConfig.linkExpansionEnabled || policyConfig.linkExpansionMax <= 0) return results; @@ -806,7 +840,7 @@ async function runMemoryRrfRetrieval( sourceSite: string | undefined, referenceTime: Date | undefined, includeKeywordChannel: boolean, - policyConfig: CoreRuntimeConfig = config, + policyConfig: SearchPipelineRuntimeConfig = config, ): Promise { const semanticResults = await repo.searchSimilar( userId, @@ -856,7 +890,7 @@ async function expandViaPPR( results: SearchResult[], excludeIds: Set, budget: number, - policyConfig: Pick = config, + policyConfig: Pick = config, ): Promise { const seedScores = new Map(); for (const r of results) { @@ -914,7 +948,7 @@ export async function generateLinks( userId: string, memoryIds: string[], embeddingCache: Map, - runtimeConfig: Pick = config, + runtimeConfig: Pick = config, ): Promise { if (!runtimeConfig.linkExpansionEnabled || memoryIds.length === 0) return 0; @@ -954,7 +988,7 @@ async function expandViaEntities( queryEmbedding: number[], excludeIds: Set, budget: number, - policyConfig: Pick = config, + policyConfig: Pick = config, ): Promise { if (!policyConfig.entityGraphEnabled || !entityRepo) return []; From bab4d6c0233265f0072e4dce3fd564a5c7589d62 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 00:17:54 -0700 Subject: [PATCH 46/59] refactor(runtime): pass config explicitly into MemoryService Keep the composition root honest by having createCoreRuntime pass the module-level config singleton explicitly into MemoryService instead of relying on the service constructor's fallback default. --- src/app/runtime-container.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index 0e18de5..7751209 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -133,8 +133,9 @@ export interface CoreRuntime { /** * Compose the core runtime. Instantiates repositories and the memory * service from an explicit pool. Reads the module-level config singleton - * for repo-construction flags so there is a single source of truth - * between the container and the rest of the codebase. No mutation. + * for repo-construction flags and passes that same singleton explicitly + * into MemoryService so the composition root owns the config seam. + * No mutation. */ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime { const { pool } = deps; @@ -151,6 +152,8 @@ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime { claims, entities ?? undefined, lessons ?? undefined, + undefined, + config, ); return { From 246654bc08acac81149fc10f9aabfa8ee34e92b9 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 00:18:33 -0700 Subject: [PATCH 47/59] Test workspace-ingest config forwarding in MemoryService seam --- .../__tests__/memory-service-config.test.ts | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/src/services/__tests__/memory-service-config.test.ts b/src/services/__tests__/memory-service-config.test.ts index e94ae3e..84d34fb 100644 --- a/src/services/__tests__/memory-service-config.test.ts +++ b/src/services/__tests__/memory-service-config.test.ts @@ -8,10 +8,16 @@ import { describe, expect, it, vi } from 'vitest'; -const { mockPerformSearch, mockPerformIngest, mockPerformQuickIngest } = vi.hoisted(() => ({ +const { + mockPerformSearch, + mockPerformIngest, + mockPerformQuickIngest, + mockPerformWorkspaceIngest, +} = vi.hoisted(() => ({ mockPerformSearch: vi.fn(), mockPerformIngest: vi.fn(), mockPerformQuickIngest: vi.fn(), + mockPerformWorkspaceIngest: vi.fn(), })); const moduleConfig = { @@ -26,7 +32,7 @@ vi.mock('../memory-ingest.js', () => ({ performIngest: mockPerformIngest, performQuickIngest: mockPerformQuickIngest, performStoreVerbatim: vi.fn(), - performWorkspaceIngest: vi.fn(), + performWorkspaceIngest: mockPerformWorkspaceIngest, })); vi.mock('../memory-search.js', () => ({ performSearch: mockPerformSearch, @@ -153,6 +159,48 @@ describe('MemoryService config seam', () => { ); }); + it('threads an explicit runtime config into delegated workspace-ingest deps', async () => { + const runtimeConfig = { + lessonsEnabled: false, + consensusValidationEnabled: false, + consensusMinMemories: 5, + auditLoggingEnabled: false, + }; + const workspace = { + workspaceId: 'ws-1', + agentId: 'agent-1', + visibility: 'workspace', + }; + mockPerformWorkspaceIngest.mockResolvedValue({ + episodeId: 'ep-1', + factsExtracted: 0, + stored: 0, + skipped: 0, + linksCreated: 0, + compositesCreated: 0, + }); + const service = new MemoryService( + {} as any, + {} as any, + undefined, + undefined, + undefined, + runtimeConfig as any, + ); + + await service.workspaceIngest('user-1', 'text', 'site', '', workspace as any); + + expect(mockPerformWorkspaceIngest).toHaveBeenCalledWith( + expect.objectContaining({ config: runtimeConfig }), + 'user-1', + 'text', + 'site', + '', + workspace, + undefined, + ); + }); + it('defaults delegated search deps to the module config singleton', async () => { mockPerformSearch.mockResolvedValue({ memories: [], From 2e63c3843527397c1d667559c32f77ea808f9449 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 00:53:58 -0700 Subject: [PATCH 48/59] refactor(ingest): thread explicit ingest config seam --- .../memory-ingest-runtime-config.test.ts | 44 ++++++++++++++- .../memory-lineage-runtime-config.test.ts | 55 +++++++++++++++++++ src/services/memory-audn.ts | 28 +++++----- src/services/memory-ingest.ts | 6 +- src/services/memory-lineage.ts | 23 +++++--- src/services/memory-service-types.ts | 13 ++++- src/services/memory-storage.ts | 10 ++-- 7 files changed, 148 insertions(+), 31 deletions(-) create mode 100644 src/services/__tests__/memory-lineage-runtime-config.test.ts diff --git a/src/services/__tests__/memory-ingest-runtime-config.test.ts b/src/services/__tests__/memory-ingest-runtime-config.test.ts index 026cf8a..3623b6a 100644 --- a/src/services/__tests__/memory-ingest-runtime-config.test.ts +++ b/src/services/__tests__/memory-ingest-runtime-config.test.ts @@ -10,9 +10,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { mockGenerateLinks } = vi.hoisted(() => ({ mockGenerateLinks: vi.fn(), })); +const { mockStoreCanonicalFact } = vi.hoisted(() => ({ + mockStoreCanonicalFact: vi.fn(), +})); const moduleConfig = { audnCandidateThreshold: 0.7, + fastAudnEnabled: false, fastAudnDuplicateThreshold: 0.95, }; @@ -44,10 +48,13 @@ vi.mock('../write-security.js', () => ({ vi.mock('../memory-storage.js', () => ({ resolveDeterministicClaimSlot: vi.fn().mockResolvedValue(null), findSlotConflictCandidates: vi.fn().mockResolvedValue([]), - storeCanonicalFact: vi.fn().mockResolvedValue({ outcome: 'stored', memoryId: 'memory-1' }), + storeCanonicalFact: mockStoreCanonicalFact, })); vi.mock('../conflict-policy.js', () => ({ - mergeCandidates: vi.fn((_vectorCandidates: unknown[], _slotCandidates: unknown[]) => []), + mergeCandidates: vi.fn((vectorCandidates: unknown[], slotCandidates: unknown[]) => [ + ...vectorCandidates, + ...slotCandidates, + ]), applyClarificationOverrides: vi.fn(), })); vi.mock('../timing.js', () => ({ @@ -83,6 +90,7 @@ describe('performQuickIngest runtime config seam', () => { beforeEach(() => { vi.clearAllMocks(); mockGenerateLinks.mockResolvedValue(1); + mockStoreCanonicalFact.mockResolvedValue({ outcome: 'stored', memoryId: 'memory-1' }); }); it('passes deps.config into generateLinks', async () => { @@ -120,4 +128,36 @@ describe('performQuickIngest runtime config seam', () => { runtimeConfig, ); }); + + it('uses deps.config for duplicate thresholds in quick ingest', async () => { + const runtimeConfig = { + audnCandidateThreshold: 0.42, + fastAudnEnabled: true, + fastAudnDuplicateThreshold: 0.83, + linkExpansionEnabled: false, + linkSimilarityThreshold: 0.5, + }; + const repo = { + storeEpisode: vi.fn().mockResolvedValue('episode-1'), + findNearDuplicates: vi.fn().mockResolvedValue([ + { id: 'existing-1', content: 'User prefers Rust', similarity: 0.9, importance: 0.8 }, + ]), + }; + const deps = { + config: runtimeConfig, + repo, + claims: {}, + entities: null, + lessons: null, + observationService: null, + uriResolver: {}, + } as any; + + const result = await performQuickIngest(deps, 'user-1', 'User: I prefer Rust', 'chat'); + + expect(repo.findNearDuplicates).toHaveBeenCalledWith('user-1', [0.1, 0.2], 0.42); + expect(result.memoriesSkipped).toBe(1); + expect(result.memoryIds).toEqual(['existing-1']); + expect(mockStoreCanonicalFact).not.toHaveBeenCalled(); + }); }); diff --git a/src/services/__tests__/memory-lineage-runtime-config.test.ts b/src/services/__tests__/memory-lineage-runtime-config.test.ts new file mode 100644 index 0000000..14ac6da --- /dev/null +++ b/src/services/__tests__/memory-lineage-runtime-config.test.ts @@ -0,0 +1,55 @@ +/** + * Runtime config seam tests for memory-lineage. + * + * Verifies that ingest-side lineage emission uses the explicit runtime + * llmModel when provided, instead of silently pinning actor_model to the + * module singleton. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { emitLineageEvent } from '../memory-lineage.js'; + +describe('memory-lineage runtime config seam', () => { + it('uses the explicit llmModel for canonical add provenance', async () => { + const claims = { + createClaim: vi.fn().mockResolvedValue('claim-1'), + createClaimVersion: vi.fn().mockResolvedValue('version-1'), + setClaimCurrentVersion: vi.fn().mockResolvedValue(undefined), + addEvidence: vi.fn().mockResolvedValue(undefined), + createUpdateVersion: vi.fn(), + supersedeClaimVersion: vi.fn(), + invalidateClaim: vi.fn(), + }; + const repo = { + storeCanonicalMemoryObject: vi.fn().mockResolvedValue('cmo-1'), + }; + + await emitLineageEvent({ claims, repo, config: { llmModel: 'runtime-llm' } }, { + kind: 'canonical-add', + userId: 'user-1', + fact: { + fact: 'User prefers Rust.', + headline: 'Prefers Rust', + importance: 0.9, + type: 'preference', + keywords: ['rust'], + entities: [], + relations: [], + }, + embedding: [0.1, 0.2], + sourceSite: 'chat', + sourceUrl: 'https://source/test', + episodeId: 'episode-1', + logicalTimestamp: undefined, + claimSlot: null, + createProjection: vi.fn().mockResolvedValue('memory-1'), + }); + + expect(claims.createClaimVersion).toHaveBeenCalledWith( + expect.objectContaining({ + provenance: expect.objectContaining({ actorModel: 'runtime-llm' }), + }), + ); + }); +}); diff --git a/src/services/memory-audn.ts b/src/services/memory-audn.ts index a03e773..306aed6 100644 --- a/src/services/memory-audn.ts +++ b/src/services/memory-audn.ts @@ -4,7 +4,6 @@ * and the full mutation pipeline (update, supersede, delete canonical facts). */ -import { config } from '../config.js'; import { type ClaimSlotInput } from '../db/claim-repository.js'; import { embedText } from './embedding.js'; import { type AUDNDecision } from './extraction.js'; @@ -66,7 +65,7 @@ export async function resolveAndExecuteAudn( const candidateIds = new Set(filteredCandidates.map((c) => c.id)); const ctx: AudnFactContext = { userId, fact, embedding, sourceSite, sourceUrl, episodeId, trustScore, claimSlot, logicalTimestamp }; - const fastDecision = tryFastAUDN(fact.fact, filteredCandidates); + const fastDecision = tryFastAUDN(fact.fact, filteredCandidates, deps.config); if (fastDecision) { return executeAndTrackSupersede(deps, fastDecision, candidateIds, ctx, supersededTargets); } @@ -82,7 +81,7 @@ export async function resolveAndExecuteAudn( const rawDecision = await timed('ingest.fact.audn', () => cachedResolveAUDN(fact.fact, filteredCandidates)); let decision = applyClarificationOverrides(rawDecision, fact.fact, filteredCandidates, fact.keywords, fact.type); - if (config.entityGraphEnabled && deps.entities) { + if (deps.config.entityGraphEnabled && deps.entities) { decision = await applyEntityScopedDedup(deps, decision, userId, fact.entities); } return executeAndTrackSupersede(deps, decision, candidateIds, ctx, supersededTargets); @@ -222,7 +221,7 @@ async function updateCanonicalFact( metadata: entry.metadata, validFrom: entry.validFrom, validTo: entry.validTo, })), ); - const lineage = await emitLineageEvent({ claims: deps.claims, repo: deps.repo }, { + const lineage = await emitLineageEvent({ claims: deps.claims, repo: deps.repo, config: deps.config }, { kind: 'canonical-update', userId, fact, @@ -259,7 +258,7 @@ async function supersedeCanonicalFact( await deps.repo.expireMemory(userId, target.memoryId); const newMemoryId = await storeProjection(deps, userId, fact, embedding, sourceSite, sourceUrl, episodeId, trustScore ?? 1.0); if (!newMemoryId) return { outcome: 'skipped', memoryId: null }; - const lineage = await emitLineageEvent({ claims: deps.claims, repo: deps.repo }, { + const lineage = await emitLineageEvent({ claims: deps.claims, repo: deps.repo, config: deps.config }, { kind: 'canonical-supersede', userId, fact, @@ -276,7 +275,7 @@ async function supersedeCanonicalFact( throw new Error(`AUDN SUPERSEDE failed: missing successor canonical object for "${target.memoryId}"`); } await deps.repo.updateMemoryMetadata(userId, newMemoryId, { cmo_id: lineage.cmoId }); - if (config.lessonsEnabled && deps.lessons && contradictionConfidence) { + if (deps.config.lessonsEnabled && deps.lessons && contradictionConfidence) { recordContradictionLesson(deps.lessons, { userId, content: fact.fact, sourceSite, contradictionConfidence, supersededMemoryId: target.memoryId, @@ -301,7 +300,7 @@ async function deleteCanonicalFact( const targetMemory = await deps.repo.getMemoryIncludingDeleted(target.memoryId, userId); if (!targetMemory) return { outcome: 'skipped', memoryId: null }; await deps.repo.softDeleteMemory(userId, target.memoryId); - await emitLineageEvent({ claims: deps.claims, repo: deps.repo }, { + await emitLineageEvent({ claims: deps.claims, repo: deps.repo, config: deps.config }, { kind: 'canonical-delete', userId, fact, @@ -313,7 +312,7 @@ async function deleteCanonicalFact( targetEmbedding: targetMemory.embedding, contradictionConfidence, }); - if (config.auditLoggingEnabled) { + if (deps.config.auditLoggingEnabled) { emitAuditEvent('memory:delete', userId, { reason: 'audn-delete', targetMemoryId: target.memoryId, contradictionConfidence, }, { memoryId: target.memoryId }); @@ -363,8 +362,12 @@ function extractQuotedLiterals(text: string): string[] { * sim >= 0.95: near-duplicate -> NOOP (skip storing). * Returns null when the case is ambiguous and needs full LLM AUDN. */ -function tryFastAUDN(factText: string, candidates: CandidateMemory[]): AUDNDecision | null { - if (!config.fastAudnEnabled) return null; +function tryFastAUDN( + factText: string, + candidates: CandidateMemory[], + runtimeConfig: Pick, +): AUDNDecision | null { + if (!runtimeConfig.fastAudnEnabled) return null; const topCandidate = candidates.reduce( (best, c) => (c.similarity > best.similarity ? c : best), @@ -375,8 +378,8 @@ function tryFastAUDN(factText: string, candidates: CandidateMemory[]): AUDNDecis return null; } - if (topCandidate.similarity >= config.fastAudnDuplicateThreshold) { - console.log(`[fast-audn] NOOP: sim=${topCandidate.similarity.toFixed(4)} >= ${config.fastAudnDuplicateThreshold} (near-duplicate of ${topCandidate.id})`); + if (topCandidate.similarity >= runtimeConfig.fastAudnDuplicateThreshold) { + console.log(`[fast-audn] NOOP: sim=${topCandidate.similarity.toFixed(4)} >= ${runtimeConfig.fastAudnDuplicateThreshold} (near-duplicate of ${topCandidate.id})`); return { action: 'NOOP', targetMemoryId: topCandidate.id, @@ -387,4 +390,3 @@ function tryFastAUDN(factText: string, candidates: CandidateMemory[]): AUDNDecis return null; } - diff --git a/src/services/memory-ingest.ts b/src/services/memory-ingest.ts index bc3de0e..daa6bd8 100644 --- a/src/services/memory-ingest.ts +++ b/src/services/memory-ingest.ts @@ -245,14 +245,14 @@ async function quickIngestFact( const claimSlot = await resolveDeterministicClaimSlot(deps, userId, fact); const [vectorCandidates, slotCandidates] = await timed('quick-ingest.fact.find-dupes', async () => Promise.all([ - deps.repo.findNearDuplicates(userId, embedding, config.audnCandidateThreshold), + deps.repo.findNearDuplicates(userId, embedding, deps.config.audnCandidateThreshold), findSlotConflictCandidates(deps, userId, claimSlot), ])); const candidates = mergeCandidates(vectorCandidates, slotCandidates); if (candidates.length > 0) { const topCandidate = candidates.reduce((a, b) => a.similarity > b.similarity ? a : b); - if (topCandidate.similarity >= config.fastAudnDuplicateThreshold) { + if (topCandidate.similarity >= deps.config.fastAudnDuplicateThreshold) { // Near-duplicate: skip but return the existing memory ID so callers // can link to the canonical memory (e.g. integration sync pointer rows). return { outcome: 'skipped', memoryId: topCandidate.id }; @@ -327,7 +327,7 @@ async function workspaceIngestFact( const networkResult = classifyNetwork(fact as any); const candidates = await deps.repo.findNearDuplicatesInWorkspace( - workspace.workspaceId, embedding, config.audnCandidateThreshold, 10, 'all', workspace.agentId, + workspace.workspaceId, embedding, deps.config.audnCandidateThreshold, 10, 'all', workspace.agentId, ); if (candidates.length === 0) { diff --git a/src/services/memory-lineage.ts b/src/services/memory-lineage.ts index 172e32c..8ed0c31 100644 --- a/src/services/memory-lineage.ts +++ b/src/services/memory-lineage.ts @@ -16,6 +16,7 @@ import { config } from '../config.js'; import type { ClaimSlotInput } from '../db/claim-repository.js'; +import type { IngestRuntimeConfig } from './memory-service-types.js'; import type { ClaimTarget, FactInput } from './memory-service-types.js'; type MutationType = 'add' | 'update' | 'supersede' | 'delete'; @@ -83,7 +84,11 @@ type LineageClaimsPort = { invalidateClaim(userId: string, claimId: string, invalidAt?: Date, invalidatedByVersionId?: string | null, status?: string): Promise; }; -type LineageDeps = { claims: LineageClaimsPort; repo?: MutationCanonicalObjectRepo }; +type LineageDeps = { + claims: LineageClaimsPort; + repo?: MutationCanonicalObjectRepo; + config?: Pick; +}; type BackfillMemory = { id: string; @@ -169,7 +174,7 @@ async function emitCanonicalAdd( sourceUrl: event.sourceUrl, episodeId: event.episodeId, validFrom: event.logicalTimestamp, - provenance: { mutationType: 'add', actorModel: config.llmModel }, + provenance: { mutationType: 'add', actorModel: lineageActorModel(deps) }, }); await deps.claims.setClaimCurrentVersion(claimId, versionId, 'active', event.logicalTimestamp); await deps.claims.addEvidence({ claimVersionId: versionId, episodeId: event.episodeId, memoryId, quoteText: event.fact.fact }); @@ -219,7 +224,7 @@ async function emitConsolidationAdd( provenance: { mutationType: 'add', mutationReason: event.mutationReason, - actorModel: config.llmModel, + actorModel: lineageActorModel(deps), }, }); await deps.claims.setClaimCurrentVersion(claimId, versionId); @@ -244,7 +249,7 @@ async function emitCanonicalUpdate( episodeId: event.episodeId, validFrom: event.logicalTimestamp, mutationReason, - actorModel: config.llmModel, + actorModel: lineageActorModel(deps), }); await deps.claims.addEvidence({ claimVersionId: versionId, @@ -279,7 +284,7 @@ async function emitCanonicalSupersede( mutationType: 'supersede', mutationReason, previousVersionId: event.target.versionId, - actorModel: config.llmModel, + actorModel: lineageActorModel(deps), contradictionConfidence: event.contradictionConfidence ?? undefined, }, }); @@ -314,7 +319,7 @@ async function emitCanonicalDelete( mutationType: 'delete', mutationReason, previousVersionId: event.target.versionId, - actorModel: config.llmModel, + actorModel: lineageActorModel(deps), contradictionConfidence: event.contradictionConfidence ?? undefined, }, }); @@ -345,7 +350,7 @@ async function createMutationCanonicalObject( previousVersionId: event.target.versionId, mutationReason, contradictionConfidence: event.contradictionConfidence ?? undefined, - actorModel: config.llmModel, + actorModel: lineageActorModel(deps), }, }); } @@ -364,3 +369,7 @@ function requireRepo(deps: LineageDeps): MutationCanonicalObjectRepo { } return deps.repo; } + +function lineageActorModel(deps: LineageDeps): string { + return deps.config?.llmModel ?? config.llmModel; +} diff --git a/src/services/memory-service-types.ts b/src/services/memory-service-types.ts index 1dfaa47..3f9847a 100644 --- a/src/services/memory-service-types.ts +++ b/src/services/memory-service-types.ts @@ -142,7 +142,7 @@ export interface RetrievalObservability { * Exposes the repositories and optional services needed by ingest, search, and CRUD. */ export interface MemoryServiceDeps { - config: import('../app/runtime-container.js').CoreRuntimeConfig; + config: import('../app/runtime-container.js').CoreRuntimeConfig & IngestRuntimeConfig; repo: import('../db/memory-repository.js').MemoryRepository; claims: import('../db/claim-repository.js').ClaimRepository; entities: import('../db/repository-entities.js').EntityRepository | null; @@ -150,3 +150,14 @@ export interface MemoryServiceDeps { observationService: import('./observation-service.js').ObservationService | null; uriResolver: import('./atomicmem-uri.js').URIResolver; } + +/** Explicit ingest/runtime config subset threaded through current ingest seams. */ +export interface IngestRuntimeConfig { + audnCandidateThreshold: number; + auditLoggingEnabled: boolean; + entityGraphEnabled: boolean; + fastAudnDuplicateThreshold: number; + fastAudnEnabled: boolean; + lessonsEnabled: boolean; + llmModel: string; +} diff --git a/src/services/memory-storage.ts b/src/services/memory-storage.ts index 5dc4f93..1c072fa 100644 --- a/src/services/memory-storage.ts +++ b/src/services/memory-storage.ts @@ -30,7 +30,7 @@ export async function storeCanonicalFact( ctx: AudnFactContext, ): Promise<{ outcome: Outcome; memoryId: string | null }> { const { userId, fact, embedding, sourceSite, sourceUrl, episodeId, trustScore, claimSlot, logicalTimestamp } = ctx; - const lineage = await emitLineageEvent({ claims: deps.claims, repo: deps.repo }, { + const lineage = await emitLineageEvent({ claims: deps.claims, repo: deps.repo, config: deps.config }, { kind: 'canonical-add', userId, fact, @@ -45,7 +45,7 @@ export async function storeCanonicalFact( }); if (!lineage?.memoryId) return { outcome: 'skipped', memoryId: null }; const memoryId = lineage.memoryId; - if (config.entityGraphEnabled && deps.entities) { + if (deps.config.entityGraphEnabled && deps.entities) { await resolveAndLinkEntities(deps, userId, memoryId, fact.entities, fact.relations, embedding); if (!claimSlot) { const persistedSlot = await derivePersistedClaimSlot(deps, userId, memoryId); @@ -112,7 +112,7 @@ export async function storeProjection( }))); } - if (config.auditLoggingEnabled) { + if (deps.config.auditLoggingEnabled) { emitAuditEvent('memory:ingest', userId, { factType: fact.type, importance: fact.importance, trustScore, }, { memoryId, sourceSite }); @@ -261,7 +261,7 @@ export async function ensureClaimTarget(deps: MemoryServiceDeps, userId: string, const version = await deps.claims.getClaimVersionByMemoryId(userId, memoryId); if (version) return { claimId: version.claim_id, versionId: version.id, memoryId, cmoId }; - const lineage = await emitLineageEvent({ claims: deps.claims }, { + const lineage = await emitLineageEvent({ claims: deps.claims, config: deps.config }, { kind: 'claim-backfill', userId, memory: { @@ -283,7 +283,7 @@ export async function ensureClaimTarget(deps: MemoryServiceDeps, userId: string, export async function findConflictCandidates(deps: MemoryServiceDeps, userId: string, factText: string, embedding: number[]): Promise { const [vectorCandidates, keywordCandidates] = await Promise.all([ - deps.repo.findNearDuplicates(userId, embedding, config.audnCandidateThreshold), + deps.repo.findNearDuplicates(userId, embedding, deps.config.audnCandidateThreshold), deps.repo.findKeywordCandidates(userId, extractConflictKeywords(factText)), ]); return mergeCandidates(vectorCandidates, keywordCandidates); From afc15ce5540ff3c415d3247f0d77a74f232d52a6 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 00:54:14 -0700 Subject: [PATCH 49/59] Thread route-layer config reads through injected adapter --- .../memory-route-config-seam.test.ts | 178 ++++++++++++++++++ src/app/runtime-container.ts | 32 ++++ src/routes/memories.ts | 110 ++++++++--- 3 files changed, 293 insertions(+), 27 deletions(-) create mode 100644 src/__tests__/memory-route-config-seam.test.ts diff --git a/src/__tests__/memory-route-config-seam.test.ts b/src/__tests__/memory-route-config-seam.test.ts new file mode 100644 index 0000000..929579c --- /dev/null +++ b/src/__tests__/memory-route-config-seam.test.ts @@ -0,0 +1,178 @@ +/** + * Route-level config seam tests for createMemoryRouter. + * + * Verifies that read-side route config now comes from the injected adapter + * rather than the module-level singleton for health/config responses and + * search-limit clamping. + */ + +import express from 'express'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMemoryRouter } from '../routes/memories.js'; +import type { MemoryService } from '../services/memory-service.js'; + +interface BootedApp { + baseUrl: string; + close: () => Promise; +} + +interface MutableRouteConfig { + retrievalProfile: string; + embeddingProvider: 'openai'; + embeddingModel: string; + llmProvider: 'openai'; + llmModel: string; + clarificationConflictThreshold: number; + maxSearchResults: number; + hybridSearchEnabled: boolean; + iterativeRetrievalEnabled: boolean; + entityGraphEnabled: boolean; + crossEncoderEnabled: boolean; + agenticRetrievalEnabled: boolean; + repairLoopEnabled: boolean; +} + +async function bindEphemeral(app: ReturnType): Promise { + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', () => resolve())); + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + return { + baseUrl: `http://localhost:${port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +describe('memory route config seam', () => { + let booted: BootedApp; + let routeConfig: MutableRouteConfig; + const search = vi.fn(); + + beforeAll(async () => { + routeConfig = { + retrievalProfile: 'route-adapter-profile', + embeddingProvider: 'openai', + embeddingModel: 'adapter-embedding-model', + llmProvider: 'openai', + llmModel: 'adapter-llm-model', + clarificationConflictThreshold: 0.91, + maxSearchResults: 3, + hybridSearchEnabled: true, + iterativeRetrievalEnabled: false, + entityGraphEnabled: true, + crossEncoderEnabled: true, + agenticRetrievalEnabled: false, + repairLoopEnabled: true, + }; + + search.mockResolvedValue({ + memories: [], + injectionText: '', + citations: [], + retrievalMode: 'flat', + }); + + const service = { + search, + fastSearch: vi.fn(), + workspaceSearch: vi.fn(), + ingest: vi.fn(), + quickIngest: vi.fn(), + storeVerbatim: vi.fn(), + workspaceIngest: vi.fn(), + expand: vi.fn(), + expandInWorkspace: vi.fn(), + list: vi.fn(), + listInWorkspace: vi.fn(), + getStats: vi.fn(), + consolidate: vi.fn(), + executeConsolidation: vi.fn(), + evaluateDecay: vi.fn(), + archiveDecayed: vi.fn(), + checkCap: vi.fn(), + getMutationSummary: vi.fn(), + getRecentMutations: vi.fn(), + getAuditTrail: vi.fn(), + getLessons: vi.fn(), + getLessonStats: vi.fn(), + reportLesson: vi.fn(), + deactivateLesson: vi.fn(), + reconcileDeferred: vi.fn(), + resetBySource: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + } as unknown as MemoryService; + + const configRouteAdapter = { + current: () => ({ ...routeConfig }), + update: (updates: { maxSearchResults?: number }) => { + if (updates.maxSearchResults !== undefined) { + routeConfig.maxSearchResults = updates.maxSearchResults; + } + return Object.keys(updates); + }, + }; + + const app = express(); + app.use(express.json()); + app.use('/memories', createMemoryRouter(service, configRouteAdapter)); + booted = await bindEphemeral(app); + }); + + beforeEach(() => { + search.mockClear(); + routeConfig.maxSearchResults = 3; + }); + + afterAll(async () => { + await booted.close(); + }); + + it('serves health/config payloads from the injected adapter snapshot', async () => { + const healthRes = await fetch(`${booted.baseUrl}/memories/health`); + expect(healthRes.status).toBe(200); + const healthBody = await healthRes.json(); + expect(healthBody.config.retrieval_profile).toBe('route-adapter-profile'); + expect(healthBody.config.max_search_results).toBe(3); + + const putRes = await fetch(`${booted.baseUrl}/memories/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ max_search_results: 7 }), + }); + expect(putRes.status).toBe(200); + const putBody = await putRes.json(); + expect(putBody.applied).toContain('maxSearchResults'); + expect(putBody.config.max_search_results).toBe(7); + + const updatedHealthRes = await fetch(`${booted.baseUrl}/memories/health`); + const updatedHealthBody = await updatedHealthRes.json(); + expect(updatedHealthBody.config.max_search_results).toBe(7); + }); + + it('clamps search limits using the injected adapter snapshot', async () => { + await fetch(`${booted.baseUrl}/memories/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 'user-1', + query: 'route seam query', + limit: 50, + }), + }); + + expect(search).toHaveBeenCalledWith( + 'user-1', + 'route seam query', + undefined, + 3, + undefined, + undefined, + undefined, + { + retrievalMode: undefined, + tokenBudget: undefined, + }, + ); + }); +}); diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index 7751209..a8b7f55 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -92,6 +92,21 @@ export interface CoreRuntimeServices { } export interface CoreRuntimeConfigRouteAdapter { + current: () => { + retrievalProfile: string; + embeddingProvider: import('../config.js').EmbeddingProviderName; + embeddingModel: string; + llmProvider: import('../config.js').LLMProviderName; + llmModel: string; + clarificationConflictThreshold: number; + maxSearchResults: number; + hybridSearchEnabled: boolean; + iterativeRetrievalEnabled: boolean; + entityGraphEnabled: boolean; + crossEncoderEnabled: boolean; + agenticRetrievalEnabled: boolean; + repairLoopEnabled: boolean; + }; update: (updates: { embeddingProvider?: import('../config.js').EmbeddingProviderName; embeddingModel?: string; @@ -159,6 +174,23 @@ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime { return { config, configRouteAdapter: { + current() { + return { + retrievalProfile: config.retrievalProfile, + embeddingProvider: config.embeddingProvider, + embeddingModel: config.embeddingModel, + llmProvider: config.llmProvider, + llmModel: config.llmModel, + clarificationConflictThreshold: config.clarificationConflictThreshold, + maxSearchResults: config.maxSearchResults, + hybridSearchEnabled: config.hybridSearchEnabled, + iterativeRetrievalEnabled: config.iterativeRetrievalEnabled, + entityGraphEnabled: config.entityGraphEnabled, + crossEncoderEnabled: config.crossEncoderEnabled, + agenticRetrievalEnabled: config.agenticRetrievalEnabled, + repairLoopEnabled: config.repairLoopEnabled, + }; + }, update(updates) { return updateRuntimeConfig(updates); }, diff --git a/src/routes/memories.ts b/src/routes/memories.ts index d4acc78..f3788e5 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -21,9 +21,26 @@ const ALLOWED_ORIGINS = new Set( ); interface RuntimeConfigRouteAdapter { + current(): RuntimeConfigRouteSnapshot; update(updates: RuntimeConfigRouteUpdates): string[]; } +interface RuntimeConfigRouteSnapshot { + retrievalProfile: string; + embeddingProvider: EmbeddingProviderName; + embeddingModel: string; + llmProvider: LLMProviderName; + llmModel: string; + clarificationConflictThreshold: number; + maxSearchResults: number; + hybridSearchEnabled: boolean; + iterativeRetrievalEnabled: boolean; + entityGraphEnabled: boolean; + crossEncoderEnabled: boolean; + agenticRetrievalEnabled: boolean; + repairLoopEnabled: boolean; +} + interface RuntimeConfigRouteUpdates { embeddingProvider?: EmbeddingProviderName; embeddingModel?: string; @@ -36,6 +53,9 @@ interface RuntimeConfigRouteUpdates { } const defaultRuntimeConfigRouteAdapter: RuntimeConfigRouteAdapter = { + current() { + return readRuntimeConfigRouteSnapshot(); + }, update(updates) { return updateRuntimeConfig(updates); }, @@ -49,12 +69,12 @@ export function createMemoryRouter( registerCors(router); registerIngestRoute(router, service); registerQuickIngestRoute(router, service); - registerSearchRoute(router, service); - registerFastSearchRoute(router, service); + registerSearchRoute(router, service, configRouteAdapter); + registerFastSearchRoute(router, service, configRouteAdapter); registerExpandRoute(router, service); registerListRoute(router, service); registerStatsRoute(router, service); - registerHealthRoute(router); + registerHealthRoute(router, configRouteAdapter); registerConfigRoute(router, configRouteAdapter); registerConsolidateRoute(router, service); registerDecayRoute(router, service); @@ -124,12 +144,18 @@ function registerIngestHandler( }); } -function registerSearchRoute(router: Router, service: MemoryService): void { +function registerSearchRoute( + router: Router, + service: MemoryService, + configRouteAdapter: RuntimeConfigRouteAdapter, +): void { router.post('/search', async (req: Request, res: Response) => { try { const body = parseSearchBody(req.body); const scope = toMemoryScope(body.userId, body.workspace, body.agentScope); - const requestLimit = body.limit === undefined ? undefined : resolveEffectiveSearchLimit(body.limit); + const requestLimit = body.limit === undefined + ? undefined + : resolveEffectiveSearchLimit(body.limit, configRouteAdapter.current()); const retrievalOptions: { retrievalMode?: typeof body.retrievalMode; tokenBudget?: typeof body.tokenBudget; skipRepairLoop?: boolean } = { retrievalMode: body.retrievalMode, tokenBudget: body.tokenBudget, @@ -162,12 +188,18 @@ function registerSearchRoute(router: Router, service: MemoryService): void { * Latency-optimized search endpoint for UC1 (memory injection, <200ms target). * Skips the LLM repair loop which accounts for ~88% of search latency. */ -function registerFastSearchRoute(router: Router, service: MemoryService): void { +function registerFastSearchRoute( + router: Router, + service: MemoryService, + configRouteAdapter: RuntimeConfigRouteAdapter, +): void { router.post('/search/fast', async (req: Request, res: Response) => { try { const body = parseSearchBody(req.body); const scope = toMemoryScope(body.userId, body.workspace, body.agentScope); - const requestLimit = body.limit === undefined ? undefined : resolveEffectiveSearchLimit(body.limit); + const requestLimit = body.limit === undefined + ? undefined + : resolveEffectiveSearchLimit(body.limit, configRouteAdapter.current()); const result = scope.kind === 'workspace' ? await service.workspaceSearch(scope.userId, body.query, body.workspace!, { agentScope: scope.agentScope, @@ -235,9 +267,9 @@ function registerStatsRoute(router: Router, service: MemoryService): void { }); } -function registerHealthRoute(router: Router): void { +function registerHealthRoute(router: Router, configRouteAdapter: RuntimeConfigRouteAdapter): void { router.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'ok', config: formatHealthConfig() }); + res.json({ status: 'ok', config: formatHealthConfig(configRouteAdapter.current()) }); }); } @@ -254,7 +286,11 @@ function registerConfigRoute(router: Router, configRouteAdapter: RuntimeConfigRo clarificationConflictThreshold: req.body.clarification_conflict_threshold, maxSearchResults: req.body.max_search_results, }); - res.json({ applied, config: formatHealthConfig(), note: 'Provider/model changes are applied in-memory for local experimentation.' }); + res.json({ + applied, + config: formatHealthConfig(configRouteAdapter.current()), + note: 'Provider/model changes are applied in-memory for local experimentation.', + }); } catch (err) { handleRouteError(res, 'PUT /memories/config', err); } @@ -600,8 +636,11 @@ function parseOptionalIsoTimestamp(value: unknown): string | undefined { return value; } -function resolveEffectiveSearchLimit(requestedLimit: number | undefined): number { - const maxLimit = config.maxSearchResults; +function resolveEffectiveSearchLimit( + requestedLimit: number | undefined, + runtimeConfig: Pick, +): number { + const maxLimit = runtimeConfig.maxSearchResults; if (requestedLimit === undefined) return maxLimit; return Math.min(requestedLimit, maxLimit); } @@ -633,21 +672,39 @@ function applyCorsHeaders(req: Request, res: Response): void { } -function formatHealthConfig() { +function readRuntimeConfigRouteSnapshot(): RuntimeConfigRouteSnapshot { return { - retrieval_profile: config.retrievalProfile, - embedding_provider: config.embeddingProvider, - embedding_model: config.embeddingModel, - llm_provider: config.llmProvider, - llm_model: config.llmModel, - clarification_conflict_threshold: config.clarificationConflictThreshold, - max_search_results: config.maxSearchResults, - hybrid_search_enabled: config.hybridSearchEnabled, - iterative_retrieval_enabled: config.iterativeRetrievalEnabled, - entity_graph_enabled: config.entityGraphEnabled, - cross_encoder_enabled: config.crossEncoderEnabled, - agentic_retrieval_enabled: config.agenticRetrievalEnabled, - repair_loop_enabled: config.repairLoopEnabled, + retrievalProfile: config.retrievalProfile, + embeddingProvider: config.embeddingProvider, + embeddingModel: config.embeddingModel, + llmProvider: config.llmProvider, + llmModel: config.llmModel, + clarificationConflictThreshold: config.clarificationConflictThreshold, + maxSearchResults: config.maxSearchResults, + hybridSearchEnabled: config.hybridSearchEnabled, + iterativeRetrievalEnabled: config.iterativeRetrievalEnabled, + entityGraphEnabled: config.entityGraphEnabled, + crossEncoderEnabled: config.crossEncoderEnabled, + agenticRetrievalEnabled: config.agenticRetrievalEnabled, + repairLoopEnabled: config.repairLoopEnabled, + }; +} + +function formatHealthConfig(runtimeConfig: RuntimeConfigRouteSnapshot) { + return { + retrieval_profile: runtimeConfig.retrievalProfile, + embedding_provider: runtimeConfig.embeddingProvider, + embedding_model: runtimeConfig.embeddingModel, + llm_provider: runtimeConfig.llmProvider, + llm_model: runtimeConfig.llmModel, + clarification_conflict_threshold: runtimeConfig.clarificationConflictThreshold, + max_search_results: runtimeConfig.maxSearchResults, + hybrid_search_enabled: runtimeConfig.hybridSearchEnabled, + iterative_retrieval_enabled: runtimeConfig.iterativeRetrievalEnabled, + entity_graph_enabled: runtimeConfig.entityGraphEnabled, + cross_encoder_enabled: runtimeConfig.crossEncoderEnabled, + agentic_retrieval_enabled: runtimeConfig.agenticRetrievalEnabled, + repair_loop_enabled: runtimeConfig.repairLoopEnabled, }; } @@ -698,4 +755,3 @@ function formatSearchResponse(result: RetrievalResult, scope: MemoryScope) { ...(observability ? { observability } : {}), }; } - From 560caa648d05a3ccdc65e3e0c6beb877959d7afa Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 00:55:52 -0700 Subject: [PATCH 50/59] test: add config singleton import regression gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static test that counts non-test source files importing the module-level config singleton and fails if the count exceeds 32 (current baseline). As config-threading PRs land and remove singleton imports, the threshold should be ratcheted down — that one-line constant change is the explicit friction that prevents regression. Includes a staleness check that warns (but does not fail) when the threshold has more than 5 files of slack, prompting the next threading PR to tighten the gate. Non-overlapping with the in-flight ingest/storage/audn/lineage config threading on this branch. Uses the static file-reading pattern from deployment-config.test.ts — no runtime, no DB, no mock dependencies. --- src/__tests__/config-singleton-audit.test.ts | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/__tests__/config-singleton-audit.test.ts diff --git a/src/__tests__/config-singleton-audit.test.ts b/src/__tests__/config-singleton-audit.test.ts new file mode 100644 index 0000000..7d5e22a --- /dev/null +++ b/src/__tests__/config-singleton-audit.test.ts @@ -0,0 +1,72 @@ +/** + * Config singleton import regression gate. + * + * Counts the non-test source files that import the module-level config + * singleton (`import { config } from '../config.js'`). The threshold + * should only move DOWN as config-threading PRs land. Any PR that adds + * a new singleton import must raise the threshold explicitly — that + * friction is the point. + * + * This test does not depend on a live database or runtime — it reads + * source files statically, matching the pattern in + * deployment-config.test.ts. + */ + +import { describe, it, expect } from 'vitest'; +import { execSync } from 'node:child_process'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SRC = resolve(__dirname, '..'); + +/** + * Maximum allowed non-test source files importing the config singleton. + * Ratchet this DOWN after each config-threading PR lands. + * Current baseline: 32 files (as of feat/phase-3-config-audit HEAD 246654b). + */ +const MAX_SINGLETON_IMPORTS = 32; + +function findSingletonImporters(): string[] { + const raw = execSync( + `grep -rl "import { config } from" "${SRC}" --include="*.ts" | grep -v "__tests__" | grep -v "node_modules" | sort`, + { encoding: 'utf-8' }, + ); + return raw + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +describe('config singleton regression gate', () => { + it(`non-test source files importing config singleton must not exceed ${MAX_SINGLETON_IMPORTS}`, () => { + const files = findSingletonImporters(); + + expect(files.length).toBeLessThanOrEqual(MAX_SINGLETON_IMPORTS); + + // Print the list on failure so the developer knows exactly which + // files to inspect or thread. + if (files.length > MAX_SINGLETON_IMPORTS) { + console.error( + `Config singleton imports (${files.length}) exceed threshold (${MAX_SINGLETON_IMPORTS}):\n` + + files.map((f) => ` ${f}`).join('\n'), + ); + } + }); + + it('threshold is not stale (count should be close to threshold)', () => { + const files = findSingletonImporters(); + const slack = MAX_SINGLETON_IMPORTS - files.length; + + // If the threshold has more than 5 files of slack, a threading PR + // landed without ratcheting the threshold down. Warn but don't fail + // — the primary gate is the upper-bound test above. + if (slack > 5) { + console.warn( + `Config singleton threshold has ${slack} files of slack ` + + `(threshold=${MAX_SINGLETON_IMPORTS}, actual=${files.length}). ` + + `Consider ratcheting MAX_SINGLETON_IMPORTS down to ${files.length + 2}.`, + ); + } + }); +}); From 58faf40a24d6712f2aae1fc7aa6a2bf1a5d80952 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 01:00:06 -0700 Subject: [PATCH 51/59] fix(test): broaden config singleton gate to catch multi-import patterns The initial grep (`import { config } from`) missed three real singleton imports that use multi-binding forms: - src/routes/memories.ts: `import { config, updateRuntimeConfig, ... }` - src/services/reranker.ts: `import { config, type CrossEncoderDtype }` - src/index.ts: `export { config, ... } from './config.js'` - src/app/runtime-container.ts: `import { config } from` (composition root) Replaces the simple grep -rl with a regex that matches any import or export statement binding the `config` value from a config.js path, excluding `import type`-only lines. Threshold updated from 32 to 36 to reflect the accurate count. --- src/__tests__/config-singleton-audit.test.ts | 46 +++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/__tests__/config-singleton-audit.test.ts b/src/__tests__/config-singleton-audit.test.ts index 7d5e22a..d160f05 100644 --- a/src/__tests__/config-singleton-audit.test.ts +++ b/src/__tests__/config-singleton-audit.test.ts @@ -1,8 +1,8 @@ /** * Config singleton import regression gate. * - * Counts the non-test source files that import the module-level config - * singleton (`import { config } from '../config.js'`). The threshold + * Counts the non-test source files that bind the module-level config + * singleton value from config.js (any import/export pattern). The threshold * should only move DOWN as config-threading PRs land. Any PR that adds * a new singleton import must raise the threshold explicitly — that * friction is the point. @@ -21,21 +21,45 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const SRC = resolve(__dirname, '..'); /** - * Maximum allowed non-test source files importing the config singleton. - * Ratchet this DOWN after each config-threading PR lands. - * Current baseline: 32 files (as of feat/phase-3-config-audit HEAD 246654b). + * Maximum allowed non-test source files that bind the runtime config + * singleton value from config.js. Ratchet this DOWN after each + * config-threading PR lands. + * Current baseline: 36 files (as of feat/phase-3-config-audit HEAD 560caa6). + * Includes multi-import forms (e.g. `import { config, updateRuntimeConfig }`) + * and re-exports (e.g. `export { config } from`). */ -const MAX_SINGLETON_IMPORTS = 32; +const MAX_SINGLETON_IMPORTS = 36; +/** + * Match any import or re-export that binds the `config` value (not just + * a type) from a path ending in `config.js` or `config`. Covers: + * import { config } from '../config.js' + * import { config, updateRuntimeConfig, ... } from '../config.js' + * export { config, ... } from './config.js' + * Excludes `import type`-only lines (those don't read the singleton at + * runtime). + */ function findSingletonImporters(): string[] { const raw = execSync( - `grep -rl "import { config } from" "${SRC}" --include="*.ts" | grep -v "__tests__" | grep -v "node_modules" | sort`, + [ + `grep -rn "config" "${SRC}" --include="*.ts"`, + `| grep -v "__tests__"`, + `| grep -v "node_modules"`, + ].join(' '), { encoding: 'utf-8' }, ); - return raw - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); + const seen = new Set(); + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + // Must be an import/export binding `config` (the value) from a + // config.js or config path — not just a type import, not a comment, + // not a reference to CoreRuntimeConfig/IngestRuntimeConfig. + if (!/(?:import|export)\s*\{[^}]*\bconfig\b[^}]*\}\s*from\s*['"][^'"]*config/.test(line)) continue; + if (/^\s*import\s+type\s/.test(line)) continue; + const file = line.split(':')[0]; + seen.add(file); + } + return [...seen].sort(); } describe('config singleton regression gate', () => { From 9a8c2860fa35d4bb4a61ef61171d61367c673cb4 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 01:03:55 -0700 Subject: [PATCH 52/59] fix(test): use file-level multiline matching in config singleton gate The prior line-by-line grep+regex approach would miss multiline import blocks like: import { config, updateRuntimeConfig, } from '../config.js' Replaces the grep pipeline with readFileSync + a regex using the /s (dotAll) flag that matches across newlines. The test now: - Walks src/ recursively (skipping __tests__ and node_modules) - Reads each .ts file as a string - Matches import/export blocks binding `config` from a config path - Filters out `import type`-only statements Verified against 6 representative patterns: single-line, multi-binding single-line, multiline import, multiline re-export, type-only (skip), and non-config-binding (skip). Count remains 36; no false positives or false negatives vs the perl reference. --- src/__tests__/config-singleton-audit.test.ts | 60 +++++++++++--------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/__tests__/config-singleton-audit.test.ts b/src/__tests__/config-singleton-audit.test.ts index d160f05..da3b6a6 100644 --- a/src/__tests__/config-singleton-audit.test.ts +++ b/src/__tests__/config-singleton-audit.test.ts @@ -13,8 +13,8 @@ */ import { describe, it, expect } from 'vitest'; -import { execSync } from 'node:child_process'; -import { resolve, dirname } from 'node:path'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { resolve, dirname, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -31,35 +31,43 @@ const SRC = resolve(__dirname, '..'); const MAX_SINGLETON_IMPORTS = 36; /** - * Match any import or re-export that binds the `config` value (not just - * a type) from a path ending in `config.js` or `config`. Covers: + * Matches any import or re-export that binds the `config` value (not + * just a type) from a path ending in `config.js` or `config`. Covers + * single-line and multiline import blocks: * import { config } from '../config.js' - * import { config, updateRuntimeConfig, ... } from '../config.js' + * import { config, updateRuntimeConfig } from '../config.js' + * import {\n config,\n updateRuntimeConfig,\n} from '../config.js' * export { config, ... } from './config.js' - * Excludes `import type`-only lines (those don't read the singleton at - * runtime). + * Excludes `import type`-only statements. */ +const CONFIG_BINDING_RE = /(?:import|export)\s*\{[^}]*\bconfig\b[^}]*\}\s*from\s*['"][^'"]*config/s; +const IMPORT_TYPE_ONLY_RE = /import\s+type\s*\{/; + +function collectTsFiles(dir: string): string[] { + const results: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === '__tests__' || entry.name === 'node_modules') continue; + results.push(...collectTsFiles(full)); + } else if (entry.isFile() && extname(entry.name) === '.ts') { + results.push(full); + } + } + return results; +} + function findSingletonImporters(): string[] { - const raw = execSync( - [ - `grep -rn "config" "${SRC}" --include="*.ts"`, - `| grep -v "__tests__"`, - `| grep -v "node_modules"`, - ].join(' '), - { encoding: 'utf-8' }, - ); - const seen = new Set(); - for (const line of raw.split('\n')) { - if (!line.trim()) continue; - // Must be an import/export binding `config` (the value) from a - // config.js or config path — not just a type import, not a comment, - // not a reference to CoreRuntimeConfig/IngestRuntimeConfig. - if (!/(?:import|export)\s*\{[^}]*\bconfig\b[^}]*\}\s*from\s*['"][^'"]*config/.test(line)) continue; - if (/^\s*import\s+type\s/.test(line)) continue; - const file = line.split(':')[0]; - seen.add(file); + const files = collectTsFiles(SRC); + const matches: string[] = []; + for (const filePath of files) { + const content = readFileSync(filePath, 'utf-8'); + // Find all import/export blocks from a config path that bind `config` + const hits = content.match(new RegExp(CONFIG_BINDING_RE.source, 'gs')) ?? []; + const hasRuntimeBinding = hits.some((hit) => !IMPORT_TYPE_ONLY_RE.test(hit)); + if (hasRuntimeBinding) matches.push(filePath); } - return [...seen].sort(); + return matches.sort(); } describe('config singleton regression gate', () => { From 89dfdbdf601df07544fefa768869483ef8979118 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 01:11:59 -0700 Subject: [PATCH 53/59] refactor(ingest): thread entropy and composite config --- .../memory-ingest-runtime-config.test.ts | 133 ++++++++++++++++-- src/services/memory-ingest.ts | 19 ++- src/services/memory-service-types.ts | 5 + 3 files changed, 141 insertions(+), 16 deletions(-) diff --git a/src/services/__tests__/memory-ingest-runtime-config.test.ts b/src/services/__tests__/memory-ingest-runtime-config.test.ts index 3623b6a..ad7f1b6 100644 --- a/src/services/__tests__/memory-ingest-runtime-config.test.ts +++ b/src/services/__tests__/memory-ingest-runtime-config.test.ts @@ -1,14 +1,24 @@ /** * Runtime config seam tests for memory-ingest. * - * Verifies that performQuickIngest forwards deps.config into generateLinks - * after accumulating stored memory IDs and embeddings. + * Verifies that memory-ingest uses deps.config for the already-threaded + * quick-ingest, entropy-gate, and composite-grouping seams. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockGenerateLinks } = vi.hoisted(() => ({ +const { + mockGenerateLinks, + mockConsensusExtractFacts, + mockComputeEntropyScore, + mockBuildComposites, + mockFindFilteredCandidates, +} = vi.hoisted(() => ({ mockGenerateLinks: vi.fn(), + mockConsensusExtractFacts: vi.fn(), + mockComputeEntropyScore: vi.fn(), + mockBuildComposites: vi.fn(), + mockFindFilteredCandidates: vi.fn(), })); const { mockStoreCanonicalFact } = vi.hoisted(() => ({ mockStoreCanonicalFact: vi.fn(), @@ -16,6 +26,11 @@ const { mockStoreCanonicalFact } = vi.hoisted(() => ({ const moduleConfig = { audnCandidateThreshold: 0.7, + compositeGroupingEnabled: false, + compositeMinClusterSize: 99, + entropyGateAlpha: 0.4, + entropyGateEnabled: false, + entropyGateThreshold: 0.9, fastAudnEnabled: false, fastAudnDuplicateThreshold: 0.95, }; @@ -61,7 +76,7 @@ vi.mock('../timing.js', () => ({ timed: vi.fn(async (_name: string, fn: () => unknown) => fn()), })); vi.mock('../consensus-extraction.js', () => ({ - consensusExtractFacts: vi.fn(), + consensusExtractFacts: mockConsensusExtractFacts, })); vi.mock('../extraction-cache.js', () => ({ cachedResolveAUDN: vi.fn(), @@ -74,26 +89,40 @@ vi.mock('../namespace-retrieval.js', () => ({ deriveMajorityNamespace: vi.fn(), })); vi.mock('../entropy-gate.js', () => ({ - computeEntropyScore: vi.fn(), + computeEntropyScore: mockComputeEntropyScore, })); vi.mock('../composite-grouping.js', () => ({ - buildComposites: vi.fn(), + buildComposites: mockBuildComposites, })); vi.mock('../memory-audn.js', () => ({ - findFilteredCandidates: vi.fn(), + findFilteredCandidates: mockFindFilteredCandidates, resolveAndExecuteAudn: vi.fn(), })); -const { performQuickIngest } = await import('../memory-ingest.js'); +const { performIngest, performQuickIngest } = await import('../memory-ingest.js'); -describe('performQuickIngest runtime config seam', () => { +describe('memory-ingest runtime config seam', () => { beforeEach(() => { vi.clearAllMocks(); mockGenerateLinks.mockResolvedValue(1); mockStoreCanonicalFact.mockResolvedValue({ outcome: 'stored', memoryId: 'memory-1' }); + mockConsensusExtractFacts.mockResolvedValue([ + { + fact: 'User prefers Rust', + headline: 'Prefers Rust', + importance: 0.8, + type: 'preference', + keywords: ['rust'], + entities: [], + relations: [], + }, + ]); + mockComputeEntropyScore.mockReturnValue({ accepted: true }); + mockBuildComposites.mockReturnValue([]); + mockFindFilteredCandidates.mockResolvedValue([]); }); - it('passes deps.config into generateLinks', async () => { + it('passes deps.config into generateLinks during quick ingest', async () => { const runtimeConfig = { linkExpansionEnabled: true, linkSimilarityThreshold: 0.42, @@ -160,4 +189,88 @@ describe('performQuickIngest runtime config seam', () => { expect(result.memoryIds).toEqual(['existing-1']); expect(mockStoreCanonicalFact).not.toHaveBeenCalled(); }); + + it('uses deps.config for entropy gate parameters during ingest', async () => { + const runtimeConfig = { + audnCandidateThreshold: 0.42, + auditLoggingEnabled: false, + compositeGroupingEnabled: false, + compositeMinClusterSize: 99, + entityGraphEnabled: false, + entropyGateAlpha: 0.73, + entropyGateEnabled: true, + entropyGateThreshold: 0.21, + fastAudnEnabled: false, + fastAudnDuplicateThreshold: 0.83, + lessonsEnabled: false, + llmModel: 'runtime-llm', + linkExpansionEnabled: false, + linkSimilarityThreshold: 0.5, + }; + const repo = { + storeEpisode: vi.fn().mockResolvedValue('episode-1'), + backdateMemories: vi.fn(), + }; + const deps = { + config: runtimeConfig, + repo, + claims: {}, + entities: null, + lessons: null, + observationService: null, + uriResolver: {}, + } as any; + + await performIngest(deps, 'user-1', 'User: I prefer Rust', 'chat'); + + expect(mockComputeEntropyScore).toHaveBeenCalledWith( + expect.objectContaining({ + windowEntities: ['rust'], + windowEmbedding: [0.1, 0.2], + }), + { threshold: 0.21, alpha: 0.73 }, + ); + }); + + it('uses deps.config for composite grouping gate during ingest', async () => { + const runtimeConfig = { + audnCandidateThreshold: 0.42, + auditLoggingEnabled: false, + compositeGroupingEnabled: true, + compositeMinClusterSize: 1, + entityGraphEnabled: false, + entropyGateAlpha: 0.73, + entropyGateEnabled: false, + entropyGateThreshold: 0.21, + fastAudnEnabled: false, + fastAudnDuplicateThreshold: 0.83, + lessonsEnabled: false, + llmModel: 'runtime-llm', + linkExpansionEnabled: false, + linkSimilarityThreshold: 0.5, + }; + const repo = { + storeEpisode: vi.fn().mockResolvedValue('episode-1'), + backdateMemories: vi.fn(), + }; + const deps = { + config: runtimeConfig, + repo, + claims: {}, + entities: null, + lessons: null, + observationService: null, + uriResolver: {}, + } as any; + + const result = await performIngest(deps, 'user-1', 'User: I prefer Rust', 'chat'); + + expect(mockBuildComposites).toHaveBeenCalledWith([ + expect.objectContaining({ + memoryId: 'memory-1', + content: 'User prefers Rust', + }), + ]); + expect(result.compositesCreated).toBe(0); + }); }); diff --git a/src/services/memory-ingest.ts b/src/services/memory-ingest.ts index daa6bd8..2c60e62 100644 --- a/src/services/memory-ingest.ts +++ b/src/services/memory-ingest.ts @@ -3,7 +3,6 @@ * Delegates AUDN resolution to memory-audn.ts and storage to memory-storage.ts. */ -import { config } from '../config.js'; import { embedText } from './embedding.js'; import { cachedResolveAUDN } from './extraction-cache.js'; import { consensusExtractFacts } from './consensus-extraction.js'; @@ -96,7 +95,7 @@ export async function performIngest( ); let compositesCreated = 0; - if (config.compositeGroupingEnabled && storedFacts.length >= config.compositeMinClusterSize) { + if (deps.config.compositeGroupingEnabled && storedFacts.length >= deps.config.compositeMinClusterSize) { compositesCreated = await timed('ingest.composites', () => generateAndStoreComposites(deps, userId, storedFacts, acc.embeddingCache, sourceSite, sourceUrl, episodeId), ); @@ -283,7 +282,7 @@ async function ingestFact( return { outcome: 'skipped', memoryId: null }; } - if (!passesEntropyGate(fact, embedding, entropyCtx)) { + if (!passesEntropyGate(fact, embedding, entropyCtx, deps.config)) { return { outcome: 'skipped', memoryId: null }; } @@ -396,8 +395,16 @@ async function storeWorkspaceMemory( } /** Check entropy gate; returns false if the fact should be skipped. */ -function passesEntropyGate(fact: FactInput, embedding: number[], entropyCtx: EntropyContext): boolean { - if (!config.entropyGateEnabled) return true; +function passesEntropyGate( + fact: FactInput, + embedding: number[], + entropyCtx: EntropyContext, + runtimeConfig: Pick< + MemoryServiceDeps['config'], + 'entropyGateEnabled' | 'entropyGateThreshold' | 'entropyGateAlpha' + >, +): boolean { + if (!runtimeConfig.entropyGateEnabled) return true; const entropyResult = computeEntropyScore( { windowEntities: fact.keywords, @@ -405,7 +412,7 @@ function passesEntropyGate(fact: FactInput, embedding: number[], entropyCtx: Ent windowEmbedding: embedding, previousEmbedding: entropyCtx.previousEmbedding, }, - { threshold: config.entropyGateThreshold, alpha: config.entropyGateAlpha }, + { threshold: runtimeConfig.entropyGateThreshold, alpha: runtimeConfig.entropyGateAlpha }, ); entropyCtx.previousEmbedding = embedding; for (const kw of fact.keywords) entropyCtx.seenEntities.add(kw); diff --git a/src/services/memory-service-types.ts b/src/services/memory-service-types.ts index 3f9847a..56fa387 100644 --- a/src/services/memory-service-types.ts +++ b/src/services/memory-service-types.ts @@ -155,7 +155,12 @@ export interface MemoryServiceDeps { export interface IngestRuntimeConfig { audnCandidateThreshold: number; auditLoggingEnabled: boolean; + compositeGroupingEnabled: boolean; + compositeMinClusterSize: number; entityGraphEnabled: boolean; + entropyGateAlpha: number; + entropyGateEnabled: boolean; + entropyGateThreshold: number; fastAudnDuplicateThreshold: number; fastAudnEnabled: boolean; lessonsEnabled: boolean; From aa3e646d12d4e2aef758bcd2c8d784ad0cf5155c Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 01:12:19 -0700 Subject: [PATCH 54/59] Thread namespace classification through runtime config seam --- src/app/runtime-container.ts | 1 + .../memory-storage-runtime-config.test.ts | 76 +++++++++++++++++++ src/services/memory-storage.ts | 3 +- 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/services/__tests__/memory-storage-runtime-config.test.ts diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index a8b7f55..eef99f3 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -59,6 +59,7 @@ export interface CoreRuntimeConfig { maxSearchResults: number; mmrEnabled: boolean; mmrLambda: number; + namespaceClassificationEnabled: boolean; pprDamping: number; pprEnabled: boolean; port: number; diff --git a/src/services/__tests__/memory-storage-runtime-config.test.ts b/src/services/__tests__/memory-storage-runtime-config.test.ts new file mode 100644 index 0000000..db1a546 --- /dev/null +++ b/src/services/__tests__/memory-storage-runtime-config.test.ts @@ -0,0 +1,76 @@ +/** + * Runtime config seam tests for memory-storage. + * + * Verifies that namespace classification decisions come from the explicit + * runtime config passed through MemoryService deps, not the module singleton. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../namespace-retrieval.js', () => ({ + classifyNamespace: vi.fn(), + inferNamespace: vi.fn(), +})); + +import { config } from '../../config.js'; +import { storeProjection } from '../memory-storage.js'; +import { classifyNamespace, inferNamespace } from '../namespace-retrieval.js'; + +describe('memory-storage runtime config seam', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses deps.config.namespaceClassificationEnabled instead of the singleton flag', async () => { + const originalNamespaceClassificationEnabled = config.namespaceClassificationEnabled; + config.namespaceClassificationEnabled = false; + + vi.mocked(classifyNamespace).mockResolvedValue('runtime.namespace'); + vi.mocked(inferNamespace).mockReturnValue('singleton.namespace'); + + const deps = { + config: { + namespaceClassificationEnabled: true, + auditLoggingEnabled: false, + }, + repo: { + storeMemory: vi.fn().mockResolvedValue('memory-1'), + storeAtomicFacts: vi.fn().mockResolvedValue(undefined), + storeForesight: vi.fn().mockResolvedValue(undefined), + }, + } as any; + + try { + await storeProjection( + deps, + 'user-1', + { + fact: 'User prefers PostgreSQL.', + headline: 'Prefers PostgreSQL', + importance: 0.8, + type: 'knowledge', + keywords: ['postgresql'], + entities: [], + relations: [], + }, + [0.1, 0.2], + 'chat.openai.com', + 'https://chat.example/test', + 'episode-1', + 0.95, + ); + } finally { + config.namespaceClassificationEnabled = originalNamespaceClassificationEnabled; + } + + expect(classifyNamespace).toHaveBeenCalledWith( + 'User prefers PostgreSQL.', + 'chat.openai.com', + ['postgresql'], + ); + expect(inferNamespace).not.toHaveBeenCalled(); + expect(deps.repo.storeMemory).toHaveBeenCalledWith( + expect.objectContaining({ namespace: 'runtime.namespace' }), + ); + }); +}); diff --git a/src/services/memory-storage.ts b/src/services/memory-storage.ts index 1c072fa..056acb3 100644 --- a/src/services/memory-storage.ts +++ b/src/services/memory-storage.ts @@ -3,7 +3,6 @@ * These helpers are used by both the ingest pipeline and the AUDN decision executor. */ -import { config } from '../config.js'; import { type ClaimSlotInput } from '../db/claim-repository.js'; import { embedTexts } from './embedding.js'; import { type ExtractedEntity, type ExtractedRelation } from './extraction.js'; @@ -74,7 +73,7 @@ export async function storeProjection( trustScore: number, cmoId?: string, ): Promise { - const namespace = config.namespaceClassificationEnabled + const namespace = deps.config.namespaceClassificationEnabled ? await classifyNamespace(fact.fact, sourceSite, fact.keywords) : inferNamespace(fact.fact, sourceSite, fact.keywords); From fe24be457be801e04969714b7db2867a97e3c76b Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 01:13:26 -0700 Subject: [PATCH 55/59] fix(test): add missing compositeMaxClusterSize to config mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composite-grouping test mock omitted compositeMaxClusterSize, causing it to resolve to undefined. Since `n >= undefined` is false in JS, the cluster-size cap in clusterBySimilarity was silently disabled — tests never exercised the capping behavior. Adds compositeMaxClusterSize: 3 (matching the production default) and adjusts the L1 overview test to use multi-sentence facts within the 3-fact cap so the joined content still exceeds the truncation threshold. --- .../__tests__/composite-grouping.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/services/__tests__/composite-grouping.test.ts b/src/services/__tests__/composite-grouping.test.ts index 72064b1..d11bdec 100644 --- a/src/services/__tests__/composite-grouping.test.ts +++ b/src/services/__tests__/composite-grouping.test.ts @@ -11,6 +11,7 @@ vi.mock('../../config.js', () => ({ config: { compositeGroupingEnabled: true, compositeMinClusterSize: 2, + compositeMaxClusterSize: 3, compositeSimilarityThreshold: 0.55, }, })); @@ -59,20 +60,21 @@ describe('buildComposites', () => { expect(composite.keywords).toContain('strict'); }); - it('produces a non-empty L1 overview for composites with 3+ facts', () => { - const embeddings = similarEmbeddings(2, 4); + it('produces a non-empty L1 overview when joined content exceeds truncation threshold', () => { + const embeddings = similarEmbeddings(2, 3); + // Multi-sentence facts so the joined content has >3 sentences within the + // compositeMaxClusterSize cap (3 facts × 2 sentences = 6 sentences joined). const facts: CompositeInput[] = [ - { memoryId: 'a', content: 'User is building a React application for personal finance tracking.', embedding: embeddings[0], importance: 0.7, keywords: ['React'], headline: 'Finance tracker' }, - { memoryId: 'b', content: 'The finance tracker uses Supabase for the backend database layer.', embedding: embeddings[1], importance: 0.6, keywords: ['Supabase'], headline: 'Supabase backend' }, - { memoryId: 'c', content: 'Tailwind CSS handles all styling in the finance tracker project.', embedding: embeddings[2], importance: 0.5, keywords: ['Tailwind'], headline: 'Tailwind styling' }, - { memoryId: 'd', content: 'User plans to deploy the finance tracker on Vercel hosting platform.', embedding: embeddings[3], importance: 0.5, keywords: ['Vercel'], headline: 'Vercel deployment' }, + { memoryId: 'a', content: 'User is building a React application. It tracks personal finances.', embedding: embeddings[0], importance: 0.7, keywords: ['React'], headline: 'Finance tracker' }, + { memoryId: 'b', content: 'The backend uses Supabase. It provides the database layer.', embedding: embeddings[1], importance: 0.6, keywords: ['Supabase'], headline: 'Supabase backend' }, + { memoryId: 'c', content: 'Tailwind CSS handles styling. The project uses utility classes.', embedding: embeddings[2], importance: 0.5, keywords: ['Tailwind'], headline: 'Tailwind styling' }, ]; const composites = buildComposites(facts); expect(composites.length).toBe(1); const composite = composites[0]; - // With 4 sentences joined, generateL1Overview should truncate to first 2-3 + // 6 sentences joined; generateL1Overview truncates to first 3 expect(composite.overview).not.toBe(''); expect(composite.overview.length).toBeLessThan(composite.content.length); }); From efe96347b473086833f96d7fb60c93b5b3d1b41f Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 01:14:54 -0700 Subject: [PATCH 56/59] refactor(lineage): remove config singleton from memory-lineage.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last caller not passing config — consolidation-service.ts:139 — now passes { claims, config } into emitLineageEvent. With all six callers supplying config explicitly, LineageDeps.config is made required (was optional), the lineageActorModel fallback to the module singleton is removed, and the `import { config }` is dropped. memory-lineage.ts is now fully config-singleton-free. Regression gate threshold ratcheted from 36 to 35. --- src/__tests__/config-singleton-audit.test.ts | 5 +++-- src/services/consolidation-service.ts | 2 +- src/services/memory-lineage.ts | 5 ++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/__tests__/config-singleton-audit.test.ts b/src/__tests__/config-singleton-audit.test.ts index da3b6a6..ab020ad 100644 --- a/src/__tests__/config-singleton-audit.test.ts +++ b/src/__tests__/config-singleton-audit.test.ts @@ -24,11 +24,12 @@ const SRC = resolve(__dirname, '..'); * Maximum allowed non-test source files that bind the runtime config * singleton value from config.js. Ratchet this DOWN after each * config-threading PR lands. - * Current baseline: 36 files (as of feat/phase-3-config-audit HEAD 560caa6). + * Current baseline: 35 files (memory-lineage.ts removed after all + * callers pass config explicitly). * Includes multi-import forms (e.g. `import { config, updateRuntimeConfig }`) * and re-exports (e.g. `export { config } from`). */ -const MAX_SINGLETON_IMPORTS = 36; +const MAX_SINGLETON_IMPORTS = 35; /** * Matches any import or re-export that binds the `config` value (not diff --git a/src/services/consolidation-service.ts b/src/services/consolidation-service.ts index 361f775..f56217b 100644 --- a/src/services/consolidation-service.ts +++ b/src/services/consolidation-service.ts @@ -136,7 +136,7 @@ export async function executeConsolidation( }, }); - await emitLineageEvent({ claims }, { + await emitLineageEvent({ claims, config }, { kind: 'consolidation-add', userId, memoryId: consolidatedId, diff --git a/src/services/memory-lineage.ts b/src/services/memory-lineage.ts index 8ed0c31..98ed47f 100644 --- a/src/services/memory-lineage.ts +++ b/src/services/memory-lineage.ts @@ -14,7 +14,6 @@ * - routing lineage-bypassing paths through claim versions */ -import { config } from '../config.js'; import type { ClaimSlotInput } from '../db/claim-repository.js'; import type { IngestRuntimeConfig } from './memory-service-types.js'; import type { ClaimTarget, FactInput } from './memory-service-types.js'; @@ -87,7 +86,7 @@ type LineageClaimsPort = { type LineageDeps = { claims: LineageClaimsPort; repo?: MutationCanonicalObjectRepo; - config?: Pick; + config: Pick; }; type BackfillMemory = { @@ -371,5 +370,5 @@ function requireRepo(deps: LineageDeps): MutationCanonicalObjectRepo { } function lineageActorModel(deps: LineageDeps): string { - return deps.config?.llmModel ?? config.llmModel; + return deps.config.llmModel; } From dbcd210c54c029cd5d9e181ce143840587fbd132 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 01:16:32 -0700 Subject: [PATCH 57/59] test: add max-cluster-size cap assertion for compositeGrouping fe24be4 restored compositeMaxClusterSize to the test mock but had no test exercising the cap. Adds one case: 5 similar facts with cap=3, asserts no cluster exceeds 3 members and all 5 facts are accounted for across the resulting composites. --- .../__tests__/composite-grouping.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/services/__tests__/composite-grouping.test.ts b/src/services/__tests__/composite-grouping.test.ts index d11bdec..683aacc 100644 --- a/src/services/__tests__/composite-grouping.test.ts +++ b/src/services/__tests__/composite-grouping.test.ts @@ -116,6 +116,31 @@ describe('buildComposites', () => { expect(composites[0].memberMemoryIds).not.toContain('c'); }); + it('caps cluster size at compositeMaxClusterSize', () => { + // 5 highly similar facts — without the cap all 5 would land in one cluster. + // With compositeMaxClusterSize=3 the first cluster fills to 3 and the + // remaining 2 spill into a second cluster (which meets minClusterSize=2). + const embeddings = similarEmbeddings(7, 5); + const facts: CompositeInput[] = embeddings.map((emb, i) => ({ + memoryId: String.fromCharCode(97 + i), + content: `Similar fact number ${i + 1} about the same topic.`, + embedding: emb, + importance: 0.5, + keywords: ['topic'], + headline: `Fact ${i + 1}`, + })); + + const composites = buildComposites(facts); + + // Every composite must respect the cap + for (const composite of composites) { + expect(composite.memberMemoryIds.length).toBeLessThanOrEqual(3); + } + // All 5 facts should still be accounted for across composites + const allMembers = composites.flatMap((c) => c.memberMemoryIds); + expect(allMembers.sort()).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + it('returns empty array when fewer facts than minClusterSize', () => { const facts: CompositeInput[] = [ { memoryId: 'a', content: 'Single fact.', embedding: fakeEmbedding(1), importance: 0.5, keywords: [], headline: 'Single' }, From 903a4093c5de68646c3e69dc1b3f8230aefda534 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 01:27:29 -0700 Subject: [PATCH 58/59] docs(test): refresh config seam truthfulness notes --- src/__tests__/config-singleton-audit.test.ts | 7 ++--- src/app/runtime-container.ts | 28 +++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/__tests__/config-singleton-audit.test.ts b/src/__tests__/config-singleton-audit.test.ts index ab020ad..fdf3f03 100644 --- a/src/__tests__/config-singleton-audit.test.ts +++ b/src/__tests__/config-singleton-audit.test.ts @@ -24,12 +24,13 @@ const SRC = resolve(__dirname, '..'); * Maximum allowed non-test source files that bind the runtime config * singleton value from config.js. Ratchet this DOWN after each * config-threading PR lands. - * Current baseline: 35 files (memory-lineage.ts removed after all - * callers pass config explicitly). + * Current baseline: 34 files after the ingest/lineage config-threading + * cleanup removed those last singleton reads from `memory-lineage.ts` + * and `memory-ingest.ts`. * Includes multi-import forms (e.g. `import { config, updateRuntimeConfig }`) * and re-exports (e.g. `export { config } from`). */ -const MAX_SINGLETON_IMPORTS = 35; +const MAX_SINGLETON_IMPORTS = 34; /** * Matches any import or re-export that binds the `config` value (not diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index eef99f3..b9b9fb4 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -29,13 +29,16 @@ import { MemoryService } from '../services/memory-service.js'; * it describes the config surface already threaded through those seams * today, without claiming full runtime-wide configurability yet. * - * NOTE (phase 1a.5): `runtime.config` currently references the same - * module-level singleton that routes, services, and the search pipeline - * all read from directly. There is no per-runtime config copy yet — - * consumers cannot construct two runtimes with different configs because - * the deeper service code (25+ `import { config }` sites across - * routes/, services/) reads the module singleton regardless. Phase 1B - * will thread config through properly and reintroduce a genuine override. + * NOTE (phase 1a.5): `runtime.config` still references the module-level + * singleton. This branch has hardened several explicit seams around that + * singleton — route-layer reads now go through `configRouteAdapter`, + * `MemoryService` receives config explicitly from the composition root, + * and the search/ingest lanes thread narrower runtime subsets through + * their already-wired call paths. But there is still no per-runtime + * config copy or override: many deeper services and repositories still + * import `config` directly, so constructing two runtimes with different + * configs would remain dishonest until a later phase removes those + * singleton reads. */ export interface CoreRuntimeConfig { adaptiveRetrievalEnabled: boolean; @@ -126,12 +129,11 @@ export interface CoreRuntimeConfigRouteAdapter { * `pool` is required — the composition root never reaches around to * import the singleton `pg.Pool` itself. * - * A `config` override is deliberately NOT accepted here. Most downstream - * route and service code still reads config directly from the module - * singleton, so any override passed here would only influence repo - * construction (`entityGraphEnabled`, `lessonsEnabled`) and silently be - * ignored everywhere else — a dishonest contract. Phase 1B will thread - * config through routes and services and reintroduce a genuine override. + * A `config` override is deliberately NOT accepted here. The container + * now owns several explicit config seams, but many downstream services + * and repositories still read the module singleton directly. Accepting + * an override here would therefore apply only partially and misstate the + * current architecture. */ export interface CoreRuntimeDeps { pool: pg.Pool; From 56f37a645e958e770e861e01ab3e41604389834f Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 01:31:59 -0700 Subject: [PATCH 59/59] test(lineage): prove consolidation forwards runtime config --- .../memory-crud-runtime-config.test.ts | 67 +++++++++++++++++++ src/services/consolidation-service.ts | 6 +- src/services/memory-crud.ts | 2 +- 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 src/services/__tests__/memory-crud-runtime-config.test.ts diff --git a/src/services/__tests__/memory-crud-runtime-config.test.ts b/src/services/__tests__/memory-crud-runtime-config.test.ts new file mode 100644 index 0000000..25e29a4 --- /dev/null +++ b/src/services/__tests__/memory-crud-runtime-config.test.ts @@ -0,0 +1,67 @@ +/** + * Runtime config seam tests for memory-crud consolidation delegation. + * + * Verifies that the service-layer CRUD helper forwards deps.config into the + * consolidation execution seam instead of letting lineage fall back to the + * module singleton. + */ + +import { describe, expect, it, vi } from 'vitest'; + +const { mockExecuteConsolidation } = vi.hoisted(() => ({ + mockExecuteConsolidation: vi.fn().mockResolvedValue({ + clustersConsolidated: 0, + memoriesArchived: 0, + memoriesCreated: 0, + consolidatedMemoryIds: [], + }), +})); + +vi.mock('../../config.js', () => ({ + config: { + auditLoggingEnabled: false, + decayRetentionThreshold: 0.5, + decayMinAgeDays: 30, + }, +})); +vi.mock('../consolidation-service.js', () => ({ + findConsolidationCandidates: vi.fn(), + executeConsolidation: mockExecuteConsolidation, +})); +vi.mock('../memory-lifecycle.js', () => ({ + evaluateDecayCandidates: vi.fn(), + checkMemoryCap: vi.fn(), +})); +vi.mock('../audit-events.js', () => ({ emitAuditEvent: vi.fn() })); +vi.mock('../deferred-audn.js', () => ({ + shouldDeferAudn: vi.fn(), + deferMemoryForReconciliation: vi.fn(), + reconcileUser: vi.fn(), + reconcileAll: vi.fn(), + getReconciliationStatus: vi.fn(), +})); +vi.mock('../claim-slotting.js', () => ({ + buildPersistedRelationClaimSlot: vi.fn(), +})); + +const { performExecuteConsolidation } = await import('../memory-crud.js'); + +describe('memory-crud runtime config seam', () => { + it('passes deps.config into executeConsolidation', async () => { + const deps = { + repo: { kind: 'repo' }, + claims: { kind: 'claims' }, + config: { llmModel: 'runtime-llm' }, + } as any; + + await performExecuteConsolidation(deps, 'user-1'); + + expect(mockExecuteConsolidation).toHaveBeenCalledWith( + deps.repo, + deps.claims, + 'user-1', + undefined, + deps.config, + ); + }); +}); diff --git a/src/services/consolidation-service.ts b/src/services/consolidation-service.ts index f56217b..1ba2e9a 100644 --- a/src/services/consolidation-service.ts +++ b/src/services/consolidation-service.ts @@ -17,6 +17,7 @@ import { config } from '../config.js'; import { MemoryRepository } from '../db/memory-repository.js'; import { ClaimRepository } from '../db/repository-claims.js'; import type { MemoryRow } from '../db/repository-types.js'; +import type { IngestRuntimeConfig } from './memory-service-types.js'; import { formClusters, type AffinityConfig, @@ -29,6 +30,7 @@ import { emitAuditEvent } from './audit-events.js'; import { emitLineageEvent } from './memory-lineage.js'; const DEFAULT_CONSOLIDATION_BATCH_SIZE = 200; +type ConsolidationRuntimeConfig = Pick; export interface ConsolidationConfig { /** Max memories to scan per consolidation run. */ @@ -96,8 +98,10 @@ export async function executeConsolidation( claims: ClaimRepository, userId: string, consolidationConfig?: Partial, + runtimeConfig?: ConsolidationRuntimeConfig, ): Promise { const candidates = await findConsolidationCandidates(repo, userId, consolidationConfig); + const lineageConfig = runtimeConfig ?? config; let memoriesArchived = 0; let clustersConsolidated = 0; @@ -136,7 +140,7 @@ export async function executeConsolidation( }, }); - await emitLineageEvent({ claims, config }, { + await emitLineageEvent({ claims, config: lineageConfig }, { kind: 'consolidation-add', userId, memoryId: consolidatedId, diff --git a/src/services/memory-crud.ts b/src/services/memory-crud.ts index 3bc38b0..4491d2c 100644 --- a/src/services/memory-crud.ts +++ b/src/services/memory-crud.ts @@ -97,7 +97,7 @@ export async function consolidate(deps: MemoryServiceDeps, userId: string): Prom /** Execute consolidation: synthesize clusters via LLM and archive originals. */ export async function performExecuteConsolidation(deps: MemoryServiceDeps, userId: string): Promise { - return executeConsolidation(deps.repo, deps.claims, userId); + return executeConsolidation(deps.repo, deps.claims, userId, undefined, deps.config); } /** Run deferred AUDN reconciliation for a user (background pass). */