AGENTS.md@crates/wenlan-core · git:20260806.9d5eb29 · 2026-08-06 · sha256 55e0e54ebd72fd0f

AGENTS.md@crates/wenlan-core git:20260806.9d5eb29A

Immutable. This exact content is served forever at /api/v1/blob/55e0e54ebd72fd0f.

# 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/`)

Only the modules whose job is not evident from the file name. Read the directory for the rest.

| 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). |
| `engine.rs` | `LlmEngine` — llama-cpp-2 wrapper, model download, inference loop, format helpers |
| `merge.rs` | Memory merging, pattern extraction, contradiction detection |
| `llm_provider.rs` | `LlmProvider` trait + `ApiProvider` (Anthropic API) + `OnDeviceProvider` shim |
| `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). |
| `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) |
| `sources/` | `RawDocument`, file watchers, Obsidian importer. `RawDocument` and related types re-exported from `wenlan-types`. |
| `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`) |
| `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`. |

## Database (`MemoryDB`, `db.rs`)

> Moved from root `AGENTS.md` (index-and-pointer refactor): these are `wenlan-core` internals, loaded when working in this crate. Root keeps only the one-line architectural summary.

One libSQL database at the platform data directory (`dirs::data_local_dir()/origin/memorydb/origin_memory.db`; on macOS, `~/Library/Application Support/wenlan/memorydb/origin_memory.db`), owned by `MemoryDB` in `db.rs`:
- **Document chunks**: `chunks` table with `F32_BLOB(768)` vector column, DiskANN indexing (768-dim, BGE-Base-EN-v1.5-Q)
- **Knowledge graph**: `entities`, `relations`, `observations` tables with FK cascades
- **Full-text search**: FTS5 virtual table (`chunks_fts`) auto-synced via triggers
- **Hybrid search**: Vector similarity + FTS combined with Reciprocal Rank Fusion (RRF)

**Connection pattern**: `tokio::sync::Mutex<libsql::Connection>` — `libsql::Connection` is `Send` but not `Sync`, so it's wrapped in an async `Mutex` inside `MemoryDB`.

**Sharing pattern**: `MemoryDB` is wrapped in `Arc<MemoryDB>` at the state layer (`ServerState.db: Option<Arc<MemoryDB>>`). This lets handlers clone the `Arc` out of the `RwLock<ServerState>` guard and drop the guard before performing long-running operations.

## Events (`EventEmitter`, `events.rs`)

Instead of passing `tauri::AppHandle` into business logic, `wenlan-core` defines an `EventEmitter` trait — this keeps the crate framework-agnostic and testable with `NoopEmitter` in unit tests:

```rust
// crates/wenlan-core/src/events.rs
pub trait EventEmitter: Send + Sync {
    fn emit(&self, event: &str, payload: &str) -> Result<()>;
}
pub struct NoopEmitter;
```

- The daemon uses `NoopEmitter` (no UI to notify directly)
- The desktop app (separate `wenlan-app` repo) provides a `TauriEmitter` adapter that wraps `AppHandle::emit`
- `MemoryDB::new(db_path, emitter: Arc<dyn EventEmitter>)` takes the trait object

## Enrichment parity & eval-seed contract

> Moved from root `AGENTS.md` (index-and-pointer refactor): these name `wenlan-core` internals (`run_canonical_enrichment`, `eval/seed_contract.rs`), loaded when working in this crate.

### M5 reader inventory identity

`scripts/m5-reader-sweep.py --check` owns the executable page-prose reader
contract in `docs/plans/2026-07-27-m5-reader-manifest-inventory.md`. Its stable
identity is `short/path.rs::function[#ordinal]`; never put source line numbers
back into the committed block. Lines belong only in `--json` diagnostics, and
unowned relevant callsites fail closed. The required check includes mutation
controls for identity, visibility, depth/`via`, callers, and duplicates.

### Ingest-path parity (training-serving skew)
- **All post-store enrichment goes through `wenlan_core::ingest::run_canonical_enrichment`.** It is the ONE shared path for classify + extract + `apply_enrichment` + tags (Phase 1), entity/title/page enrichment (Phase 2), and dual-pool dedup/contradiction resolution (Phase 3). The server `handle_store_memory`, the eval seed pipeline, and the importer all call it. Do NOT re-implement a subset of enrichment in any consumer.
- **Why.** The eval seed used to re-implement a divergent subset (`enrich_db_for_eval` = entity + title + page only), so every new write-time feature (importance/T8, event_date/T11+T20, episode/T2, fact-channel/T15, dual-pool/T14, summary-nodes/T18) silently lagged in the eval path and shipped merged-but-inert, re-discovered as "starved" each eval cycle. Sharing the code makes seed-vs-production fidelity hold by construction. This is the standard fix for **training-serving skew** — Google "Rules of ML", Rule #32: *"Re-use code between your training pipeline and your serving pipeline whenever possible"* → *"eliminates a source of training-serving skew."* See also 12-Factor X (dev/prod parity) and the technical-debt framing (Cunningham, OOPSLA '92): the eval shortcut was debt never repaid.
- **New write-time feature checklist.** Add it inside `run_canonical_enrichment` (not in a consumer), then add a seed-completeness assert — a contract test (Fowler, `ContractTest`) — so the seed cache fails loud when the feature's artifact is missing rather than silently absent. A flag merged without its artifact present in the seed is unmeasurable.

### Eval seed + eval read: ONE route, ONE contract (no drift)

The recurring failure mode was not any single missing artifact — it was that seeding a cached scenario DB was a *scatter of manual STEP tests* (`seed_inject_event_dates`, `seed_backfill_classify`, entity sweep, `seed_backfill_episodes`, `distill_pages`) run by memory. Miss one and a channel ships starved, then a graph/temporal A/B over it returns a null that gets misread as "the channel doesn't help" — a lie about a dead substrate, re-discovered every cycle.

- **Seed side — the ONE route.** Re-seed cached scenario DBs with the orchestrator `seed_scenario_dbs_complete` (`crates/wenlan-core/tests/eval_harness.rs`). It runs every enrichment step in the correct order (event_date inject → classify → entity/`memory_entities` sweep → episodes → distill) then asserts `SeedExpectations::complete()`. **Never hand-run the individual `seed_*` STEP tests** — they are the orchestrator's internals. Run the one route and the seed is complete *and* contract-verified by construction.
- **Contract side — teeth, not prose.** `crates/wenlan-core/src/eval/seed_contract.rs` is the single liveness contract. `SeedExpectations::complete()` hard-fails the seed when a channel's substrate is empty: `memory_entities = 0` (graph), `event_date = 0` (temporal), `pages = 0` active (page channel), plus dupes + classification from `strict()`. These are *presence* checks (`> 0`), not coverage percentages — a percentage floor rots (see the L3/coverage note), but zero links means the channel is dead, which is the bug. `strict()` stays lenient (report-only) for minimal seeds; only `complete()` has teeth.
- **Eval side — refuse, don't lie.** The SAME contract gates the consumer: every per-query eval collector calls `seed_contract::assert_feature_substrate_live(conn, feature)` at entry. A graph A/B over a DB with zero `memory_entities` (or a temporal A/B with zero `event_date`, or a page-channel A/B with zero active `pages`) **errors loud** ("EVAL REFUSED") instead of emitting a null. Producer and consumer share one contract, so neither can drift onto a dead substrate.
- **Adding a write-time channel.** Add its step to `seed_scenario_dbs_complete`, its presence floor to `SeedExpectations` (+ wire it into `assert_feature_substrate_live` if it has an A/B), and a unit test in `seed_contract.rs`. The contract — not a runbook — is what keeps the seed honest.

## 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_DEVICE`: llama.cpp device policy on GPU-enabled builds: `auto` (DEFAULT), `cpu`, or a llama.cpp GPU device index. `auto` prefers a discrete GPU over an integrated GPU and accelerator, then greater free memory, then the lower stable index; this preserves macOS Metal accelerators while selecting discrete Vulkan adapters on mixed-GPU Windows systems. On Windows Vulkan, invalid selection and GPU model/context initialization failure visibly fall back by reloading the model with zero GPU layers. The effective backend/device/fallback is exposed on `/api/status`; Windows build setup and physical live-smoke steps live in `docs/windows-vulkan.md`. Linux stock builds remain CPU-only.
- `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.

### Background sweep & routing flags

The retrieval-channel and rerank flags moved to [`src/retrieval/AGENTS.md`](src/retrieval/AGENTS.md), which loads when working under that module. What follows gates background sweeps and compile routing.

- `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.
- `WENLAN_ENABLE_DOC_RECONCILE` — DEFAULT ON (opt out with `0`/`false`/`no`/`off`). Gates the background 30-min doc-reconcile sweep (doc-grounded revisions, L3): detects direct factual contradictions between ingested documents (`source_agent='folder'`) and agent captures, and stages human-gated rewrite+cite revisions on the existing pending-revisions queue (`/curate revisions`, `accept_revision`/`dismiss_revision`). Never mutates doc rows or captures; the human accept does. Bounded per tick: 50 rows/frontier, ≤25 LLM judge calls, vector top-k=5 with cosine ≥0.70, and new ticks hold while >20 doc-grounded revisions await review (checked once at tick start, so one in-flight tick can briefly overshoot the cap). Proposals whose rewrite is identical to the capture's current text are dropped as no-ops. Watermarks persist in `app_metadata` (`reconcile_frontier_docs` / `reconcile_frontier_captures`); a poison item is ejected with a `warn!` after 3 consecutive failed ticks. Parsed by `wenlan_core::db::doc_reconcile_enabled()`, orchestrated by `wenlan_core::reconcile::run_reconcile_tick`, fired from `scheduler.rs`.
- `WENLAN_ENABLE_CITATION_BACKFILL` — DEFAULT ON (opt out with `0`/`false`/`no`/`off`). Gates the background 30-min citation-backfill sweep: annotates legacy pages (`citations IS NULL`) with per-claim `[N]` markers against their memory-kind `page_evidence`, verified by the shared `crate::faithfulness` scorer (union-of-cited-sources ≥ 0.5). ANNOTATE-ONLY by construction: a deterministic guard strips markers from the model output and requires byte-equality (whitespace-normalized) with the existing body — any prose change is discarded; 3 rejected attempts poison-pill the page to `citations='[]'` (changelog notes it). ≤5 pages/tick, ≤5 LLM calls/tick, label `citation_annotate`. Parsed by `wenlan_core::db::citation_backfill_enabled()`, orchestrated by `wenlan_core::citations::run_citation_backfill_tick`, fired from `scheduler.rs`.
- `WENLAN_ENABLE_EDGE_GROUNDING_PROMOTE` — DEFAULT OFF (opt in with `1`/`true`/`yes`). Gates the background 30-min edge-grounding promotion sweep (M3g stage B, spec `docs/plans/2026-07-25-m3g-promotion-mechanics.md`): promotes stored `grounded=0` `relates` edges to `grounded=1`, writing `root_id`. It is the ONLY writer of `grounded=1`; extraction proposes (`grounded=0`), this validator grounds. **Two-gate promotion, both mandatory.** (1) Deterministic span pre-filter: the edge's Stage-A captured `payload.span.quote` must be located verbatim in the REAL stored `memories.content` of the edge's `source_memory_id` (never trust the stored char offsets — the quote is re-found in live content). A backlog edge with no captured span skips straight to gate 2. (2) A MANDATORY independent LLM entailment check — an independent judgment that the source text entails the structured triple, NOT the extracting model grading itself; span-presence alone can NEVER promote (it closes hallucinated-quote but not present-but-non-entailing / injected-text vectors). The source text is fenced as untrusted data in the prompt (`build_entailment_prompt`), so an instruction embedded in a document is judged, never obeyed. Only external-origin edges are eligible — the predicate is the daemon-recorded `memories.origin_class = 'document_ingest'` (`crates/wenlan-core/src/origin.rs`, migration 112), NOT a `source_agent` string match; agent-authored relations are `generated` and left `grounded=0`. The classification is stamped by the `RawDocument` ingest path (`upsert_documents`) from the writing path's own `source_agent` — that is where every external document and every agent capture enters — while the other writers that INSERT into `memories` (chat-export import, episode backfill, in-place rewrite) set the column explicitly at their own INSERT; `/api/memory/store` normalizes away any reserved value so no wire request can select an origin. Migration 112's one-time backfill grants `document_ingest` to `source_agent='folder'` rows only: the set is frozen historical fact rather than the live list, and pre-112 `obsidian` rows are left `generated` because some are Wenlan's own projected pages re-ingested and nothing in the row can tell them apart (a re-sync reclassifies them correctly). A new document-ingest connector adds its `source_agent` to `origin::DOCUMENT_INGEST_SOURCE_AGENTS` — one edit that both makes its documents groundable and bars a client from claiming the string. Batch-era relations whose quote no longer locates fail gate 1 and stay `grounded=0` (safe coverage loss, not a false ground). **Provider-gated** (unlike the two READ-ONLY reconcile sweeps): the entailment call needs the pinned LLM, so the lane's availability is `provider_available && edge_grounding_promote_enabled()`, mirroring reconcile / citation. Drains as a backlog slice — one entailment call per ambient turn (`AmbientBudgetProvider` caps one LLM call/turn), so the lane stays due while it progresses and backs off 30 min only when the backlog is empty. Bounded: ≤50 rows scanned per tick, ≤25 entailment calls per full `run_edge_grounding_tick` (the slice spends 1); a durable rowid cursor + poison-ejection after 3 consecutive failed ticks persist in `app_metadata` (`edge_grounding_cursor`). Each survivor mints a content-addressed provenance root via `acquire_provenance_root` (`root_kind='document_ingest'`, `source_identity` = the source memory's url-or-source_id) then monotone-flips `grounded 0→1` under an `AND grounded=0` idempotence guard — the flip is parity-invisible (structural edge columns unchanged; §1). No SQLite transaction spans the entailment call (§6.3): mint and flip are separate short transactions taken AFTER the LLM returns. Disabling the flag leaves already-promoted bits in place (monotone derived state). Parsed by `wenlan_core::db::edge_grounding_promote_enabled()`, orchestrated by `wenlan_core::edge_grounding::run_edge_grounding_slice` (ambient scheduler) / `run_edge_grounding_tick` (Gate 3 + hermetic tests), fired from `scheduler.rs`. Stays default-OFF until the false-grounding and foreground-latency gates (`docs/plans/2026-07-25-m3g-gate-criteria.md`) are measured on a real corpus (Stage C).
- `WENLAN_ENABLE_COMMUNITY_LEIDEN` — DEFAULT OFF (opt in with `1`/`true`/`yes`). Gates M4 PR-1's write-only persisted-community shadow. When enabled, the existing `Phase::CommunityDetection` slot processes at most one dirty space per firing through the durable lease, pure Leiden/incremental compute, and generation-CAS finalize path; it creates no new ambient scheduler lane. The legacy label-propagation producer still runs in the same phase and continues writing only `entities.community_id`, preserving the rollback surface while the M4 job writes only `communities`, `community_members`, `space_graph_state`, and `grouping_leases`. Grounded promotion, grounded retraction/reactivation, and entity merge mark a space dirty in their own transaction. Disabling the flag leaves recomputable shadow rows in place and restores the exact legacy-only phase behavior. Parsed by `wenlan_core::db::community_leiden_enabled()`, orchestrated by `MemoryDB::run_next_community_grouping_cycle`, fired from `refinery::Phase::CommunityDetection`.
- `WENLAN_ENABLE_GENESIS_SHADOW` — DEFAULT OFF (opt in with `1`/`true`/`yes`/`on`). Gates the M6 PR-B genesis shadow lane (spec `docs/plans/2026-08-03-m6-pr-b-genesis-shadow-spec.md` §4.1). When enabled, `wenlan-server` spawns one sibling task that runs a bounded turn: the S0-5 startup recovery scan once, then **at most one unit of work per turn** against **one space**, chosen round-robin — one cursor-resumed frontier reconciliation slice (≤512 rows), one candidate prepare (≤16 proposals considered, ≤64 roots each; the shipped `genesis` lease makes the effective bound one prepare), or one dry-run finalization. Writes are confined to `genesis_*` tables, the shared `grouping_leases` registry (M6 phases only, via `m6::leases`), and M6's own `app_metadata` cursor key; the lane publishes nothing, writes no `genesis_enabled`, and touches no `pages`, `page_*`, `entities`, `relations`, `observations`, `edges`, `memories`, `chunks`, or `page_projection_outbox` row. NO LLM anywhere on the path — dry-run finalization verifies the eight CAS gates and stops (§2.4), and the lane takes no `ServerState` snapshot, so there is no provider handle to reach. Gated **at the `tokio::spawn`**, not inside the loop, so an OFF daemon has no task at all. **Stays default-OFF because the lane is unmeasured, not because it is unsafe:** a turn takes the single `MemoryDB` connection mutex every 1s idle / 100ms working, and neither its RSS nor its foreground-request-latency ceiling has been measured on a real corpus — the same reason the now-retired `WENLAN_ENABLE_EDGES_RECONCILE` and `WENLAN_ENABLE_ENTITY_PAGE_RECONCILE` sweeps (G6 Stage 2 PR 2a) defaulted OFF at a 30-minute cadence. Flipping it default-ON needs those two measurements on a representative store, plus the frozen S0-97 corpus the §7.3 benchmark is specified against (that generator does not exist yet). It is also what makes spec §10.3's rollback — "a flag flip plus a lease sweep" — an operation that exists. Parsed by `wenlan_core::db::genesis_shadow_enabled()`, driven by `wenlan_core::m6::shadow::run_genesis_shadow_turn`, fired from `register_optional_runtime_workers` in `crates/wenlan-server/src/main/runtime.rs`.
- `WENLAN_PREFER_ON_DEVICE_COMPILE` — opt-in (default OFF). Compile routing for the Emergence phase follows spec §3.1: (1) a cloud/API provider compiles in the daemon with the LLM coherence gate; (2) otherwise eligible clusters are left pending for the existing agent lane (`POST /api/distill` pending clusters + `/distill` skill); (3) only when this flag is truthy may a healthy on-device provider compile those new clusters directly. The on-device path skips the LLM coherence gate; the page keep-card carries that judgment. An unavailable on-device provider still leaves clusters pending. Parsed by `on_device_compile_preferred()` in `crates/wenlan-core/src/refinery/mod.rs`, applied only at the Emergence phase's compile-routing decision inside `run_periodic_steep_with_api` (never inside `distill_pages_scoped`/`distill_pages_scoped_gated` themselves, so every other caller of those functions is unaffected). Does NOT gate the separate ReDistill-phase page refresh or the reserved Overview-page refresh (`maybe_refresh_overview_page`) — those already-existing pages keep refreshing via `compile_llm` regardless, since they are refresh ops on established rows, not the "which lane grows a brand-new page" decision this flag targets.

### 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`.