# crates/wenlan-core

Applies to agents working under `crates/wenlan-core/`. Read alongside root `AGENTS.md`, which takes precedence on any topic not covered here.

All business logic lives here. No tauri, no axum. Framework-agnostic.

## Key Modules (`crates/wenlan-core/src/`)

| Module | Purpose |
|---|---|
| `db.rs` | `MemoryDB` — libSQL storage, vectors, chunks, hybrid search, embeddings, knowledge graph, migrations. Three search methods: `search_memory` (embedding+FTS+RRF), `search_memory_reranked` (+ LLM reranking after), `search_memory_expanded` (+ LLM query expansion before). Uses `EventEmitter` trait for UI notifications (no tauri). |
| `events.rs` | `EventEmitter` trait and `NoopEmitter` |
| `engine.rs` | `LlmEngine` — llama-cpp-2 wrapper, model download, inference loop, format helpers |
| `classify.rs` | Memory/profile classification via `LlmEngine` |
| `extract.rs` | Knowledge-graph extraction (entities, relations) via `LlmEngine` |
| `rerank.rs` | LLM reranker |
| `merge.rs` | Memory merging, pattern extraction, contradiction detection |
| `llm_provider.rs` | `LlmProvider` trait + `ApiProvider` (Anthropic API) + `OnDeviceProvider` shim |
| `llm_classifier.rs` | Higher-level classification orchestration |
| `refinery.rs` | Distill-cycle orchestration, dedup, auto-linking, consolidation |
| `post_ingest.rs` | Post-ingest enrichment (dedup check, entity linking, title enrich, recap, page growth) |
| `pages.rs` | Type definitions for the `Page` struct (synthesized wiki entries distilled from memory clusters). Actual clustering + distillation live in `db.rs` + `refinery.rs`. SQL tables are `pages`/`page_sources` (renamed from `concepts`/`concept_sources` in migration 46). |
| `spaces.rs` | Spaces / tag store |
| `narrative.rs` | Profile narrative assembly (editorial prose) |
| `briefing.rs` | Daily briefing assembly |
| `working_memory.rs` | Working memory builder |
| `access_tracker.rs` | Memory access counts + time decay |
| `contradiction.rs` | Contradiction detection |
| `context_packager.rs` | Context bundle → prompt packaging |
| `importer.rs` | File importer pipeline |
| `quality_gate.rs` | Pre-store quality gate |
| `tuning.rs` | Tuning config (distill cycles, distillation, weights) |
| `schema.rs` | Memory schema definitions (formerly `memory_schema.rs`) |
| `prompts/` | Prompt registry (defaults + override dir loader) |
| `chunker/` | Code-aware, Markdown-aware, fixed-size chunking |
| `sources/` | `RawDocument`, file watchers, Obsidian importer. `RawDocument` and related types re-exported from `wenlan-types`. |
| `privacy.rs` | PII redaction |
| `router/classify.rs`, `content_score.rs` | Smart router scoring helpers (non-tauri parts) |
| `config.rs` | Persistent config at `dirs::data_local_dir()/origin/config.json` (on macOS, `~/Library/Application Support/wenlan/config.json`) |
| `export/` | Markdown/JSON/zip/PDF exporters |
| `eval/` | Benchmark harness: LoCoMo, LongMemEval. Each benchmark has base (embedding-only), reranked (LLM rescores after search), and expanded (LLM query expansion before search) variants. Baselines under `EVAL_BASELINES_DIR` (gitignored). See `crates/wenlan-core/src/eval/AGENTS.md`. |
| `state.rs` | `CoreState` — shared state struct used by wenlan-server |

## Retrieval, LLM throughput & consolidation env flags

> Moved from root `AGENTS.md` (2026-06-23) per the agents.md hierarchical convention: these are wenlan-core internals, loaded only when working in this crate. `drift_guard` teeth #2 scans every tracked `*AGENTS.md`, so the `WENLAN_*` flag-doc contract still holds.

### On-device LLM throughput flags
- `WENLAN_LLM_SLOT_BACKFILL`: continuous-batch slot backfill for the on-device LLM (`OnDeviceProvider`). **DEFAULT OFF (opt-in; enable with `1`/`true`/`yes`/`on`).** When ON, a continuous-batch call may drain more than `m` (= `WENLAN_LLM_PARALLEL_SEQS`) immediately-available requests and the engine keeps all `m` KV slots full by backfilling the next queued request the moment a slot finishes — so decode width stays at `m` instead of raggedly draining `m`→1 as short outputs (classify ~20 tok) finish before long ones (entity ~100 tok). Fixes the decode-bound enrichment throughput floor (the seed enrichment batch measured 40% prefill / 59% decode; the 59% is this ragged drain). Pure throughput optimization, semantically-equivalent outputs (same prompts, same per-request sampler seeds; cross-slot KV is cleared on reuse). The drain cap is `m * 4` (m≤8 → ≤32), bounding per-call latency since a drained batch's outputs return together. **Default-OFF** because it is a structural rewrite of the SHARED inference path that CI cannot validate on Metal; enable it for throughput-bound paths (bulk ingest, eval seed firehose) where the latency-return-together tradeoff is acceptable. When OFF (the default) the drain caps at `m` — one slot per request, byte-identical to the pre-backfill engine. The no-overflow path (queue ≤ `m`, e.g. a single memory's enrichment) never backfills regardless of the flag. Parsed by `slot_backfill_enabled()` in `crates/wenlan-core/src/llm_provider.rs`; the engine scheduling lives in `LlmEngine::run_inference_continuous_batch` (`engine.rs`, `BackfillScheduler`). Follow-ups before any default-ON flip: separate live/bulk queues (or priority gating) so live enrichment can never inherit firehose head-of-line latency, and stochastic/cancellation/timeout fuzzing beyond the GPU grounding oracle. Correctness validated by `eval::engine_throughput::tests::backfill_grounding` (KV-reuse cross-contamination oracle, L7 GPU) + the wall-clock A/B `backfill_throughput_ab`.
- `WENLAN_LLM_PREFIX_KV_CACHE`: prefill-side prefix-KV cache for the on-device LLM continuous batch (`OnDeviceProvider`). **DEFAULT OFF (opt-in; enable with `1`/`true`/`yes`/`on`).** When ON, `LlmEngine::run_inference_continuous_batch` (`engine.rs`) detects the longest token prefix shared by every sequence in the batch (the fixed chatml system prompt + task framing — ~80% of a ~225-token enrichment prompt), primes that prefix's KV **once** into seq 0, fans it to the other slots via `copy_kv_cache_seq`, and then prefills only each request's unique suffix at positions `[prefix_len, ..)`. The per-slot pre-prefill clear preserves `[0, prefix_len)` (clears only `[prefix_len, ..)`), so a slot-backfilled request reuses the resident prefix too — the prefix is encoded exactly once for the whole batch. Attacks the **prefill** half of the enrichment batch (measured 40% prefill / 59% decode via `[batch_timing]`); complementary to `WENLAN_LLM_SLOT_BACKFILL`, which attacks the decode half. Honest ceiling ~1.5x on the prefill portion, not the whole batch. **Semantically equivalent to a full per-seq prefill** — a prefix token's KV is causally closed and batch-composition-independent in exact arithmetic, so the algebra is identical; as with all batched GPU inference, intermediate logits can still differ at the ULP level (greedy decode absorbs this — the L7 equivalence oracle asserts byte-identity at temperature 0). A pure throughput optimization, not a semantic change. The cache engages only on TRANSFORMER models (gated off when `model.is_recurrent()` or `model.is_hybrid()` — e.g. Qwen3.5-9B's DeltaNet hybrid layers, whose compressed recurrent state is not the position-addressable per-token K/V that prefix sharing requires), and only when there are ≥ 2 sequences, ≥ 2 KV slots, and the shared prefix clears `PREFIX_KV_MIN_TOKENS` (32); otherwise, and on any priming failure, it falls back to byte-identical full per-seq prefill (`prefix_len = 0`). **Default-OFF** because, like slot backfill, it mutates the SHARED on-device inference path's KV handling, which CI cannot validate on Metal. Parsed by `prefix_kv_cache_enabled()`; the prefix-length seam is `reusable_prefix_len()` / `longest_common_prefix_len()` in `crates/wenlan-core/src/engine.rs` (pure, unit-tested). Correctness validated by `eval::engine_throughput` (fresh-vs-cached equivalence oracle, L7 GPU) + the `[batch_timing]` `prime_ms`/`prefill_ms` A/B.

### Retrieval env flags

- `WENLAN_RERANKER_MODEL` — cross-encoder model selector for the opt-in CE rerank path (`WENLAN_RERANKER_ENABLED=1` on the daemon; CE itself stays default-OFF). DEFAULT (unset/unrecognized) = `bge-base` (BGERerankerBase, 1.1GB) since 2026-06-11: LME paired sweep PR #260 (scaffold N=1, n=479) measured it at 94% of v2-m3's NDCG lift (+0.1058 vs +0.1130 agg, both BH-sig, all categories above the A/A floor) at 29% of its marginal P50 cost (dP50 +318ms vs +1094ms; on-arm P50 510ms vs 1279ms CPU). `bge`/`bge-v2-m3` = quality ceiling for users who accept ~1.3s P50; `turbo`/`jina-turbo`/`jina` = JINARerankerV1TurboEn (146MB, +20ms P50, 71% of v2-m3's lift — the latency-critical pick). History: the 2026-06-04 LoCoMo model A/B (channels OFF, older substrate) ranked base WORST (0.500 vs turbo 0.569 vs v2-m3 0.612) — the base-vs-turbo ordering is fixture-dependent; the default decision trusts the LME sweep on the current harness/substrate per user call 2026-06-11. v2-m3-vs-base ordering is consistent on both. BYO override: `WENLAN_RERANKER_ONNX_DIR` (+ `WENLAN_RERANKER_MODEL_ID`) bypasses the selector entirely.
- `WENLAN_RERANKER_MODE` — cross-encoder activation mode: `off` (DEFAULT) | `lite` | `full` (PR-B). Selects which retrieval paths get a CE and which model, wired at the daemon HANDLER layer (never inside core `search_memory`, so internal callers `search_corrections_by_topic` + `verify_page` stay CE-free). `off` = byte-identical to no rerank. `lite` = turbo (jina-turbo, ~146MB) on the quick (`/api/search`) + context (`/api/chat-context`) paths AND on an explicit `rerank=true` deep search. `full` = turbo on quick/context + the heavy bge-base (~1.1GB, pool-widened via `compute_rerank_fetch_pool`) on the explicit `rerank=true` deep path; the deep model loads in the BACKGROUND so startup never blocks on the download (rerank=true falls back to plain hybrid until it is ready). The `rerank` request flag on `/api/memory/search` keeps its contract (`true` = rerank when a model is wired, `false` = plain hybrid) — the mode never inverts it. Legacy `WENLAN_RERANKER_ENABLED=1` with `WENLAN_RERANKER_MODE` unset = deep-only CE using `WENLAN_RERANKER_MODEL` (exactly the pre-mode behavior, blocking startup); an explicit mode wins over the legacy switch. **Config-backed + persistent:** set it set-and-forget with `wenlan reranker <off|lite|full>` (writes `config.json reranker_mode`, mirroring `wenlan model` / `wenlan key`); the daemon reads it at startup. Effective-mode precedence is `WENLAN_RERANKER_MODE` env (dev/override — wins only when non-empty; an empty/unset value falls through to config, matching the daemon's other empty-as-unset knobs) > `config.json reranker_mode` (persistent user setting) > `off`. Per-path state is surfaced on `/api/status` (`reranker` = deep, `reranker_light` = quick+context, `reranker_mode`) and in `wenlan status` / `wenlan doctor`. Parsed by `reranker_mode_resolved()` (env+config resolution) / `reranker_mode_from_str()` (pure parse) + `resolve_reranker_plan()` in `crates/wenlan-core/src/reranker.rs`; the handler rerank step is `rerank_results_light()` in `crates/wenlan-core/src/db.rs`. CE model weights are a user-downloadable asset (the ~1.1GB bge-base cold download is the UX gate).
- `WENLAN_ENABLE_TEMPORAL_SOFT_BOOST` — opt-in (default OFF). Multiplicatively BOOSTS in-window dated memories by `(1 + WENLAN_TEMPORAL_BONUS)` while leaving outside-window / undated / no-cue rows neutral (×1.0, never dropped). Gentler successor to the `WENLAN_ENABLE_TEMPORAL_FILTER` hard filter; the two are mutually exclusive (soft takes precedence when both are set). Measured CLOSED-NULL on LME-S (2026-06-09, paired A/B `temporal_soft_cached` / `temporal_filter_cached` arms, n=479, event_date 100% live): n_touched 15/479 and 10/479, agg deltas inside noise, temporal-reasoning slightly negative. Attribution is structural, not substrate: `temporal_query::extract_cue` recognizes only deictic now-anchored phrasing (yesterday / last week / N days ago / since YYYY), while LME temporal-reasoning questions are event-anchored ordering/arithmetic ("which did I do first, A or B", "how many days between A and B") — the cue gate fires on ~3% of queries, and the few deictic hits compute windows against the eval wall-clock, not the 2022-23 memory dates. Re-measuring with a bigger `WENLAN_TEMPORAL_BONUS` cannot change this; the lever for the TR category is multi-anchor retrieval (query decomposition, #11), not window boosting. FUTURE NOTE (fair re-test, parked): the closure is for the flags AS-WIRED, and the A/B carries a clock-era confound — read-side cue windows anchor to the eval wall-clock (2026) while memory event_dates are 2022-23, so even the few fired rows could not land in-window, and write-side grounding (T11) anchors to `last_modified` = seed-import time, same era mismatch. A fair re-test needs era-aligned clocks: per-question `now` = the question's own timestamp on the read side, and event-clock (not import-clock) grounding on the write side. The ~3% cue fire rate is regex-only and clock-independent, so the phrasing gap dominates regardless of the confound.
- `WENLAN_TEMPORAL_BONUS` — additive bonus for the soft boost (default `0.5`). Clamped non-negative: a negative or non-finite value falls back to neutral (`0.0`) / the default so the boost can only lift a row, never demote it.
- `WENLAN_ENABLE_INTENT_LLM` — opt-in (default OFF). On the deep/expanded path (`search_memory_expanded`), the existing query-expansion LLM call emits a structured intent object `{expansions, use_graph, entities, temporal_window, subqueries}` instead of a plain array of rephrasings (one call, `temperature=0`, per-field-tolerant parse via `engine::extract_json`, fallback to the keyword `classify_query` gate on timeout / error / unparseable). Slice-1 wires only `use_graph` into the deep-path graph gate (through the `graph_override` arg on `search_memory_with_cue`); `entities`, `temporal_window`, and `subqueries` are emitted and logged only (parked: `entities`→#10 graph traversal, `temporal_window`→#13, `subqueries`→#11). DISTINCT from the shipped zero-LLM T19 `WENLAN_ENABLE_QUERY_INTENT` (channel-weight classifier, `__query_intent` baseline suffix); never reuse that flag for the LLM emitter or eval baselines confound. Wiring + scope: `search_memory_expanded` is an expansion path exercised by the eval harness and the dormant `search_memory_routed` strategy (`WENLAN_LLM_ROUTE`, default OFF); the live daemon calls `search_memory` (quick) + `search_memory_cross_rerank` (deep), so slice-1 wires and PROVES the `use_graph` signal on the expanded path, while deploying it into the live deep path is a downstream step. Reconciliation with existing prototypes (all default-OFF, all off the live path): the T7 strategy router (`retrieval/route.rs` `classify_strategy` / `parse_strategy`, `WENLAN_LLM_ROUTE`, dispatched by `search_memory_routed`) and the query-decomposition parser (`retrieval/decompose.rs` `parse_subqueries`, used by the `search_memory_decomposed` prototype); the `subqueries` field deliberately reuses `decompose.rs`'s JSON-array shape so #11 can consume it without re-contracting. Measurement caveat: the paired probe's ON arm feeds the intent object's expansions into RRF while the OFF arm feeds the legacy array-rephrasing expansions, so the A/B contrasts the whole intent pipeline against the legacy pipeline, not `use_graph` in isolation; read a positive result as enable-the-intent-pipeline and add a third arm (intent-expansions + keyword gate) to attribute the delta to routing alone. Never surfaced on the MCP `recall` tool; the daemon owns retrieval routing.
- `WENLAN_GRAPH_MEMORY_STREAM` — DEFAULT ON since 2026-06-10 (opt out with `0`/`false`/`no`/`off`; unset or any other value = ON). Live entity->memory RRF stream on the quick path (`search_memory`): query-anchored non-person entities with `memory_entities` degree <= hub cap contribute their linked memories as an extra RRF term (boost-only by default; surface-new stays opt-in). Receipt: +0.0545 NDCG@10 agg, BH-sig, paired A/B on cached canonical DBs (2026-06-09). The CE deep path skips the stream ONLY under a live rescore (`allow_graph_stream = reranker.is_none()` at the `search_memory_cross_rerank_cued` pool fetch): the stream×rerank stack measured not significant (+0.0126, p=0.17), so the stream's value is the non-rerank path; a rerank call without a configured reranker degrades to plain hybrid search WITH the stream (byte-identical to base). The skip-preference bypass keeps the stream allowed (stays byte-identical to the base `search_memory` baseline). Paired-A/B note: OFF arms must set `0` explicitly (unset = ON), and the `graph_seed` arm pins the stream to `0` on both arms (seed branch is unreachable while the stream is engaged). The `rerank_graph_stack` arm is retired — with the per-path skip, both its arms are byte-identical by construction. HAZARD: `WENLAN_GRAPH_SURFACE_NEW` (opt-in) inserts graph-only rows WITHOUT re-applying memory_type/space/source_agent filters (`get_memories_for_entities` filters only entity link + source='memory' + chunk 0), i.e. a space-scoped search can surface cross-space rows — the same disclosure class the page/episode/fact channels gate; keep it OFF on multi-space data until an overlap gate lands (follow-up). Note on k-hop interaction: with the stream engaged, the opt-in `WENLAN_ENABLE_GRAPH_KHOP` branch is unreachable on the quick path (stream early-returns before k-hop); set `WENLAN_GRAPH_MEMORY_STREAM=0` to measure k-hop in isolation, mirroring the `graph_seed` note above.
- `WENLAN_RERANK_SKIP_PREFERENCE` — opt-in (default OFF). On the cross-encoder path (`search_memory_cross_rerank_cued`), preference/recommendation-seeking queries (per `router::classify::is_preference_query` — request-form keywords like "recommend"/"any tips", vetoed by past-recall markers like "you recommended"/"remind me") bypass the CE entirely and return the base `search_memory_with_cue` ranking, byte-identical to the non-rerank baseline. History: built against an older "CE hurts single-session-preference −0.155 NDCG@10" measurement that did NOT reproduce on either current seeded substrate — paired A/Bs at n=479 measured CE *helping* SSP (+0.027 on both the canonical and the re-seeded DB) and the bypass net-negative (−0.0117 agg, BH-sig). Ships as a tested, default-OFF escape hatch, NOT a recommended setting; the CE base-vs-on case itself re-verified at +0.178 NDCG@10 agg (BH-sig, positive every category, N=2 substrates) with P50 1165ms vs 111ms base (CPU BGE-reranker-v2-m3) — the default-ON decision stays gated on the smaller-CE-model benchmark. The keyword lists were validated against the full LME-S fixture: 30/30 SSP detected, 0 false positives across the other 470 questions; generalization beyond the fixture is heuristic, same trust level as the temporal/relational keyword gates. Paired A/B arm: `rerank_skip_pref` in `paired_ab_emit` (CE path, flag toggled). The `rerank_graph_stack` arm (same test) toggled `WENLAN_GRAPH_MEMORY_STREAM` with the CE active on both arms to measure graph×rerank composition — measured NOT significant (+0.0126, p=0.17): graph_stream's +0.0545 quick-path gain is subsumed under the CE (except temporal-reasoning, +0.037), so the two levers do not stack. That arm is retired as of 2026-06-10; the code now hard-skips the stream under a live reranker (`allow_graph_stream = reranker.is_none()`), making both arms byte-identical by construction.
- `WENLAN_ENABLE_ENTITY_SWEEP` — DEFAULT ON (opt out with `0`/`false`/`no`/`off`). Gates the background 30-min entity-enrichment sweep that backfills `memory_entities` over existing memories via the configured LLM. Disabling it stops the automatic LLM spend/compute on a large corpus; the graph stream then only sees links created at write time. Parsed by `wenlan_core::db::entity_sweep_enabled()`, checked in `scheduler.rs` before the sweep fire-condition.

### Consolidation demotion (P3, always-on, no flag)

`compute_distill_demotion` (db.rs) multiplies a distilled memory's FINAL cross-rerank score by 0.7 at distill time, recovering linearly to 1.0 over 5 days (`memories.last_distilled_at` stamp, set by both distill paths + the scoped-match attach). Applied POST-CE in `search_memory_cross_rerank_cued` so the cross-encoder score overwrite cannot erase it (adversarial finding C1); the quick path (`search_memory`) and `/api/context` surface are untouched by design. Eval verdict (2026-06-10, SHIPPED): paired A/B, N=3/arm, both arms same binary on freshly re-seeded cached scenario DBs (`seed_scenario_dbs_complete` with the P3 binary; stamps live: LoCoMo 206 source_ids/18 new pages, LME 45/2; all runs within 7h of seeding, well inside the 5-day window), BGE-Base-EN-v1.5-Q + CPU bge-reranker-v2-m3, page-channel ON. LoCoMo (byte-deterministic across runs): NDCG@10 −0.07pt, recall@5 +0.12pt, coverage unchanged. LME: NDCG@10 +0.29pt (ON 0.5979±0.0045 vs OFF 0.5950±0.0027), MRR +0.35pt, cov_blind −0.12pt (inside one OFF-stddev). All plan gate criteria PASS (coverage non-regressing within noise, NDCG within 1pt, `distilled_memory_demoted_but_reachable` green). Chat-context N=3 leg was SKIPPED as a structural null by construction: the `/api/context` eval path calls `search_memory` + `search_pages`, never the cross-rerank function where the demotion applies, so both arms are byte-identical there — running it would only launder a no-op as a "pass". Raw per-run baselines: gitignored `app/eval/baselines/{locomo,longmemeval}_p3ab_demotion_{ON,OFF}_run{1..3}.json`.
