Skip to content

feat(config): per-request config_override on memory routes#41

Merged
ethanj merged 3 commits intomainfrom
feat/per-request-config-override
Apr 23, 2026
Merged

feat(config): per-request config_override on memory routes#41
ethanj merged 3 commits intomainfrom
feat/per-request-config-override

Conversation

@ethanj
Copy link
Copy Markdown
Contributor

@ethanj ethanj commented Apr 23, 2026

Summary

Adds a per-request config_override mechanism that lets callers
pass a partial RuntimeConfig overlay on every memory API call.
Enables stateless per-request A/B, benchmark experimentation, and
external-caller tuning without env-var scattering, named-profile
proliferation, or container restarts between experiments.

Motivation

Research-side benchmark experiments (the AtomicMemory v3 parity
roadmap's Phase 1) need to vary retrieval config per experiment
without editing source or creating named profiles. The prior
approach tried a named balanced-parity profile; that was a
source edit in service of experimentation, which is the wrong
shape — config should be a parameter, not a commit. This PR moves
that config surface into the API.

Full design doc lives in the research repo at
atomicmemory-research/docs/core-repo/design/per-request-config-override.md.

What's in this PR

API contract

All four memory routes that share the body schemas
(POST /v1/memories/search, /search/fast, /ingest,
/ingest/quick) gain an optional config_override body field.

{
  "user_id": "alice",
  "query": "What did Alice say about Vite?",
  "limit": 5,
  "config_override": {
    "hybridSearchEnabled": true,
    "maxSearchResults": 12,
    "mmrLambda": 0.7
  }
}
  • Flat shape: keys match RuntimeConfig field names one-for-one.
  • Validation: Zod .strict().partial() — unknown keys reject with
    400; every field is optional.
  • Layering: effective = { ...startupConfig, ...override }.
  • Scope: just this request. No server state mutated.
  • config_override absent → zero-cost path; startup config used
    as-is.

Response headers (override-only)

Requests that carry an override emit three response headers so
callers can audit what config was applied:

  • X-Atomicmem-Config-Override-Applied: true
  • X-Atomicmem-Effective-Config-Hash: sha256:<hex> — sorted-key
    canonical JSON fingerprint.
  • X-Atomicmem-Config-Override-Keys: <sorted-csv> — flat list of
    keys present in the override.

Requests without an override emit none of the three — zero overhead
on that path.

ConfigOverride fields (45 total)

Covers every RuntimeConfig field already threaded through
MemoryServiceDeps. Split roughly 32 retrieval-side / 13
ingest-side. Deliberately excludes:

  • Nested-profile fields (rerankDepth, lexicalWeight,
    repairPrimaryWeight, repairRewriteWeight) — live under
    retrievalProfileSettings.* and aren't threaded through
    deps.config. Accepting them would silently no-op, violating
    the "no degraded mode" rule in CLAUDE.md.
  • Singleton-read fields (scoringWeight{Similarity,Importance, Recency}, repairSkipSimilarity) — leaf code reads these
    directly from the module singleton, not from deps.config.

Future landings that tune any of these will first lift the field
into the threaded config path (small additive change) and
automatically gain overlay support.

Implementation

  • src/schemas/memories.ts — new ConfigOverrideSchema;
    extended IngestBodySchema and SearchBodySchema with
    config_override?: ConfigOverride. Because /search/fast and
    /ingest/quick reuse those schemas, all four routes auto-gain
    support.
  • src/services/retrieval-config-overlay.ts (new, ~80 LOC) —
    applyConfigOverride, hashEffectiveConfig,
    summarizeOverrideKeys.
  • src/routes/memories.ts — each of the four route handlers
    parses the overlay, builds EffectiveConfig, sets the three
    headers, passes to the service.
  • src/services/memory-service.tsdepsFor() helper swaps
    deps.config for the call's duration. No global mutation.
  • memory-search.ts, memory-ingest.ts, search-pipeline.ts,
    ingest-fact-pipeline.ts, memory-service-types.ts — accept
    effectiveConfig parameter instead of reading the singleton.
  • OpenAPI regenerated.

Tests

  • 26 new assertions across three files: overlay unit tests,
    schema strictness tests, route-level integration tests covering
    all four routes × (with override, without override) × headers ×
    service threading × 400 rejection.
  • Full suite: 1067 tests pass, zero regressions.

Validation

End-to-end validated against the AtomicMemory R&D benchmark harness:

  1. Regression check (new core code, no override): full-mini
    3-run mean matched the pre-abstraction P1-H baseline within σ
    on all metrics. hit@5 0.147 ± 0.031 vs baseline 0.140;
    judge 0.560 ± 0.060 vs 0.540; p50 352 vs 365ms; p95 560 vs
    565ms. Confirms the abstraction is transparent to requests
    without overrides.
  2. P1-1 experiment (hybrid ON via override): headers confirmed
    present on all 150 QA calls (50 × 3 runs) with the expected
    effective-config hash. Gate passed per the roadmap's slice-
    aware rule: hypothesis slice (cat1 single-hop) +1.7pp judge,
    all controls within σ.

Full summary in the research repo at
memory-research/evaluation/baselines/p1-1-enable-hybrid-2026-04-23.md.

Test plan

  • npm test — 1067 pass
  • npm run build clean
  • npx tsc --noEmit clean
  • npm run generate:openapi — spec regenerated
  • Live-stack smoke via curl — all three headers emitted
    correctly with and without override
  • 3-run regression check on full-mini: within σ of prior
    baseline
  • P1-1 experiment 3-run: hypothesis-slice positive,
    controls within σ

Backward compatibility

Fully backward compatible. Existing callers unaffected:

  • config_override is optional on the body.
  • No response body shape changes.
  • Existing field-level env vars (HYBRID_SEARCH_ENABLED etc.)
    still override as before.

Not in scope / deferred

  • SDK plumbing (the external atomicmem-sdk package gaining
    configOverride) — deferred until an external customer asks.
    The internal research adapter has it; that's the only consumer
    today.
  • Deprecation of field-level env vars — separate follow-up.
  • Lifting nested/singleton fields (rerankDepth, etc.) onto the
    threaded config surface — each will happen alongside its
    respective Phase-1 landing that needs it.

🤖 Generated with Claude Code

ethanj added 3 commits April 23, 2026 00:39
Land the per-request config-override abstraction described in
`atomicmemory-research/docs/core-repo/design/per-request-config-override.md`.
Callers may now pass a flat `config_override` body field on all four
memory routes (search, search/fast, ingest, ingest/quick) to overlay
a shallow subset on top of the startup RuntimeConfig for the
duration of a single request. No server-side mutation; no global
state is touched.

Wire contract
-------------
- `config_override` is a flat object keyed one-for-one on
  `RuntimeConfig` field names (camelCase, matching the schema field
  list below).
- Validation is a Zod `.strict().partial()` — unknown keys reject
  with a 400 and never reach the service layer; every listed key is
  optional.
- Unknown-key typos surface immediately (e.g. snake_case spelling
  of a camelCase field), so silent no-ops are impossible.

Observability headers (emitted only when an override is present)
----------------------------------------------------------------
- `X-Atomicmem-Config-Override-Applied: true`
- `X-Atomicmem-Effective-Config-Hash: sha256:<hex>` (sorted-key
  canonical JSON fingerprint of the full effective config — lets
  callers correlate traces to a specific config snapshot).
- `X-Atomicmem-Config-Override-Keys: <sorted,comma,list>`

Implementation
--------------
- `src/schemas/memories.ts` adds `ConfigOverrideSchema` (44 fields,
  grouped retrieval/ingest for readability only — the overlay merges
  flat). `IngestBodySchema` and `SearchBodySchema` gain
  `config_override: ConfigOverrideSchema.optional()`, re-emitted
  as `configOverride` after the camelCase transform.
- `src/services/retrieval-config-overlay.ts` exports the three
  route-side primitives: `applyConfigOverride` (one-line shallow
  merge), `hashEffectiveConfig`, `summarizeOverrideKeys`.
- `src/services/memory-service.ts` accepts an optional
  `effectiveConfig` at every public method. A private `depsFor()`
  helper swaps it into `MemoryServiceDeps.config` for the call's
  duration; absent override returns the shared `this.deps` (zero
  allocation). All downstream pipeline code already reads
  `deps.config` so no leaf-module edits are needed.
- `src/routes/memories.ts` adds `applyRequestConfigOverride(res,
  override)` which validates-then-emits the three headers and
  returns the effective config (or undefined for the no-op path).
- `RuntimeConfig` is exported from `src/config.ts` so the overlay
  and schema can reference its field shape.

Coverage scope (intentionally narrower than RuntimeConfig)
----------------------------------------------------------
ConfigOverrideSchema covers exactly the fields threaded through
`MemoryServiceDeps.config` (`CoreRuntimeConfig + IngestRuntimeConfig`).
Fields read from the module-level `config` singleton by leaf modules
— scoring weights (`db/query-helpers.ts`), nested
`retrievalProfileSettings.*` (rerankDepth, lexicalWeight, the
repair-weight pair) — are deliberately omitted. Accepting overrides
for those would silently no-op, violating the workspace rule against
degraded modes. Future landings lift those fields onto top-level
`RuntimeConfig` as they become overlay-eligible.

Fields accepted (44 total, sorted):
  adaptiveRetrievalEnabled, agenticRetrievalEnabled, auditLoggingEnabled,
  audnCandidateThreshold, chunkedExtractionEnabled,
  compositeGroupingEnabled, compositeMinClusterSize,
  consensusExtractionEnabled, consensusExtractionRuns,
  consensusMinMemories, consensusValidationEnabled,
  crossEncoderEnabled, entityGraphEnabled, entitySearchMinSimilarity,
  entropyGateAlpha, entropyGateEnabled, entropyGateThreshold,
  fastAudnDuplicateThreshold, fastAudnEnabled, hybridSearchEnabled,
  iterativeRetrievalEnabled, lessonsEnabled, linkExpansionBeforeMMR,
  linkExpansionEnabled, linkExpansionMax, linkSimilarityThreshold,
  maxSearchResults, mmrEnabled, mmrLambda,
  namespaceClassificationEnabled, pprDamping, pprEnabled,
  queryAugmentationEnabled, queryAugmentationMaxEntities,
  queryAugmentationMinSimilarity, queryExpansionEnabled,
  queryExpansionMinSimilarity, repairConfidenceFloor,
  repairDeltaThreshold, repairLoopEnabled, repairLoopMinSimilarity,
  rerankSkipMinGap, rerankSkipTopSimilarity, trustScoreMinThreshold,
  trustScoringEnabled.

Tests
-----
- `src/services/__tests__/retrieval-config-overlay.test.ts` (9 tests)
  — merge semantics, hash stability under key reorder, sorted key
  summary.
- `src/schemas/__tests__/config-override.test.ts` (10 tests) —
  strict mode, empty-object accept, snake_case typo rejection,
  threading through IngestBodySchema + SearchBodySchema.
- `src/__tests__/memory-route-config-override.test.ts` (7 tests)
  — no-override zero-header path, three-header emission on
  override, effectiveConfig threading into scopedSearch /
  ingest / quickIngest, 400 on unknown keys, empty-object
  treated as no-override.

`npm test` → 109 files · 1067 tests pass.
`npm run build` → clean.
OpenAPI spec regenerated (44 new fields surfaced on the four
route schemas).
…eader

Relaxes the per-request config_override schema from an enumerated
`.strict().partial()` list of 45 fields to a permissive
`z.record(z.union([z.boolean(), z.number(), z.string(), z.null()]))`.
Adds a runtime unknown-key detector in the route handler that emits
`X-Atomicmem-Unknown-Override-Keys` and logs a server-side warning
when any override key doesn't match a known RuntimeConfig field.

**Why this change.** The enumerated-schema version coupled every new
overlay-eligible RuntimeConfig field to a core release. An
experimenter tuning a newly-added field would have to wait for core
to land a schema update before their config override could address
it. That defeats the purpose of a per-request config mechanism —
configuration should be decoupled from schema releases.

**New contract.**
- `config_override` accepts any object whose values are primitives
  (boolean, number, string, null). Keys are not enumerated.
- Unknown keys are carried through on the effective config merge
  AND surfaced on the response as
  `X-Atomicmem-Unknown-Override-Keys: <sorted-csv>` + a warning log.
- Typos don't 400 — they no-op visibly. Experimenters see the
  warning header and fix the typo.
- Nested objects and arrays still reject (override is flat by
  contract).
- Field-level type validation (e.g. "mmrLambda must be 0–1") is no
  longer enforced by the schema — that's the consumer's job at the
  point where it reads the value. A value that looks wrong silently
  misbehaves or throws downstream rather than 400-ing at the edge.

**Backward compatibility.**
- Requests that previously passed (all fields matched the enumerated
  set) still pass.
- Requests that previously 400'd on unknown keys now 200 with the
  warning header.
- Responses without override behavior unchanged.
- OpenAPI regenerated (`config_override` is now `additionalProperties`
  with primitive-or-null values).

**Tests updated.**
- `src/schemas/__tests__/config-override.test.ts`: strictness
  assertions replaced with permissive-shape assertions. New coverage
  for forward-compat (unknown keys accepted), primitive-value
  boundaries (objects/arrays rejected).
- `src/__tests__/memory-route-config-override.test.ts`: the "unknown
  key → 400" test became "unknown key → 200 + warning header + log";
  added new tests for "mix of known + unknown" and "all-known → no
  warning header".
- Full suite: 1071 tests pass (net +4 from new coverage).

**Design follow-up.** The schema is still the "static config override"
shape; genuine plugin-style runtime-loadable experiment modules (code
that participates in pipeline stages, not just knob values) remain a
future architectural choice. This landing unblocks Phase-1 experiments
without pre-committing to the enumeration anti-pattern on the public
API.

Updates PR #41 before merge / initial public release.
…SearchResults

The two search routes (/search, /search/fast) called resolveSearchPreamble()
before applyRequestConfigOverride(). resolveSearchPreamble() clamped
body.limit against configRouteAdapter.current().maxSearchResults — i.e. the
startup snapshot, computed BEFORE the per-request override merged in. A
request that raised maxSearchResults in its config_override would have its
effective-config-hash header advertise the new cap while the service
actually received a limit clamped to the startup cap.

Reorder: merge the override first, derive the effective maxSearchResults
(override ?? startup), then compute the clamped request limit from that.
Regression test added: override bumps max 20 → 50, request asks for 40,
service must receive 40, not 20.

Reported by Codex review.
@ethanj ethanj merged commit d360916 into main Apr 23, 2026
1 check passed
@ethanj ethanj deleted the feat/per-request-config-override branch April 23, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant