Skip to main content

lunaris/
handle.rs

1//! `Lunaris` — the high-level memory-engine handle (Phase 2 surface).
2//!
3//! Wraps `Arc<dyn StoragePort> + Arc<dyn Embedder> + Arc<HlcClock>` so callers
4//! can construct multiple instances against different URLs (originally for the
5//! Moon-vs-Postgres benches; since 0.7.0 that means several independent Moons).
6//! All three fields are `Arc`-shared so `Lunaris::clone()` is cheap and
7//! `Lunaris` is `Send + Sync` for free.
8//!
9//! ## Construction paths
10//!
11//! - [`Lunaris::open`] — production constructor. Routes the `url` through the
12//!   Phase 1 [`crate::open::open`] dispatcher to pick a [`StoragePort`] backend,
13//!   constructs the default embedder (llama.cpp Q4_K_M granite-r2 GGUF —
14//!   llama.cpp-only cutover) and a fresh `HlcClock(node_id=0)`.
15//! - [`Lunaris::with_parts`] — escape hatch for tests + the Plan 02-01
16//!   latency-budget swap. Lets callers wire any `Arc<dyn StoragePort>` and
17//!   `Arc<dyn Embedder>` directly. Used by the Phase 2 ingest smoke test
18//!   (in-memory recording storage + `StubEmbedder`).
19//! - [`Lunaris::with_embedder`] — public escape hatch to replace the
20//!   embedder on an already-constructed handle (e.g., swap to the
21//!   feature-gated `lunaris_embed_remote::OllamaEmbedder`
22//!   or to a BYO `Arc<dyn Embedder>`).
23//!
24//! ## Invariant
25//!
26//! `Lunaris` does NOT cache mutable per-call retrieval state. Every call
27//! constructs a fresh borrow of the shared Arcs, so the same handle is safe to
28//! use from multiple tokio tasks concurrently. The production constructor wraps
29//! the embedder in a small exact-text LRU cache so repeated agent prompts and
30//! repeated chunk text do not re-run model inference.
31
32use std::collections::{HashMap, HashSet};
33use std::num::NonZeroUsize;
34use std::sync::atomic::{AtomicUsize, Ordering};
35use std::sync::{Arc, OnceLock};
36use std::time::{SystemTime, UNIX_EPOCH};
37
38use lunaris_consolidate::Consolidator;
39use lunaris_core::{
40    Embedder, HlcClock, KeywordPort, Lsn, LunarisError, Scope, StorageError, StoragePort,
41};
42// ADD task activation-ledger — persistent per-memory activation ledger types.
43use lunaris_core::activation::RefSignal;
44use lunaris_core::keyspace::activation_key;
45// engram-soul-loop task 6 (staleness-pass) — verify-agenda keyspace helper.
46use lunaris_core::keyspace::verify_agenda_key;
47use lunaris_ingest::{BakoffConfig, TokenCounter, make_token_counter};
48use serde::{Deserialize, Serialize};
49use ulid::Ulid;
50
51use crate::episode_builder::EpisodeBuilder;
52use lunaris_extract::{Extractor, NoopExtractor};
53use lunaris_rerank::{NoopReranker, Reranker};
54use lunaris_storage_moon::MoonStorage;
55use lunaris_verify::{
56    BOOST_DELTA, NoopReflectSupervisor, NoopVerifier, ReflectInput, ReflectOutput,
57    ReflectSupervisor, Verifier, apply_reflect_boost, apply_reflect_invalidate,
58    boost_cache_capacity,
59};
60
61use crate::consolidator_pipeline::ConsolidatorPipelineHandle;
62use crate::graph_pipeline::GraphPipelineHandle;
63use crate::verify_pipeline::VerifierPipelineHandle;
64
65#[derive(Clone)]
66pub struct Lunaris {
67    pub(crate) storage: Arc<dyn StoragePort>,
68    pub(crate) keyword: Arc<dyn KeywordPort>,
69    pub(crate) embedder: Arc<dyn Embedder>,
70    pub(crate) clock: Arc<HlcClock>,
71    /// Concrete `MoonStorage` Arc when the handle was opened against a `moon://` URL.
72    /// Plan 02-02's `fuse_rrf` Moon-native dispatch reads this to opt into the
73    /// one-round-trip `text().hybrid_search()` path. `None` for a handle built
74    /// via `with_parts*` from a custom or decorated `StoragePort` — including a
75    /// test double wrapping a live Moon, which is exactly how the client-side
76    /// fusion path is still exercised now that no second backend exists.
77    pub(crate) moon_storage: Option<Arc<MoonStorage>>,
78    /// Plan 02-03: cross-encoder reranker for the recall hot path.
79    /// Defaults to `BgeRerankerV2M3` when `~/.cache/lunaris/models/bge-reranker-v2-m3/`
80    /// is present; falls back to `NoopReranker` per RETRIEVE-06 contract when
81    /// the cache is missing. Callers swap via `with_reranker(reranker)`.
82    pub(crate) reranker: Arc<dyn Reranker>,
83    /// GA-1 — opt-in rerank stage on the production recall root. Read ONCE
84    /// from `LUNARIS_RECALL_RERANK` / `LUNARIS_RECALL_RERANK_TOP_IN` at
85    /// `open*` construction (default OFF; the `with_parts*` test seams stay
86    /// OFF like the graph pipeline's hardcoded `false`). Accessor + escape
87    /// hatch live in `crate::recall_rerank`.
88    pub(crate) recall_rerank: crate::recall_rerank::RecallRerankConfig,
89    /// Plan 03-03: graph extraction pipeline toggle (D-10/D-11). Default OFF.
90    /// The `Extractor` itself lives INSIDE the handle's
91    /// `RwLock<Option<Arc<dyn Extractor>>>` — callers `swap` via
92    /// [`Self::with_extractor`] which delegates to
93    /// [`GraphPipelineHandle::set_extractor`]; toggle ON/OFF via
94    /// `handle.graph_pipeline().enable() / .disable()` (D-10 single-switch
95    /// surface, EXTRACT-06).
96    pub(crate) graph_pipeline: Arc<GraphPipelineHandle>,
97    /// Plan 04-04: slow-path Verifier worker toggle (D-08, default OFF per
98    /// blueprint §5.1). Owns the `Arc<dyn Verifier>`, the late-bound
99    /// `Arc<dyn StoragePort>`, the worker JoinHandle, and the shutdown
100    /// `tokio::sync::Notify`. Toggle ON/OFF via
101    /// `handle.verify_pipeline().enable() / .disable()` (D-08 single-switch
102    /// surface, VERIFY-01..06).
103    pub(crate) verify_pipeline: Arc<VerifierPipelineHandle>,
104    /// Plan 04-04: ACT-R Consolidator worker toggle (D-08, default OFF per
105    /// blueprint §5.1). Same shape as `verify_pipeline`. Toggle ON/OFF via
106    /// `handle.consolidator_pipeline().enable() / .disable()` (D-08
107    /// single-switch surface, CONSOL-01..05).
108    pub(crate) consolidator_pipeline: Arc<ConsolidatorPipelineHandle>,
109    /// Phase 13 — per-turn reflection supervisor. Default OFF
110    /// (`NoopReflectSupervisor`), matching blueprint §5.1 default-OFF pattern
111    /// for all optional LLM pipeline stages. Callers install a real supervisor
112    /// via [`Self::with_reflect_supervisor`] and call [`Self::end_turn`] at the
113    /// end of each agent turn to trigger the reflection pass.
114    pub(crate) reflect_supervisor: Arc<dyn ReflectSupervisor>,
115    /// Phase 14.2 — ephemeral per-handle LRU boost cache.
116    ///
117    /// Populated by [`ScopedLunaris::end_turn`] from
118    /// [`lunaris_verify::ReflectOutput::boost`]; consumed as a post-hydrate
119    /// rescorer by every [`lunaris_retrieve::RetrievalBuilder`] returned from
120    /// [`Self::recall`]. Cache key is `(Scope, Ulid)` so boost signals from
121    /// one tenant scope never leak into another scope's recall results.
122    ///
123    /// Lock discipline: the guard is acquired, all entries are written /
124    /// read, then the guard is dropped before the next `.await` point. This
125    /// upholds the CLAUDE.md "never hold a lock across `.await`" invariant.
126    ///
127    /// Capacity: controlled by `LUNARIS_BOOST_CACHE_CAPACITY` (default 10 000)
128    /// via [`lunaris_verify::boost_cache_capacity`].
129    pub(crate) boost_cache: Arc<parking_lot::RwLock<lru::LruCache<(Scope, Ulid), f32>>>,
130    /// Phase 14.3 — concurrency bound for speculative warm-up recalls spawned
131    /// by [`ScopedLunaris::end_turn`] when [`ReflectOutput::pre_warm_query`] is
132    /// `Some`. Capacity defaults to 4; override via
133    /// `LUNARIS_PREWARM_CONCURRENCY` env var (positive integer; 0 or
134    /// non-numeric values fall back to the default). If the semaphore is
135    /// exhausted when `end_turn` fires, the warm-up is silently skipped (logged
136    /// at `DEBUG`) — `end_turn` never blocks on the semaphore.
137    pub(crate) warm_up_semaphore: Arc<tokio::sync::Semaphore>,
138    /// BPE token counter for the ingest chunker (CHUNK-01 / Finding 1 fix).
139    ///
140    /// Loaded from the embedder model directory (`embedder_dir()/tokenizer.json`)
141    /// at `open` time via `make_token_counter`. Falls back to
142    /// `SurrogateTokenCounter` (words×1.3) when the file is absent or
143    /// malformed — `tracing::warn!` is emitted in that case. The `with_parts`
144    /// and `with_parts_keyword` test seams always use the surrogate so tests
145    /// have no model-artifact dependency.
146    ///
147    /// Passed to `ingest_episode_with_counter` so production chunking uses
148    /// real BPE token counts rather than the v0 heuristic.
149    pub(crate) token_counter: Arc<dyn TokenCounter + Send + Sync>,
150    /// Phase 28 — adaptive meta-framework bake-off config.
151    ///
152    /// When `Some`, [`Lunaris::ingest`] routes through
153    /// [`lunaris_ingest::ingest_episode_with_bakeoff`] which runs the multi-generator
154    /// bake-off and persists the winning candidate. The winner's scoring embeddings
155    /// are reused directly (SINGLE-PASS — no re-embed). When `None` (default),
156    /// the standard [`lunaris_ingest::ingest_episode_with_counter`] path is used.
157    ///
158    /// Install via [`Self::with_bakeoff`]. `Arc` allows cheap clone of the handle
159    /// without copying the config on every ingest call.
160    pub(crate) bakeoff_config: Option<Arc<BakoffConfig>>,
161}
162
163struct CachedEmbedder {
164    inner: Arc<dyn Embedder>,
165    cache: parking_lot::RwLock<lru::LruCache<String, Vec<f32>>>,
166    hits: AtomicUsize,
167    misses: AtomicUsize,
168}
169
170impl CachedEmbedder {
171    fn new(inner: Arc<dyn Embedder>, capacity: NonZeroUsize) -> Self {
172        Self {
173            inner,
174            cache: parking_lot::RwLock::new(lru::LruCache::new(capacity)),
175            hits: AtomicUsize::new(0),
176            misses: AtomicUsize::new(0),
177        }
178    }
179
180    /// Shared cache-then-embed path. `lowpri` selects the inner embedder's
181    /// background lane (`embed_batch_lowpri`) for cache misses so the wrapper
182    /// preserves the priority the caller asked for — without this forwarding,
183    /// ingest promotion would be silently upgraded to the interactive lane,
184    /// defeating the whole non-blocking design (every real embedder is wrapped
185    /// in a `CachedEmbedder`).
186    async fn embed_batch_with(
187        &self,
188        inputs: &[&str],
189        lowpri: bool,
190    ) -> Result<Vec<Vec<f32>>, LunarisError> {
191        let mut out: Vec<Option<Vec<f32>>> = vec![None; inputs.len()];
192        let mut missing: HashMap<String, Vec<usize>> = HashMap::new();
193
194        {
195            let cache = self.cache.read();
196            for (idx, input) in inputs.iter().enumerate() {
197                if let Some(cached) = cache.peek(*input) {
198                    out[idx] = Some(cached.clone());
199                    self.hits.fetch_add(1, Ordering::Relaxed);
200                } else {
201                    missing.entry((*input).to_string()).or_default().push(idx);
202                }
203            }
204        }
205
206        if !missing.is_empty() {
207            let keys: Vec<String> = missing.keys().cloned().collect();
208            let refs: Vec<&str> = keys.iter().map(String::as_str).collect();
209            let embedded = if lowpri {
210                self.inner.embed_batch_lowpri(&refs).await?
211            } else {
212                self.inner.embed_batch(&refs).await?
213            };
214            if embedded.len() != keys.len() {
215                return Err(LunarisError::Storage(StorageError::Backend(format!(
216                    "cached embedder inner returned {} rows for {} inputs",
217                    embedded.len(),
218                    keys.len()
219                ))));
220            }
221
222            let mut cache = self.cache.write();
223            for (key, embedding) in keys.into_iter().zip(embedded.into_iter()) {
224                self.misses.fetch_add(1, Ordering::Relaxed);
225                cache.put(key.clone(), embedding.clone());
226                if let Some(indices) = missing.remove(&key) {
227                    for idx in indices {
228                        out[idx] = Some(embedding.clone());
229                    }
230                }
231            }
232        }
233
234        out.into_iter()
235            .map(|row| {
236                row.ok_or_else(|| {
237                    LunarisError::Storage(StorageError::Backend(
238                        "cached embedder failed to fill an output row".into(),
239                    ))
240                })
241            })
242            .collect()
243    }
244}
245
246impl std::fmt::Debug for CachedEmbedder {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        f.debug_struct("CachedEmbedder")
249            .field("dim", &self.inner.dim())
250            .field("cache_len", &self.cache.read().len())
251            .field("hits", &self.hits.load(Ordering::Relaxed))
252            .field("misses", &self.misses.load(Ordering::Relaxed))
253            .finish()
254    }
255}
256
257#[async_trait::async_trait]
258impl Embedder for CachedEmbedder {
259    fn dim(&self) -> usize {
260        self.inner.dim()
261    }
262
263    async fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError> {
264        self.embed_batch_with(inputs, false).await
265    }
266
267    async fn embed_batch_lowpri(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError> {
268        self.embed_batch_with(inputs, true).await
269    }
270}
271
272impl std::fmt::Debug for Lunaris {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        f.debug_struct("Lunaris")
275            .field("backend_capabilities", &self.storage.capabilities())
276            .field("embedder_dim", &self.embedder.dim())
277            .field("clock_node_id", &self.clock.node_id())
278            .field("has_moon_native_path", &self.moon_storage.is_some())
279            .field("reranker_applies", &self.reranker.applies())
280            .field("graph_pipeline_enabled", &self.graph_pipeline.is_enabled())
281            .field("verify_pipeline_enabled", &self.verify_pipeline.is_enabled())
282            .field("consolidator_pipeline_enabled", &self.consolidator_pipeline.is_enabled())
283            .field("reflect_supervisor_applies", &self.reflect_supervisor.applies())
284            .field("boost_cache_len", &self.boost_cache.read().len())
285            .field("warm_up_semaphore_permits", &self.warm_up_semaphore.available_permits())
286            .finish()
287    }
288}
289
290impl Lunaris {
291    /// Which embedder backend this process resolved, as a stable lowercase
292    /// string (`"llamacpp"`, `"openai-remote"`, `"ollama-remote"`, `"noop"`,
293    /// `"unresolved"`).
294    ///
295    /// **This is the only way an SDK caller can see a degraded embedder.**
296    /// The `Noop` fallback is silent by construction: every vector is zeros,
297    /// so hybrid recall collapses to BM25 plus insertion-order tie-breaks
298    /// while `recall` keeps returning successfully with a plausible-looking
299    /// hit list. `NoopEmbedder::dim()` deliberately reports a non-zero
300    /// dimension so the operator's existing index geometry stays valid, which
301    /// means no amount of inspecting the results reveals it either. Rust
302    /// callers had `resolved_embedder_backend()`; Python and TypeScript
303    /// callers had nothing at all, which is the gap this closes.
304    ///
305    /// Process-global, not per-handle: `resolve_embedder` reads env and the
306    /// model cache once, on the first `open`. Taking `&self` is for SDK
307    /// discoverability — a free function does not appear on the handle a
308    /// caller already has, and one that cannot be found does not report.
309    ///
310    /// Keyword-only operation is a SUPPORTED mode (`npx`/`uvx` standalone
311    /// with no staged GGUF), so this reports rather than refuses. See the
312    /// W0.7 ledger entry for why the hard-error variant was reverted.
313    ///
314    /// ## What it does NOT cover
315    ///
316    /// Only [`Lunaris::open`] records a backend. [`Lunaris::open_with_embedder`]
317    /// and the post-open [`Lunaris::with_embedder`] /
318    /// [`Lunaris::try_with_embedder`] swaps do not, because the caller already
319    /// holds the `Arc<dyn Embedder>` and knows what it is. So a process that
320    /// only ever built handles through those paths reports `"unresolved"`
321    /// (correct: nothing was resolved), and a process that called `open` and
322    /// then swapped in a different embedder keeps reporting what `open`
323    /// resolved. Both SDK `open` entry points route through `Lunaris::open`,
324    /// so this caveat does not reach a Python or TypeScript caller today —
325    /// it matters only to Rust embedders using the BYO seam.
326    #[must_use]
327    pub fn embedder_backend(&self) -> String {
328        resolved_embedder_backend().as_str().to_string()
329    }
330
331    /// Production constructor. Opens a storage backend by URL and constructs
332    /// the default embedder.
333    ///
334    /// - `moon://...` → [`lunaris_storage_moon::MoonStorage`] backend.
335    ///   Plan 02-02 wires the typed `Arc<MoonStorage>` alongside the dyn
336    ///   trait Arcs so `recall().fuse_rrf()` can take the Moon-native one-
337    ///   round-trip path. **This is the only scheme 0.7.0 serves.**
338    /// - `postgres://`, `sqlite:///path` and `memory://` were retired in
339    ///   0.7.0 with `lunaris-storage-postgres` / `lunaris-storage-embedded`.
340    ///   They now fail with an `UnsupportedScheme` error that names the
341    ///   migration path (`lunaris-migrate` from the v0.6.2 release binary —
342    ///   see `docs/migration/0.6-to-0.7.md`) rather than opening a store.
343    ///
344    /// ## Default backend resolution (llama.cpp-only cutover)
345    ///
346    /// - **Embedder** — [`lunaris_llamacpp::LlamaCppEmbedder`] backed by the
347    ///   `granite-embedding-311m-multilingual-r2` Q4_K_M GGUF (768-d).
348    ///   Resolved from `LUNARIS_EMBEDDER_GGUF`, else the
349    ///   `~/.lunaris/models/` staged default. Missing GGUF →
350    ///   `tracing::warn!` + [`lunaris_core::NoopEmbedder`] (zero vectors).
351    /// - **Reranker** — [`lunaris_llamacpp::LlamaCppReranker`] backed by the
352    ///   `bge-reranker-v2-m3` Q5_K_M GGUF (sigmoid scores ∈ [0, 1]).
353    ///   Resolved from `LUNARIS_RERANKER_GGUF`, else the staged default;
354    ///   weight load deferred to the first `rerank()` (N-04 D1). Missing
355    ///   GGUF → [`NoopReranker`] (RETRIEVE-06 contract: recall path runs
356    ///   even without the rerank pass).
357    /// - **Extractor / Verifier** — REMOTE-ONLY. Resolved from
358    ///   `LUNARIS_EXTRACT_PROVIDER` / `LUNARIS_VERIFY_PROVIDER`
359    ///   (see `default_extractor`, `default_verifier`); unset → degraded
360    ///   Noop mode.
361    /// - **Consolidator** — resolved from `LUNARIS_CONSOLIDATOR_BACKEND`
362    ///   (see `default_consolidator`).
363    /// - **Remote embedder (Tier-0 / air-gap)** — build with
364    ///   `--features embed-remote` and set
365    ///   `LUNARIS_EMBEDDER_OPENAI_URL` (OpenAI-compatible `/v1/embeddings`)
366    ///   or `LUNARIS_EMBEDDER_OLLAMA_URL` to skip local inference entirely.
367    pub async fn open(url: &str) -> Result<Self, LunarisError> {
368        let embedder = resolve_embedder(embed_max_batch_tokens()).await?;
369        // W0.7 successor: `resolve_embedder` has now recorded the backend, so a
370        // degraded one announces itself here rather than waiting to be asked.
371        // This is deliberately on `open` and NOT on `open_with_embedder`: the
372        // latter is the injection seam, where the caller supplied the embedder
373        // and already knows what it is. Both SDK entry points route through
374        // `open`, so neither needs its own copy.
375        announce_degradation_once();
376        Self::open_with_embedder(url, embedder).await
377    }
378
379    /// Like [`Lunaris::open`] but uses the caller-provided `embedder`
380    /// directly instead of constructing the default llama.cpp embedder /
381    /// `NoopEmbedder` fallback.
382    ///
383    /// Use this when:
384    ///
385    /// - The compile-time feature set has no real embedder backend and the
386    ///   silent-fallback `NoopEmbedder` is unacceptable — pass a BYO
387    ///   embedder you constructed elsewhere.
388    /// - You need to pin a specific vector dim BEFORE Moon creates its FT
389    ///   indices. Moon's `FT.CREATE` is idempotent and DOES NOT auto-resize
390    ///   an existing index, so post-`open()` `with_embedder` calls cannot
391    ///   change the on-disk dim of an existing collection. This method runs
392    ///   the embedder's `dim()` through `MoonStorage::connect_with_dim` on
393    ///   first open, which is the right time to size the index.
394    /// - You want a `NoopEmbedder` at a specific dim:
395    ///   ```no_run
396    ///   use std::sync::Arc;
397    ///   use lunaris::{Lunaris, LunarisError};
398    ///   use lunaris_core::NoopEmbedder;
399    ///
400    ///   # async fn demo() -> Result<(), LunarisError> {
401    ///   let handle = Lunaris::open_with_embedder(
402    ///       "moon://localhost:6380",
403    ///       Arc::new(NoopEmbedder::new(1536)),
404    ///   ).await?;
405    ///   # Ok(()) }
406    ///   ```
407    ///
408    /// The reranker / extractor / verifier / consolidator are still
409    /// resolved from their env vars exactly as in [`Lunaris::open`].
410    pub async fn open_with_embedder(
411        url: &str,
412        embedder: Arc<dyn Embedder>,
413    ) -> Result<Self, LunarisError> {
414        let embedder = maybe_cached_embedder(embedder);
415        let scheme = url.split("://").next().unwrap_or("");
416        let clock = HlcClock::new(0);
417        // Build the BPE token counter from the embedder's tokenizer.json.
418        // Falls back to SurrogateTokenCounter (tracing::warn!) when absent.
419        let token_counter = make_token_counter(Some(&embedder_dir().join("tokenizer.json")));
420        let reranker = resolve_reranker().await?;
421        // Plan 03-03: Construct the graph pipeline handle. Initial state
422        // comes from `LUNARIS_GRAPH_ENABLED=1|0` env var (D-10); default OFF
423        // per blueprint §5.2. The default extractor is candle Gemma-3 4B (or
424        // NoopExtractor on cache miss — see `default_extractor`).
425        let extractor = default_extractor().await;
426        let initial_graph_state = GraphPipelineHandle::initial_state_from_env();
427        let graph_pipeline = Arc::new(GraphPipelineHandle::new(initial_graph_state, extractor));
428        // Plan 04-04: Construct the verifier + consolidator pipeline handles.
429        // Initial state from `LUNARIS_VERIFY_ENABLED` / `LUNARIS_CONSOLIDATE_ENABLED`
430        // env vars (D-08); default OFF per blueprint §5.1. Default backends
431        // are NoopVerifier / NoopConsolidator — production callers wire real
432        // backends via `with_verifier` / `with_consolidator`.
433        let verifier = default_verifier().await;
434        // Phase 16-01 (CONSOL-V1-01): resolve backend from LUNARIS_CONSOLIDATOR_BACKEND;
435        // fail-fast on unknown env values (no silent fallback).
436        let consolidator = default_consolidator()?;
437        let initial_verify_state = VerifierPipelineHandle::initial_state_from_env();
438        let initial_consolidate_state = ConsolidatorPipelineHandle::initial_state_from_env();
439        let verify_pipeline = Arc::new(VerifierPipelineHandle::new(initial_verify_state, verifier));
440        let consolidator_pipeline =
441            Arc::new(ConsolidatorPipelineHandle::new(initial_consolidate_state, consolidator));
442        match scheme {
443            "moon" => {
444                // Size the Moon FT vector indices to the resolved embedder's
445                // dimension (default 768-d for granite-r2; pass a wider embedder
446                // via `Lunaris::open_with_embedder` and the indices grow to
447                // match). Moon's FT.CREATE has no dimension cap. Footgun: if
448                // the Moon instance already holds indices at a different dim,
449                // they are NOT auto-resized — drop them first.
450                let m = Arc::new(MoonStorage::connect_with_dim(url, embedder.dim()).await?);
451                let storage_arc: Arc<dyn StoragePort> = m.clone();
452                // B-10: bind the StoragePort Arc to BOTH pipelines AFTER
453                // we've constructed it. Also bind the HlcClock so the
454                // Plan 04-04 Task 4 apply_supersede has a tick source. If
455                // env var initial-state was ON, also kick the worker via
456                // spawn_worker_if_idle so callers don't have to call
457                // enable() a second time post-bind.
458                verify_pipeline.bind_storage(storage_arc.clone());
459                verify_pipeline.bind_clock(clock.clone());
460                consolidator_pipeline.bind_storage(storage_arc.clone());
461                if initial_verify_state {
462                    verify_pipeline.spawn_worker_if_idle();
463                }
464                if initial_consolidate_state {
465                    consolidator_pipeline.spawn_worker_if_idle();
466                }
467                Ok(Self {
468                    storage: storage_arc,
469                    keyword: m.clone() as Arc<dyn KeywordPort>,
470                    embedder,
471                    clock,
472                    moon_storage: Some(m),
473                    reranker,
474                    // GA-1: rerank toggle frozen at construction — the ONLY
475                    // env read (mirrors the graph pipeline's D-10 pattern).
476                    recall_rerank: crate::recall_rerank::RecallRerankConfig::from_env(),
477                    graph_pipeline,
478                    verify_pipeline,
479                    consolidator_pipeline,
480                    reflect_supervisor: Arc::new(NoopReflectSupervisor),
481                    boost_cache: Arc::new(parking_lot::RwLock::new(lru::LruCache::new(
482                        boost_cache_capacity(),
483                    ))),
484                    warm_up_semaphore: Arc::new(tokio::sync::Semaphore::new(
485                        resolve_prewarm_concurrency(),
486                    )),
487                    token_counter: token_counter.clone(),
488                    bakeoff_config: None,
489                })
490            }
491            other => {
492                Err(LunarisError::Storage(crate::open::retired_scheme_error(other).unwrap_or_else(
493                    || lunaris_core::StorageError::UnsupportedScheme(other.to_string()),
494                )))
495            }
496        }
497    }
498
499    /// Legacy test / latency-budget-swap escape hatch. Wires a custom
500    /// storage handle, embedder, and clock — bypasses [`Self::open`]'s
501    /// default constructors. The keyword Arc is taken from the same
502    /// `storage` Arc by attempting an Arc-to-trait downcast — when the
503    /// caller's storage type also impls `KeywordPort`, this works
504    /// transparently. Otherwise the keyword path returns
505    /// `StorageError::NotSupported` at call time.
506    ///
507    /// Production callers should use [`Self::open`] OR
508    /// [`Self::with_parts_keyword`] with explicit `keyword` Arc.
509    #[doc(hidden)]
510    pub fn with_parts(
511        storage: Arc<dyn StoragePort>,
512        embedder: Arc<dyn Embedder>,
513        clock: Arc<HlcClock>,
514    ) -> Self {
515        // Plan 04-04 B-10: construct the verify + consolidator pipelines
516        // BEFORE the Self struct so we can call bind_storage on each handle
517        // with the storage Arc.
518        let verify_pipeline = Arc::new(VerifierPipelineHandle::new(
519            false,
520            Arc::new(NoopVerifier) as Arc<dyn Verifier>,
521        ));
522        // Phase 16-01 (CONSOL-V1-01): resolve backend from env. Test seam is
523        // infallible — `expect` surfaces env misconfiguration loudly rather
524        // than silently falling back (matches fail-fast contract of the
525        // `Lunaris::open` path).
526        let consolidator = ConsolidatorPipelineHandle::backend_from_env()
527            .expect("LUNARIS_CONSOLIDATOR_BACKEND resolution failed in with_parts test seam");
528        let consolidator_pipeline = Arc::new(ConsolidatorPipelineHandle::new(false, consolidator));
529        // B-10: bind storage to BOTH pipelines (2 of the 4 total bind_storage
530        // call sites in handle.rs). Also bind the HlcClock to verify_pipeline
531        // so the Plan 04-04 Task 4 apply_supersede has a tick source.
532        verify_pipeline.bind_storage(storage.clone());
533        verify_pipeline.bind_clock(clock.clone());
534        consolidator_pipeline.bind_storage(storage.clone());
535        Self {
536            storage,
537            keyword: Arc::new(NoKeywordSupport) as Arc<dyn KeywordPort>,
538            embedder,
539            clock,
540            moon_storage: None,
541            // Default to NoopReranker so existing callers (Plan 02-01 smoke
542            // tests) keep working without picking up the candle dep
543            // transitively. Production callers swap via with_reranker.
544            reranker: Arc::new(NoopReranker) as Arc<dyn Reranker>,
545            // GA-1: test seam stays OFF (no env read) — same shape as the
546            // graph pipeline's hardcoded `false` below. Tests opt in via
547            // `with_recall_rerank`.
548            recall_rerank: crate::recall_rerank::RecallRerankConfig::default(),
549            // Plan 03-03: graph pipeline OFF by default with a NoopExtractor
550            // installed. Tests that exercise the graph-ON path call
551            // `handle.graph_pipeline().enable()` + `handle.with_extractor(...)`
552            // explicitly; default-OFF preserves the Phase 2 fast path.
553            graph_pipeline: Arc::new(GraphPipelineHandle::new(
554                false,
555                Arc::new(NoopExtractor) as Arc<dyn Extractor>,
556            )),
557            verify_pipeline,
558            consolidator_pipeline,
559            // Phase 13 — default OFF per blueprint §5.1 default-OFF pattern.
560            reflect_supervisor: Arc::new(NoopReflectSupervisor),
561            // Phase 14.2 — ephemeral boost cache, capacity from env (default 10_000).
562            boost_cache: Arc::new(parking_lot::RwLock::new(lru::LruCache::new(
563                boost_cache_capacity(),
564            ))),
565            // Phase 14.3 — semaphore for bounded fire-and-forget warm-up spawns.
566            warm_up_semaphore: Arc::new(tokio::sync::Semaphore::new(resolve_prewarm_concurrency())),
567            // Test seam: no model artifact available; use the surrogate counter.
568            token_counter: make_token_counter(None),
569            // Phase 28: bakeoff OFF by default in test seam; install via with_bakeoff.
570            bakeoff_config: None,
571        }
572    }
573
574    /// Test seam used by Plan 02-02 Task 3's `recall_smoke` — wire a
575    /// `KeywordPort` Arc explicitly. Production callers go through
576    /// [`Self::open`] which constructs both Arcs from the URL.
577    #[doc(hidden)]
578    pub fn with_parts_keyword(
579        storage: Arc<dyn StoragePort>,
580        keyword: Arc<dyn KeywordPort>,
581        embedder: Arc<dyn Embedder>,
582        clock: Arc<HlcClock>,
583    ) -> Self {
584        // Plan 04-04 B-10: same shape as with_parts — construct the pipeline
585        // handles BEFORE the Self struct, then bind_storage on both.
586        let verify_pipeline = Arc::new(VerifierPipelineHandle::new(
587            false,
588            Arc::new(NoopVerifier) as Arc<dyn Verifier>,
589        ));
590        // Phase 16-01 (CONSOL-V1-01): resolve backend from env (same fail-fast
591        // contract as `with_parts`).
592        let consolidator = ConsolidatorPipelineHandle::backend_from_env().expect(
593            "LUNARIS_CONSOLIDATOR_BACKEND resolution failed in with_parts_keyword test seam",
594        );
595        let consolidator_pipeline = Arc::new(ConsolidatorPipelineHandle::new(false, consolidator));
596        // B-10: bind storage to BOTH pipelines (the OTHER 2 of the 4 total
597        // bind_storage call sites in handle.rs). Also bind the HlcClock to
598        // verify_pipeline.
599        verify_pipeline.bind_storage(storage.clone());
600        verify_pipeline.bind_clock(clock.clone());
601        consolidator_pipeline.bind_storage(storage.clone());
602        Self {
603            storage,
604            keyword,
605            embedder,
606            clock,
607            moon_storage: None,
608            reranker: Arc::new(NoopReranker) as Arc<dyn Reranker>,
609            // GA-1: test seam stays OFF (no env read) — see `with_parts`.
610            recall_rerank: crate::recall_rerank::RecallRerankConfig::default(),
611            // Plan 03-03 — see `with_parts` for the rationale.
612            graph_pipeline: Arc::new(GraphPipelineHandle::new(
613                false,
614                Arc::new(NoopExtractor) as Arc<dyn Extractor>,
615            )),
616            verify_pipeline,
617            consolidator_pipeline,
618            // Phase 13 — default OFF per blueprint §5.1 default-OFF pattern.
619            reflect_supervisor: Arc::new(NoopReflectSupervisor),
620            // Phase 14.2 — ephemeral boost cache, capacity from env (default 10_000).
621            boost_cache: Arc::new(parking_lot::RwLock::new(lru::LruCache::new(
622                boost_cache_capacity(),
623            ))),
624            // Phase 14.3 — semaphore for bounded fire-and-forget warm-up spawns.
625            warm_up_semaphore: Arc::new(tokio::sync::Semaphore::new(resolve_prewarm_concurrency())),
626            // Test seam: no model artifact available; use the surrogate counter.
627            token_counter: make_token_counter(None),
628            // Phase 28: bakeoff OFF by default in test seam; install via with_bakeoff.
629            bakeoff_config: None,
630        }
631    }
632
633    /// Public escape hatch — replace the embedder on an existing handle.
634    ///
635    /// [`Lunaris::open`] constructs the default llama.cpp embedder backed by
636    /// the granite-r2 Q4_K_M GGUF; call this method post-construction
637    /// to swap in any `Arc<dyn Embedder>` (e.g., a `StubEmbedder` in tests,
638    /// the feature-gated `lunaris_embed_remote::OllamaEmbedder`, or a remote
639    /// embedder service).
640    ///
641    /// **Footgun**: this method does NOT re-size the underlying storage
642    /// vector index. If you swap embedders post-`open()`, ensure the new
643    /// embedder's `dim()` matches the original; otherwise `FT.SEARCH` /
644    /// `pgvector` queries will reject the dimension mismatch at call time.
645    /// Use [`Lunaris::open_with_embedder`] for the pre-index-creation path.
646    pub fn with_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
647        if self.embedder.dim() != embedder.dim() {
648            tracing::warn!(
649                target: "lunaris::handle",
650                store_dim = self.embedder.dim(),
651                new_dim = embedder.dim(),
652                "with_embedder: dim mismatch — silently swapping; vector index is sized for store_dim. \
653                 Use try_with_embedder() to refuse the swap, or open_with_embedder() for a fresh handle."
654            );
655        }
656        self.embedder = maybe_cached_embedder(embedder);
657        self
658    }
659
660    /// Phase 28 — install an adaptive meta-framework bake-off config.
661    ///
662    /// When installed, every subsequent [`Lunaris::ingest`] call routes through
663    /// [`lunaris_ingest::ingest_episode_with_bakeoff`], which runs the
664    /// multi-generator bake-off and persists the winning candidate. The winner's
665    /// scoring embeddings are reused directly (SINGLE-PASS — no re-embed).
666    ///
667    /// Pass `None` (or call this with `Arc::new(BakoffConfig::default())`) to
668    /// restore the standard counter-based ingest path. The `Arc` wrapper lets
669    /// the config be shared cheaply across `Lunaris::clone()` calls.
670    ///
671    /// ## INGEST-04 invariant
672    ///
673    /// Installing a bakeoff config does NOT add a second `atomic_write` call.
674    /// Both the standard path and the bakeoff path funnel through
675    /// `assemble_and_write` in `lunaris_ingest::pipeline`, which holds the
676    /// single executable `storage.atomic_write` call site.
677    pub fn with_bakeoff(mut self, config: Arc<BakoffConfig>) -> Self {
678        self.bakeoff_config = Some(config);
679        self
680    }
681
682    /// N-04 D2 — fallible counterpart to [`Self::with_embedder`].
683    ///
684    /// Refuses the swap when `embedder.dim() != self.embedder.dim()`. The
685    /// handle's existing `embedder.dim()` is the dim Moon's `FT.CREATE` index
686    /// was sized for at `Lunaris::open*` time
687    /// (see [`Lunaris::open_with_embedder`] — the dim flows into
688    /// `MoonStorage::connect_with_dim`). Replacing it with a different-width
689    /// embedder produces garbage similarity scores until the index is
690    /// rebuilt, which is silent corruption masquerading as a working
691    /// recall path. This method exposes the check at the API boundary so
692    /// callers can either match the dim or migrate explicitly.
693    ///
694    /// Returns `Ok(Self)` on match, otherwise
695    /// `Err(LunarisError::Storage(StorageError::Backend(_)))` carrying
696    /// both dims in the message.
697    ///
698    /// The infallible [`Self::with_embedder`] is intentionally retained
699    /// (and emits a `tracing::warn!` on mismatch) for backwards-compat with
700    /// callers that have proven their store tolerates the swap (e.g., tests
701    /// that never run a vector query).
702    pub fn try_with_embedder(mut self, embedder: Arc<dyn Embedder>) -> Result<Self, LunarisError> {
703        let store_dim = self.embedder.dim();
704        let new_dim = embedder.dim();
705        if store_dim != new_dim {
706            return Err(LunarisError::Storage(lunaris_core::StorageError::Backend(format!(
707                "embedder dim {new_dim} != store dim {store_dim}; drop and re-open with \
708                 matching config or migrate (no auto-resize — vectors at the storage \
709                 layer are sized for a specific dim, swapping would produce garbage \
710                 similarity scores)"
711            ))));
712        }
713        self.embedder = maybe_cached_embedder(embedder);
714        Ok(self)
715    }
716
717    /// Escape hatch — replace the reranker on an existing handle.
718    ///
719    /// [`Lunaris::open`] constructs the default llama.cpp reranker backed by
720    /// the bge-reranker-v2-m3 Q5_K_M GGUF and falls back to
721    /// [`NoopReranker`] on cache miss per the RETRIEVE-06 contract. Tests
722    /// pass `Arc::new(NoopReranker)` for determinism; production callers can
723    /// wire a custom cross-encoder (e.g., a remote rerank service) without
724    /// touching the rest of the construction path. Per RETRIEVE-06 this is
725    /// also how callers turn the rerank pass off entirely if the per-batch
726    /// budget busts on their hardware:
727    /// `handle.with_reranker(Arc::new(NoopReranker))`.
728    pub fn with_reranker(mut self, reranker: Arc<dyn Reranker>) -> Self {
729        self.reranker = reranker;
730        self
731    }
732
733    /// Plan 03-03 escape hatch — replace the extractor on an existing handle.
734    /// Production callers wiring a `CloudApiExtractor` (cfg-gated behind the
735    /// `cloud-api` feature) or a custom [`lunaris_extract::Extractor`] impl
736    /// use this; tests pass `Arc::new(lunaris_extract::NoopExtractor)` for
737    /// determinism.
738    ///
739    /// Note: the extractor lives inside the [`GraphPipelineHandle`]'s
740    /// `RwLock<Option<Arc<dyn Extractor>>>` — this method swaps it via
741    /// [`GraphPipelineHandle::set_extractor`], NOT by replacing the entire
742    /// `graph_pipeline` field. Toggle state and the state-change counter are
743    /// preserved across the swap (D-12 idempotent observability).
744    pub fn with_extractor(self, extractor: Arc<dyn Extractor>) -> Self {
745        self.graph_pipeline.set_extractor(extractor);
746        self
747    }
748
749    /// Plan 04-04 escape hatch — replace the verifier on an existing handle.
750    /// Production callers wiring `CandleGemma3_27B` (cfg-gated `candle`) /
751    /// `OllamaVerifier` / `CloudApiVerifier` use this; tests pass
752    /// `Arc::new(NoopVerifier)` for determinism.
753    ///
754    /// The verifier lives inside the [`VerifierPipelineHandle`]'s
755    /// `RwLock<Option<Arc<dyn Verifier>>>` — this method swaps it via
756    /// [`VerifierPipelineHandle::set_verifier`], NOT by replacing the entire
757    /// `verify_pipeline` field. Toggle state and the state-change counter are
758    /// preserved across the swap (D-12 idempotent observability).
759    pub fn with_verifier(self, verifier: Arc<dyn Verifier>) -> Self {
760        self.verify_pipeline.set_verifier(verifier);
761        self
762    }
763
764    /// Plan 04-04 escape hatch — replace the consolidator on an existing handle.
765    /// Production callers install a real ACT-R consolidator via this; tests pass
766    /// `Arc::new(NoopConsolidator)` for determinism.
767    ///
768    /// Same swap semantics as [`Self::with_verifier`] — toggle + counter
769    /// preserved.
770    pub fn with_consolidator(self, consolidator: Arc<dyn Consolidator>) -> Self {
771        self.consolidator_pipeline.set_consolidator(consolidator);
772        self
773    }
774
775    /// Phase 13 escape hatch — replace the reflection supervisor on an existing
776    /// handle. Production callers install an [`LlmReflectSupervisor`] (or a
777    /// custom [`ReflectSupervisor`] impl) via this; tests pass
778    /// `Arc::new(NoopReflectSupervisor)` for determinism.
779    ///
780    /// Unlike `verify_pipeline` and `consolidator_pipeline`, the reflect
781    /// supervisor is a plain `Arc` (no background worker, no toggle) — it is
782    /// invoked synchronously per [`Self::end_turn`] call on the caller's task.
783    ///
784    /// [`LlmReflectSupervisor`]: lunaris_verify::LlmReflectSupervisor
785    pub fn with_reflect_supervisor(mut self, supervisor: Arc<dyn ReflectSupervisor>) -> Self {
786        self.reflect_supervisor = supervisor;
787        self
788    }
789
790    /// Phase 13 — signal the end of an agent turn and run the reflection pass.
791    ///
792    /// Calls [`ReflectSupervisor::reflect`] with `input` and returns the
793    /// advisory [`ReflectOutput`] (`invalidate`, `boost`, `pre_warm_query`).
794    ///
795    /// ## Budget + failure discipline
796    ///
797    /// The supervisor enforces its own timeout (default 500 ms for
798    /// [`LlmReflectSupervisor`]). If the supervisor returns `Err`, this method
799    /// propagates it — callers that treat reflect as best-effort should wrap
800    /// with `.unwrap_or_default()`. If the installed supervisor is
801    /// [`NoopReflectSupervisor`] (the default), this call is a cheap no-op
802    /// returning `ReflectOutput::default()`.
803    ///
804    /// ## Non-requirements in this commit
805    ///
806    /// The returned [`ReflectOutput`] is **advisory only** — storage-side
807    /// application (`invalidate` → `BiTemporal::invalidate_sys`, `boost` →
808    /// retrieval-rank adjustment, `pre_warm_query` → speculative recall) is a
809    /// Phase 13 follow-up. For now, the output is logged and returned to the
810    /// caller.
811    ///
812    /// [`LlmReflectSupervisor`]: lunaris_verify::LlmReflectSupervisor
813    pub async fn end_turn(&self, input: ReflectInput) -> Result<ReflectOutput, LunarisError> {
814        let turn_id = input.turn_id;
815        let output = self.reflect_supervisor.reflect(input).await?;
816        tracing::info!(
817            target: "lunaris::handle",
818            turn_id = ?turn_id,
819            invalidate_count = output.invalidate.len(),
820            boost_count = output.boost.len(),
821            pre_warm_query = output.pre_warm_query.is_some(),
822            "end_turn_reflect_complete"
823        );
824        Ok(output)
825    }
826
827    /// Borrow accessors — needed by Plan 02-02's retrieve DSL builder.
828    pub fn storage(&self) -> Arc<dyn StoragePort> {
829        self.storage.clone()
830    }
831    pub fn keyword(&self) -> Arc<dyn KeywordPort> {
832        self.keyword.clone()
833    }
834    pub fn embedder(&self) -> Arc<dyn Embedder> {
835        self.embedder.clone()
836    }
837
838    /// Liveness probe for `lunaris-server`'s `/healthz` rollout-cutback surface
839    /// (`observability-rollout-maturity`): delegates to the storage backend's
840    /// [`StoragePort::health_check`] (Moon issues a real PING; in-process
841    /// backends report healthy via the additive default). `Err` → the server
842    /// answers 503 so the 5%→100% rollout controller cuts traffic back.
843    pub async fn health_check(&self) -> Result<(), LunarisError> {
844        self.storage.health_check().await.map_err(LunarisError::Storage)
845    }
846    pub fn clock(&self) -> Arc<HlcClock> {
847        self.clock.clone()
848    }
849    /// Borrow the typed `Arc<MoonStorage>` when the handle was opened against
850    /// a Moon backend; `None` otherwise. Plan 02-02's `recall()` plumbs this
851    /// into the `RetrievalBuilder` so `fuse_rrf` can opt into Moon-native
852    /// hybrid search.
853    pub fn moon_storage(&self) -> Option<Arc<MoonStorage>> {
854        self.moon_storage.clone()
855    }
856    /// Borrow the configured reranker. Lets callers chain
857    /// `handle.recall().rerank(handle.reranker())` when they want the rerank
858    /// pass without re-declaring it.
859    pub fn reranker(&self) -> Arc<dyn Reranker> {
860        self.reranker.clone()
861    }
862
863    /// Plan 03-03 — borrow the [`GraphPipelineHandle`] for runtime toggle
864    /// control. EXTRACT-06 single-switch surface (D-10):
865    ///
866    /// - `handle.graph_pipeline().enable()` / `.disable()` — flip the
867    ///   pipeline ON / OFF (idempotent, observable per D-12).
868    /// - `handle.graph_pipeline().is_enabled()` — current state.
869    /// - `handle.graph_pipeline().force_reload().await` — reload the
870    ///   extractor from default cache (e.g., after `huggingface-cli` finished
871    ///   downloading weights).
872    pub fn graph_pipeline(&self) -> Arc<GraphPipelineHandle> {
873        self.graph_pipeline.clone()
874    }
875
876    /// Plan 03-03 — snapshot the currently-installed [`Extractor`] `Arc`.
877    /// Useful for the canonical compose example in tests + bench harnesses.
878    /// Returns `None` only when the [`GraphPipelineHandle`] has no extractor
879    /// installed (rare — only via explicit `set_extractor` with a None which
880    /// is not exposed in the public surface; the public surface always
881    /// installs at least [`NoopExtractor`]).
882    pub fn extractor(&self) -> Option<Arc<dyn Extractor>> {
883        self.graph_pipeline.snapshot_extractor()
884    }
885
886    /// Plan 04-04 — borrow the [`VerifierPipelineHandle`] for runtime toggle
887    /// control. D-08 single-switch surface:
888    ///
889    /// - `handle.verify_pipeline().enable()` / `.disable()` — flip the
890    ///   pipeline ON / OFF (idempotent, observable per D-12). Spawns / signals
891    ///   shutdown on the in-process tokio worker.
892    /// - `handle.verify_pipeline().is_enabled()` — current state.
893    /// - `handle.verify_pipeline().join_worker().await` — await full worker
894    ///   exit after a `disable()`.
895    pub fn verify_pipeline(&self) -> Arc<VerifierPipelineHandle> {
896        self.verify_pipeline.clone()
897    }
898
899    /// Plan 04-04 — borrow the [`ConsolidatorPipelineHandle`] for runtime
900    /// toggle control. Same surface shape as [`Self::verify_pipeline`].
901    pub fn consolidator_pipeline(&self) -> Arc<ConsolidatorPipelineHandle> {
902        self.consolidator_pipeline.clone()
903    }
904
905    /// Plan 04-04 — snapshot the currently-installed [`Verifier`] `Arc`.
906    pub fn verifier(&self) -> Option<Arc<dyn Verifier>> {
907        self.verify_pipeline.snapshot_verifier()
908    }
909
910    /// Plan 04-04 — snapshot the currently-installed [`Consolidator`] `Arc`.
911    pub fn consolidator(&self) -> Option<Arc<dyn Consolidator>> {
912        self.consolidator_pipeline.snapshot_consolidator()
913    }
914
915    /// Phase 13 — borrow the configured [`ReflectSupervisor`] `Arc`.
916    /// Returns the currently-installed supervisor — `NoopReflectSupervisor` by
917    /// default, or whatever was last passed to [`Self::with_reflect_supervisor`].
918    pub fn reflect_supervisor(&self) -> Arc<dyn ReflectSupervisor> {
919        self.reflect_supervisor.clone()
920    }
921
922    /// Phase 14.3 — borrow the warm-up semaphore `Arc`.
923    ///
924    /// Primarily for testing: callers can assert `available_permits()` to
925    /// verify the semaphore was / was not acquired.
926    pub fn warm_up_semaphore(&self) -> Arc<tokio::sync::Semaphore> {
927        self.warm_up_semaphore.clone()
928    }
929
930    /// Phase 14.3 test seam — replace the warm-up semaphore with a custom
931    /// capacity. Use in integration tests that need to control the concurrency
932    /// bound (e.g., capacity=1 for the semaphore-bound test).
933    ///
934    /// This is intentionally `#[doc(hidden)]` — production code uses the
935    /// env-var knob (`LUNARIS_PREWARM_CONCURRENCY`) at construction time.
936    #[doc(hidden)]
937    pub fn with_prewarm_concurrency(mut self, capacity: usize) -> Self {
938        self.warm_up_semaphore = Arc::new(tokio::sync::Semaphore::new(capacity));
939        self
940    }
941
942    /// RFC 0001 Wave 0 — construct a scope-bound view over this handle.
943    ///
944    /// All operations issued through the returned [`ScopedLunaris`] carry
945    /// `scope` as their partitioning key. The underlying `Lunaris` handle is
946    /// borrowed for the lifetime `'a` — no cloning occurs.
947    ///
948    /// Wave 1 will route each method through the real scope-aware backends.
949    /// Wave 0 stubs return `todo!()` so the API surface is frozen before the
950    /// routing logic lands.
951    pub fn scoped(&self, scope: Scope) -> ScopedLunaris<'_> {
952        ScopedLunaris { engine: self, scope }
953    }
954
955    /// Cross-scope enumeration — pass-through to
956    /// [`StoragePort::list_scopes`].
957    ///
958    /// Returns a paginated [`ScopePage`](lunaris_core::ScopePage) of scopes
959    /// known to the underlying backend, optionally filtered by `prefix`. The
960    /// cursor is opaque (Q-U1 lock) and MUST be passed back unchanged on
961    /// subsequent calls; `next_cursor == None` means enumeration is exhausted.
962    ///
963    /// ## Backend support
964    ///
965    /// Supported on Moon — lazy SCAN-parse derivation from the
966    /// `lunaris:{scope}:…` keyspace. The method still returns `Result` and a
967    /// custom `StoragePort` may answer `Err(StorageError::NotSupported(_))`:
968    /// that is what the Postgres backend did (its primitive tables were
969    /// RLS-protected with `FORCE ROW LEVEL SECURITY` and the application role
970    /// could not bypass it), and the contract is kept so a future backend can
971    /// decline without a signature change. Callers handling `NotSupported`
972    /// supply a known scope list from caller context instead.
973    ///
974    /// This is the v0.3 surface introduced by the cross-scope enumeration
975    /// patch. The higher-level `list_atoms` / `get_atom_by_scope_lsn` from the
976    /// upstream brief are intentionally deferred — Lunaris exposes six
977    /// primitive kinds (episode/chunk/entity/relation/fact/community) rather
978    /// than a unified `Atom`, and introducing that abstraction is a separate
979    /// design pass.
980    ///
981    /// ## Example
982    ///
983    /// ```no_run
984    /// use lunaris::{Lunaris, LunarisError};
985    ///
986    /// # async fn demo() -> Result<(), LunarisError> {
987    /// let engine = Lunaris::open("moon://127.0.0.1:6380").await?;
988    /// let page = engine.list_scopes(None, 100, None).await?;
989    /// for scope in page.scopes {
990    ///     println!("known scope: {scope:?}");
991    /// }
992    /// # Ok(()) }
993    /// ```
994    pub async fn list_scopes(
995        &self,
996        prefix: Option<&str>,
997        limit: usize,
998        cursor: Option<&str>,
999    ) -> Result<lunaris_core::ScopePage, LunarisError> {
1000        self.storage.list_scopes(prefix, limit, cursor).await.map_err(LunarisError::from)
1001    }
1002
1003    /// Bulk-invalidate FT index records authored by `node_id` within the HLC wall-clock
1004    /// window `[hlc_wall_lo_inclusive, hlc_wall_hi_inclusive]` (both ends inclusive).
1005    ///
1006    /// Called by Helios when `helios-git` detects a force-push or rebase that abandons
1007    /// commits. This evicts stale recall from the agent's memory so subsequent queries
1008    /// do not surface facts from the abandoned branch.
1009    ///
1010    /// ## Fan-out
1011    ///
1012    /// The method issues `FT.INVALIDATE_RANGE` against each known Lunaris collection
1013    /// (`chunks`, `entities`, `facts`, `communities`) in parallel via `join_all`.
1014    /// Collections whose index is missing on Moon (`WRONGTYPE` response) or whose
1015    /// backend does not support the primitive (`NotSupported`) are skipped with a
1016    /// `WARN` log (degraded mode — the caller receives a partial count, not an error).
1017    ///
1018    /// ## HLC wall-clock semantics
1019    ///
1020    /// `hlc_wall_lo_inclusive` and `hlc_wall_hi_inclusive` are milliseconds since
1021    /// the Unix epoch, matching Moon's `hlc_wall` NUMERIC field convention. Both
1022    /// bounds are **inclusive** (Moon `[lo, hi]` closed interval). Callers with a
1023    /// half-open Rust range `lo..hi` must pass `hi - 1` as the upper bound.
1024    ///
1025    /// ## Timeout
1026    ///
1027    /// Each per-index call is bounded to 250 ms (CONTEXT.md §5 IO failure surface).
1028    /// There is no retry — this is a bulk admin operation; the caller decides retry
1029    /// policy.
1030    ///
1031    /// ## Schema preconditions
1032    ///
1033    /// For the invalidation to match documents, the target FT indices must declare:
1034    /// - `hlc_node_id` as a `TAG` field
1035    /// - `hlc_wall` as a `NUMERIC` field
1036    ///
1037    /// Indices lacking these fields return 0 silently (Moon bitmap intersect returns
1038    /// empty). This is expected for indices predating the `helios-git` schema additions;
1039    /// see `.planning/W2-L2-INVALIDATE-RANGE-SUMMARY.md` for the full schema roadmap.
1040    ///
1041    /// ## Empty range
1042    ///
1043    /// If `hlc_wall_lo_inclusive > hlc_wall_hi_inclusive`, the method returns `Ok(0)`
1044    /// immediately without issuing any wire calls.
1045    ///
1046    /// # Example
1047    ///
1048    /// ```no_run
1049    /// // Helios force-push detector hands us the abandoned HLC window:
1050    /// use lunaris::{Lunaris, LunarisError};
1051    /// use lunaris_core::Scope;
1052    ///
1053    /// # async fn demo(engine: Lunaris) -> Result<(), LunarisError> {
1054    /// let scope = Scope::new("helios.my-worktree").unwrap();
1055    /// let count = engine.invalidate_range(
1056    ///     &scope,
1057    ///     "helios-git@aabbcc",
1058    ///     1_700_000_000_000,
1059    ///     1_700_000_100_000,
1060    /// ).await?;
1061    /// tracing::info!(count, "invalidated stale recall");
1062    /// # Ok(()) }
1063    /// ```
1064    pub async fn invalidate_range(
1065        &self,
1066        scope: &Scope,
1067        node_id: &str,
1068        hlc_wall_lo_inclusive: i64,
1069        hlc_wall_hi_inclusive: i64,
1070    ) -> Result<u64, LunarisError> {
1071        crate::invalidate::invalidate_range(
1072            &self.storage,
1073            scope,
1074            node_id,
1075            hlc_wall_lo_inclusive,
1076            hlc_wall_hi_inclusive,
1077        )
1078        .await
1079    }
1080}
1081
1082/// Sentinel `KeywordPort` impl returned by [`Lunaris::with_parts`] when the
1083/// caller did NOT supply a real keyword backend. Calling `keyword_search`
1084/// returns `StorageError::NotSupported` so callers see a clear failure
1085/// rather than a silent empty result.
1086#[derive(Debug, Clone, Copy)]
1087struct NoKeywordSupport;
1088
1089#[async_trait::async_trait]
1090impl KeywordPort for NoKeywordSupport {
1091    /// Wave 2.5A: gains `scope: &Scope` per RFC 0001 §3.4 amendment.
1092    /// Scope is ignored — this sentinel returns NotSupported regardless.
1093    async fn keyword_search(
1094        &self,
1095        _scope: &lunaris_core::Scope,
1096        _index: &str,
1097        _query: &str,
1098        _k: usize,
1099        _filter: Option<&lunaris_core::Filter>,
1100        _as_of: Option<lunaris_core::Hlc>,
1101    ) -> Result<Vec<lunaris_core::KeywordHit>, lunaris_core::StorageError> {
1102        Err(lunaris_core::StorageError::NotSupported(
1103            "Lunaris::with_parts was called without a KeywordPort — use with_parts_keyword or open(url)",
1104        ))
1105    }
1106}
1107
1108// ── HOOK-05: idempotency ──────────────────────────────────────────────────────
1109
1110/// Outcome of [`ScopedLunaris::ingest_idempotent`] (HOOK-05).
1111///
1112/// `Fresh` means a new episode was written; `Duplicate` means the dedupe key
1113/// was already present and the prior LSN is returned without a second
1114/// `atomic_write`. INGEST-04 is preserved: `Duplicate` does NOT call
1115/// `atomic_write` at all; `Fresh` calls it exactly once via [`ScopedLunaris::ingest`].
1116#[derive(Debug, Clone, PartialEq, Eq)]
1117pub enum IngestKind {
1118    /// New episode was written; the enclosed `Lsn` is its committed LSN.
1119    Fresh,
1120    /// Episode already present; the enclosed `Lsn` is the prior committed LSN.
1121    Duplicate(lunaris_core::Lsn),
1122}
1123
1124/// RFC 0001 Wave 1D — scope-bound view over a [`Lunaris`] handle.
1125///
1126/// Constructed via [`Lunaris::scoped`]. All operations issued through this
1127/// wrapper carry the bound [`Scope`] as their partitioning key. The `'a`
1128/// lifetime ties the view to the underlying handle so no `Arc` clone is
1129/// required for the wrapper itself.
1130///
1131/// ## Scope enforcement
1132///
1133/// Callers build an [`EpisodeBuilder`] (scope-less payload) and pass it to
1134/// [`Self::ingest`]. The wrapper is the ONLY code path that can call
1135/// `EpisodeBuilder::into_episode` (it's `pub` but the scope value comes
1136/// exclusively from this wrapper's `self.scope` field). Callers cannot
1137/// construct an `Episode` with an arbitrary scope by bypassing this type.
1138pub struct ScopedLunaris<'a> {
1139    pub(crate) engine: &'a Lunaris,
1140    pub(crate) scope: Scope,
1141}
1142
1143impl<'a> ScopedLunaris<'a> {
1144    /// Returns the [`Scope`] this view is bound to.
1145    pub fn scope(&self) -> &Scope {
1146        &self.scope
1147    }
1148
1149    /// Ingest an episode payload under the bound scope.
1150    ///
1151    /// Takes an [`EpisodeBuilder`] (scope-less payload) rather than a fully
1152    /// constructed `Episode` so the caller cannot inject an arbitrary scope.
1153    /// The wrapper stamps `self.scope` onto the episode via
1154    /// `builder.into_episode(self.scope.clone(), &self.engine.clock)` before
1155    /// delegating to [`Lunaris::ingest`].
1156    ///
1157    /// INGEST-04 invariant: exactly one `atomic_write` call per ingest path.
1158    /// The write lives in `lunaris_ingest::ingest_episode` (graph OFF) or
1159    /// `ingest_episode_graph_on` (graph ON), unchanged from the non-scoped path.
1160    pub async fn ingest(&self, builder: EpisodeBuilder) -> Result<Lsn, LunarisError> {
1161        let episode = builder.into_episode(self.scope.clone(), &self.engine.clock);
1162        self.engine.ingest(episode).await
1163    }
1164
1165    /// Idempotent ingest (HOOK-05): if `dedupe_key` has been seen before within
1166    /// this scope, return the prior `Lsn` without a second `atomic_write`.
1167    ///
1168    /// ## INGEST-04 invariant preserved
1169    ///
1170    /// The dedupe key lookup is READ-ONLY (`StoragePort::lookup_by_dedupe_key`).
1171    /// Only on [`IngestKind::Fresh`] does the existing single `atomic_write`
1172    /// (inside [`Self::ingest`]) run. No new `atomic_write` call site is introduced.
1173    ///
1174    /// ## Trait-method approach (W6 fix)
1175    ///
1176    /// Uses `StoragePort::lookup_by_dedupe_key` / `insert_dedupe_key` trait methods
1177    /// directly — no `as_any()` downcast. Moon implements them via the
1178    /// `lunaris:{scope}:dedupe:{blake3}` KV sidecar with SET-NX
1179    /// first-writer-wins (ADD task moon-parity-honesty — closed the former
1180    /// "SQLite-only idempotency" v0.5 boundary). Both methods keep a trait
1181    /// default returning `Ok(None)` / `Ok(())`, so a custom `StoragePort` that
1182    /// implements neither falls through to unconditional Fresh ingest rather
1183    /// than failing — which is why the HOOK-05 guard
1184    /// (`lunaris-hook/tests/idempotency.rs`) asserts against a real Moon and
1185    /// not a double.
1186    ///
1187    /// ## Post-commit race window (T-24-03-06)
1188    ///
1189    /// `insert_dedupe_key` runs AFTER the `atomic_write` commit. If the process is
1190    /// killed in the window between those two operations, replay produces a duplicate
1191    /// Episode. Mitigation deferred to v0.6. The `insert_dedupe_key` failure is
1192    /// non-fatal (logged at WARN level).
1193    pub async fn ingest_idempotent(
1194        &self,
1195        builder: EpisodeBuilder,
1196        dedupe_key: &str,
1197    ) -> Result<(Lsn, IngestKind), LunarisError> {
1198        // Attempt read-only lookup via StoragePort trait method.
1199        // Moon returns the real hit from its dedupe sidecar; a port that does
1200        // not implement the sidecar answers Ok(None) via the trait default.
1201        match self.engine.storage.lookup_by_dedupe_key(&self.scope, dedupe_key).await {
1202            Ok(Some(prior_lsn)) => {
1203                tracing::debug!(
1204                    dedupe_key,
1205                    prior_lsn = %prior_lsn,
1206                    scope = self.scope.as_str(),
1207                    "duplicate dedupe key — returning prior LSN without ingest",
1208                );
1209                return Ok((prior_lsn, IngestKind::Duplicate(prior_lsn)));
1210            }
1211            Ok(None) => {}
1212            Err(e) => {
1213                tracing::warn!(
1214                    err = %e,
1215                    dedupe_key,
1216                    "dedupe key lookup failed — proceeding as fresh ingest",
1217                );
1218            }
1219        }
1220
1221        // Fresh path: ingest (single atomic_write inside self.ingest), then
1222        // record the dedupe key in the sidecar table (best-effort, non-fatal).
1223        let lsn = self.ingest(builder).await?;
1224
1225        if let Err(e) = self.engine.storage.insert_dedupe_key(&self.scope, dedupe_key, lsn).await {
1226            tracing::warn!(
1227                err = %e,
1228                dedupe_key,
1229                lsn = %lsn,
1230                "dedupe key insert failed — continuing (non-fatal, T-24-03-06 race window)",
1231            );
1232        }
1233
1234        Ok((lsn, IngestKind::Fresh))
1235    }
1236
1237    /// Phase 23 — agent-supplied structured ingest under the bound scope.
1238    ///
1239    /// Delegates to [`Lunaris::ingest_structured`] with `self.scope` so
1240    /// the caller cannot inject an arbitrary scope. See the
1241    /// [`crate::structured_ingest`] module rustdoc for the full design
1242    /// (deterministic EntityId, always-on graph writes, single
1243    /// `atomic_write` per call).
1244    ///
1245    /// INGEST-04 invariant: exactly one `atomic_write` call per ingest
1246    /// path. The write lives in
1247    /// [`crate::structured_ingest::ingest_structured_inner`] for this path
1248    /// (vs. `lunaris_ingest::ingest_episode` / `ingest_episode_graph_on`
1249    /// for [`Self::ingest`]).
1250    pub async fn ingest_structured(
1251        &self,
1252        payload: crate::structured_ingest::StructuredIngest,
1253    ) -> Result<Lsn, LunarisError> {
1254        self.engine.ingest_structured(payload, self.scope.clone()).await
1255    }
1256
1257    /// Recall hits under the bound scope.
1258    ///
1259    /// Runs the **default plan** — the GA-1 unified production root
1260    /// (`lunaris_retrieve::production_root`): `Vector ∧ BM25("chunks") →
1261    /// fuse_rrf(60) → top(30)`, fact legs when the graph pipeline is ON, and
1262    /// the opt-in `LUNARIS_RECALL_RERANK` cross-encoder stage — executes it,
1263    /// and returns the hydrated `Vec<Hit>`. This is the one-shot convenience
1264    /// form; for a custom plan (graph / tree, `as_of`, thresholds) use
1265    /// [`Self::dsl`].
1266    /// Wave 2.5C: the scope is applied to the `Vector` search and to hydrate,
1267    /// so only hits from this scope's partition are returned. (The same scope
1268    /// threading covers `Graph` / `Keyword` and any other operators you attach
1269    /// via [`Self::dsl`].)
1270    pub async fn recall(
1271        &self,
1272        query: lunaris_retrieve::Query,
1273    ) -> Result<Vec<lunaris_retrieve::Hit>, LunarisError> {
1274        self.engine.recall().with_scope(self.scope.clone()).execute(query).await
1275    }
1276
1277    /// Set this scope's retention policy (W4.6 / D6.4).
1278    ///
1279    /// Retention is **opt-in per scope**: a scope with no policy is never
1280    /// swept. The failure mode of an accidental policy is unrecoverable data
1281    /// loss and the failure mode of an accidentally-absent one is disk, so the
1282    /// default is the recoverable one.
1283    pub async fn set_retention_policy(
1284        &self,
1285        policy: lunaris_core::retention::RetentionPolicy,
1286    ) -> Result<(), LunarisError> {
1287        crate::retention::write_policy(&self.engine.storage, &self.scope, policy).await
1288    }
1289
1290    /// Read this scope's retention policy, or `None` when it has none.
1291    pub async fn retention_policy(
1292        &self,
1293    ) -> Result<Option<lunaris_core::retention::RetentionPolicy>, LunarisError> {
1294        crate::retention::read_policy(&self.engine.storage, &self.scope, &self.engine.clock).await
1295    }
1296
1297    /// Run one retention pass over this scope, against the current wall clock.
1298    ///
1299    /// A no-op returning `policy: None` when the scope has no policy. See
1300    /// [`crate::retention`] for why a sweep goes through `forget` rather than
1301    /// deleting directly, and why Lunaris does not schedule this for you.
1302    pub async fn enforce_retention(
1303        &self,
1304    ) -> Result<crate::retention::RetentionReceipt, LunarisError> {
1305        let now_ms = self.engine.clock.tick().wall_ms;
1306        crate::retention::enforce_at(self.engine, &self.scope, now_ms).await
1307    }
1308
1309    /// [`Self::enforce_retention`] against a caller-chosen wall clock, so a
1310    /// backfill or a replay can pin the cutoff instead of racing it.
1311    pub async fn enforce_retention_at(
1312        &self,
1313        now_ms: u64,
1314    ) -> Result<crate::retention::RetentionReceipt, LunarisError> {
1315        crate::retention::enforce_at(self.engine, &self.scope, now_ms).await
1316    }
1317
1318    /// Report what [`Self::enforce_retention`] would sweep, sweeping nothing.
1319    ///
1320    /// Wave 6 / R1 — the preview half of retention, so a caller (notably the
1321    /// LLM-driven `memory.retention_enforce` tool, which previews by default)
1322    /// can answer "what would this take?" without recomputing the cutoff.
1323    /// See [`crate::retention::preview_at`].
1324    pub async fn preview_retention(
1325        &self,
1326    ) -> Result<crate::retention::RetentionReceipt, LunarisError> {
1327        let now_ms = self.engine.clock.tick().wall_ms;
1328        crate::retention::preview_at(self.engine, &self.scope, now_ms).await
1329    }
1330
1331    /// [`Self::preview_retention`] against a caller-chosen wall clock.
1332    pub async fn preview_retention_at(
1333        &self,
1334        now_ms: u64,
1335    ) -> Result<crate::retention::RetentionReceipt, LunarisError> {
1336        crate::retention::preview_at(self.engine, &self.scope, now_ms).await
1337    }
1338
1339    /// Read this scope's audit trail over a closed time range.
1340    ///
1341    /// W4.6 / D6.3. Until now the audit log was write-only: every producer
1342    /// published to `__lunaris_audit__` and nothing in the repo read it back,
1343    /// so "who deleted this?" — the question the trail exists to answer — had
1344    /// no answer. This is the consumer.
1345    ///
1346    /// **Non-destructive.** It does not pop, ack, or advance any consumer
1347    /// group, so it can be run repeatedly and a background subscriber on the
1348    /// same topic is unaffected.
1349    ///
1350    /// **Reads only this scope's own topic**, which since W4.6 is also the
1351    /// only place this scope's events are written. That ordering was not
1352    /// optional: a reader built on the previous `Scope::dev()`-for-everyone
1353    /// publish would have served one tenant another tenant's history.
1354    ///
1355    /// `from_ms` / `to_ms` are inclusive wall-clock milliseconds, `None`
1356    /// unbounded. Records come back oldest-first, capped at `limit`. Entries
1357    /// that fail to decode are counted in [`lunaris_core::audit::AuditPage::undecodable`] rather
1358    /// than silently skipped.
1359    ///
1360    /// Returns `StorageError::NotSupported` on a backend with no range read.
1361    pub async fn audit_events(
1362        &self,
1363        from_ms: Option<u64>,
1364        to_ms: Option<u64>,
1365        limit: usize,
1366    ) -> Result<lunaris_core::audit::AuditPage, LunarisError> {
1367        lunaris_core::audit::read_audit_events(
1368            &self.engine.storage,
1369            &self.scope,
1370            from_ms,
1371            to_ms,
1372            limit,
1373        )
1374        .await
1375        .map_err(LunarisError::Storage)
1376    }
1377
1378    /// Forget primitive bound to the wrapper's scope — the canonical entry
1379    /// point superseding the deprecated [`Lunaris::forget`].
1380    ///
1381    /// Wave 1D (ADD task forget-scope-routing, 2026-07-14): the per-scope
1382    /// storage routing is REAL — scan, read, and the single `atomic_write`
1383    /// all run under `self.scope`. The former shim delegated to the
1384    /// `Scope::dev()`-hard-coded pipeline and silently returned
1385    /// `rows_written = 0` for every real scope (proved live on Moon in the
1386    /// 2026-07-14 deep test). Soft-deleted rows are hidden from recall by
1387    /// the hydrate sys-gate (`lunaris_retrieve::hydrate`).
1388    pub async fn forget(
1389        &self,
1390        request: impl Into<crate::forget::ForgetRequest>,
1391    ) -> Result<crate::forget::ForgetReceipt, LunarisError> {
1392        crate::forget::forget_scoped(
1393            &self.engine.storage,
1394            &self.engine.clock,
1395            &self.scope,
1396            request.into(),
1397        )
1398        .await
1399    }
1400
1401    /// Return a [`lunaris_retrieve::RetrievalBuilder`] bound to the engine's
1402    /// storage / embedder / keyword Arcs AND this wrapper's scope for
1403    /// DSL-style query composition.
1404    ///
1405    /// ```no_run
1406    /// use lunaris::{Keyword, Lunaris, LunarisError, Query, Scope, Vector};
1407    ///
1408    /// # async fn demo(engine: Lunaris, scope: Scope) -> Result<(), LunarisError> {
1409    /// let hits = engine.scoped(scope)
1410    ///     .dsl()
1411    ///     .with_root(Vector::new("chunks", 30).and(Keyword::bm25("chunks", 30)).fuse_rrf(60).top(5))
1412    ///     .execute(Query::text("brown fox"))
1413    ///     .await?;
1414    /// # Ok(()) }
1415    /// ```
1416    pub fn dsl(&self) -> lunaris_retrieve::RetrievalBuilder {
1417        // Wave 2.5C: pre-seed scope so all operators in the tree use the
1418        // bound scope rather than Scope::dev() placeholders.
1419        self.engine.recall().with_scope(self.scope.clone())
1420    }
1421
1422    /// Phase 14.1 — signal the end of an agent turn, run the reflection pass,
1423    /// and apply MVCC invalidations for every ulid in [`ReflectOutput::invalidate`].
1424    ///
1425    /// ## What this does (Phase 14.1)
1426    ///
1427    /// 1. Delegates to the handle's [`ReflectSupervisor`] (same as
1428    ///    [`Lunaris::end_turn`]). If the supervisor is a
1429    ///    [`NoopReflectSupervisor`] (the default), the reflect call is a
1430    ///    cheap no-op returning `ReflectOutput::default()`.
1431    ///
1432    /// 2. For every ulid in `output.invalidate`, calls
1433    ///    [`apply_reflect_invalidate`] which:
1434    ///    - reads the fact row,
1435    ///    - stamps `bt.sys.1 = Some(now)` (JSON-patched into the payload),
1436    ///    - commits **one `atomic_write`** for the entire batch (D-11), and
1437    ///    - publishes one `AuditEvent::ReflectInvalidation` per stamped ulid
1438    ///      (D-22, fire-and-forget).
1439    ///
1440    /// ## What is NOT done in this commit (Phase 14.2 / 14.3)
1441    ///
1442    /// - `boost` — deferred to Phase 14.2 (ephemeral LRU per-handle cache).
1443    /// - `pre_warm_query` — deferred to Phase 14.3 (fire-and-forget recall).
1444    ///
1445    /// ## Failure discipline
1446    ///
1447    /// Reflect is advisory.  Supervisor errors and storage errors during
1448    /// invalidation are logged via `tracing::warn!` and swallowed — this
1449    /// method **never** fails the agent's next turn due to a reflect error.
1450    /// The full `ReflectOutput` (including `boost` and `pre_warm_query`) is
1451    /// returned to the caller regardless.
1452    ///
1453    /// ## Scope enforcement
1454    ///
1455    /// `self.scope` (the JWT-bound partition key) is the sole source of
1456    /// truth for the storage partition.  The caller cannot inject a different
1457    /// scope — that is the whole point of `ScopedLunaris`.
1458    pub async fn end_turn(&self, input: ReflectInput) -> Result<ReflectOutput, LunarisError> {
1459        let turn_id = input.turn_id;
1460
1461        // Step 1: run the reflection pass (best-effort — never fail the turn).
1462        let output = match self.engine.reflect_supervisor.reflect(input).await {
1463            Ok(o) => o,
1464            Err(e) => {
1465                tracing::warn!(
1466                    target: "lunaris::scoped",
1467                    err = %e,
1468                    turn_id = ?turn_id,
1469                    "reflect_supervisor_error; emitting empty output"
1470                );
1471                ReflectOutput::default()
1472            }
1473        };
1474
1475        // Step 2 (Phase 14.1): apply invalidations — one atomic_write for the batch.
1476        if !output.invalidate.is_empty() {
1477            match apply_reflect_invalidate(
1478                &self.engine.storage,
1479                &self.scope,
1480                &self.engine.clock,
1481                turn_id,
1482                &output.invalidate,
1483            )
1484            .await
1485            {
1486                Ok(stamped) => {
1487                    tracing::debug!(
1488                        target: "lunaris::scoped",
1489                        turn_id = ?turn_id,
1490                        invalidated_count = stamped.len(),
1491                        "reflect_invalidate_applied"
1492                    );
1493                }
1494                Err(e) => {
1495                    tracing::warn!(
1496                        target: "lunaris::scoped",
1497                        err = %e,
1498                        turn_id = ?turn_id,
1499                        "reflect_invalidate_storage_error; continuing"
1500                    );
1501                }
1502            }
1503        }
1504
1505        // Step 3 (Phase 14.2): populate the per-handle boost cache for every
1506        // chunk ulid nominated by the reflect supervisor.
1507        //
1508        // `apply_reflect_boost` is synchronous — it acquires the write lock,
1509        // writes all entries, and drops the guard before returning.  No `.await`
1510        // appears between the guard acquisition and its release, satisfying the
1511        // CLAUDE.md lock-across-await invariant.
1512        if !output.boost.is_empty() {
1513            apply_reflect_boost(&self.engine.boost_cache, &self.scope, &output.boost, BOOST_DELTA);
1514            tracing::debug!(
1515                target: "lunaris::scoped",
1516                turn_id = ?turn_id,
1517                boost_count = output.boost.len(),
1518                boost_delta = BOOST_DELTA,
1519                "reflect_boost_cache_populated"
1520            );
1521        }
1522
1523        // Summary log at turn boundary (Phase 14.1 + 14.2 combined).
1524        // Step 3 (Phase 14.3): fire-and-forget speculative warm-up recall.
1525        //
1526        // If the reflector predicted a next-turn query, spawn a background task
1527        // to issue a real recall against the storage backend. This populates
1528        // Moon's FT page cache before the
1529        // agent issues the actual query, reducing first-hit latency on the next
1530        // turn.
1531        //
1532        // Design constraints (§4 of docs/design/phase-14-reflect-output-application.md):
1533        // - MUST NOT block `end_turn` — use `try_acquire_owned`, never
1534        //   `acquire_owned().await`.
1535        // - Concurrency is bounded by `engine.warm_up_semaphore` (default 4,
1536        //   configurable via `LUNARIS_PREWARM_CONCURRENCY`). Exhausted semaphore
1537        //   → skip + DEBUG log, never block.
1538        // - `OwnedSemaphorePermit` moves into the spawned task via
1539        //   `let _permit = permit;` INSIDE the `async move {}` block so it is
1540        //   released when the task ends, not when `end_turn` returns.
1541        // - Errors inside the task become `tracing::warn!` — never propagate,
1542        //   never panic.
1543        // - Warm-up uses the same `Scope` as this `ScopedLunaris` handle so no
1544        //   cross-tenant data can be accessed.
1545        if let Some(query_str) = output.pre_warm_query.clone() {
1546            match self.engine.warm_up_semaphore.clone().try_acquire_owned() {
1547                Ok(permit) => {
1548                    // Clone all Arcs needed by the spawned task before the move.
1549                    // `moon_storage` is included so the warm-up uses the Moon-native
1550                    // one-round-trip FT path when available — without it the task
1551                    // would take the generic retrieval path and miss the FT cache.
1552                    let storage = self.engine.storage.clone();
1553                    let keyword = self.engine.keyword.clone();
1554                    let embedder = self.engine.embedder.clone();
1555                    let moon_storage = self.engine.moon_storage.clone();
1556                    let scope = self.scope.clone();
1557                    let q = query_str.clone();
1558                    tokio::spawn(async move {
1559                        // PERMIT MOVE: `_permit` is dropped when this task ends,
1560                        // releasing the semaphore slot. It MUST live inside this
1561                        // `async move {}` block — placing it outside would release
1562                        // the permit when `end_turn` returns, defeating the bound.
1563                        let _permit = permit;
1564                        // Build a default Vector top-30 recall — the goal is to
1565                        // warm the backend's FT/page cache, not to return results
1566                        // to the caller. The default root is the same shape used
1567                        // by `Lunaris::recall()` and `ScopedLunaris::dsl()`.
1568                        let mut builder = lunaris_retrieve::RetrievalBuilder::from_handle(
1569                            storage, keyword, embedder,
1570                        )
1571                        .with_scope(scope);
1572                        if let Some(moon) = moon_storage {
1573                            builder = builder.with_moon_storage(moon);
1574                        }
1575                        match builder.execute(lunaris_retrieve::Query::text(q.as_str())).await {
1576                            Ok(hits) => tracing::debug!(
1577                                target: "lunaris::scoped",
1578                                hits = hits.len(),
1579                                query = %q,
1580                                "pre_warm_complete"
1581                            ),
1582                            Err(e) => tracing::warn!(
1583                                target: "lunaris::scoped",
1584                                err = %e,
1585                                query = %q,
1586                                "pre_warm_failed"
1587                            ),
1588                        }
1589                    });
1590                    tracing::debug!(
1591                        target: "lunaris::scoped",
1592                        query = %query_str,
1593                        "pre_warm_spawned"
1594                    );
1595                }
1596                Err(_) => {
1597                    tracing::debug!(
1598                        target: "lunaris::scoped",
1599                        query = %query_str,
1600                        "pre_warm_skipped_semaphore_full"
1601                    );
1602                }
1603            }
1604        }
1605
1606        // Summary log at turn boundary (Phase 14.1 requirement).
1607        tracing::info!(
1608            target: "lunaris::scoped",
1609            turn_id = ?turn_id,
1610            invalidated_count = output.invalidate.len(),
1611            boost_count = output.boost.len(),
1612            pre_warm_query = output.pre_warm_query.is_some(),
1613            "scoped_end_turn_complete"
1614        );
1615
1616        Ok(output)
1617    }
1618
1619    /// ADD task activation-ledger — record usage signals into the
1620    /// persistent per-memory activation ledger.
1621    ///
1622    /// Read-modify-write of every DISTINCT id touched by `signals`: for each
1623    /// id, reads the existing `ActivationRecord` at
1624    /// `lunaris_core::keyspace::activation_key(scope, id)` (or starts from
1625    /// `ActivationRecord::default()` when none exists, or when the existing
1626    /// row is corrupt — a malformed stored record must not block new
1627    /// reinforcement), applies every signal for that id in input order via
1628    /// `ActivationRecord::apply`, and commits ALL touched records in exactly
1629    /// ONE `atomic_write` (mirrors D-11 — one atomic write per logical batch).
1630    ///
1631    /// ## Best-effort contract
1632    ///
1633    /// This method itself SURFACES storage errors (`Result::Err`) rather
1634    /// than swallowing them — it is a library primitive, not a turn-path
1635    /// caller. Callers on the agent-turn / injection path (e.g.
1636    /// `lunaris-hook::trace_injection`) MUST log-and-continue on `Err`: a
1637    /// reinforcement-signal failure must never fail the agent's turn (same
1638    /// contract as `apply_reflect_invalidate` / `apply_reflect_boost`).
1639    pub async fn record_activation_refs(&self, signals: &[RefSignal]) -> Result<(), LunarisError> {
1640        if signals.is_empty() {
1641            return Ok(());
1642        }
1643
1644        let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1645        let read_at = self.engine.clock.tick();
1646
1647        // Group signals by id, preserving first-seen order for a
1648        // deterministic WriteOp ordering in the batch.
1649        let mut order: Vec<Ulid> = Vec::new();
1650        let mut by_id: HashMap<Ulid, Vec<RefSignal>> = HashMap::new();
1651        for s in signals {
1652            by_id
1653                .entry(s.id)
1654                .or_insert_with(|| {
1655                    order.push(s.id);
1656                    Vec::new()
1657                })
1658                .push(*s);
1659        }
1660
1661        let mut ops: Vec<lunaris_core::WriteOp> = Vec::with_capacity(order.len());
1662        for id in order {
1663            let key = activation_key(&self.scope, id);
1664            let mut record = match self
1665                .engine
1666                .storage
1667                .read_as_of(&self.scope, &key, read_at)
1668                .await
1669                .map_err(LunarisError::Storage)?
1670            {
1671                Some(row) => {
1672                    serde_json::from_slice::<lunaris_core::activation::ActivationRecord>(&row.value)
1673                        .unwrap_or_else(|e| {
1674                            tracing::warn!(
1675                                err = %e,
1676                                %id,
1677                                scope = self.scope.as_str(),
1678                                "activation_ledger_corrupt_record_reseeded"
1679                            );
1680                            lunaris_core::activation::ActivationRecord::default()
1681                        })
1682                }
1683                None => lunaris_core::activation::ActivationRecord::default(),
1684            };
1685            for s in &by_id[&id] {
1686                record.apply(s, now);
1687            }
1688            let value = serde_json::to_vec(&record).map_err(|e| {
1689                LunarisError::Storage(StorageError::Backend(format!(
1690                    "activation_ledger_serialize_failed: {e}"
1691                )))
1692            })?;
1693            ops.push(lunaris_core::WriteOp::KvPut { key, value });
1694        }
1695
1696        // Mirrors D-11: exactly ONE atomic_write for the whole batch.
1697        self.engine.storage.atomic_write(&self.scope, &ops).await.map_err(LunarisError::Storage)?;
1698        Ok(())
1699    }
1700
1701    /// engram-soul-loop task 8b (`memory.distill`, `.add/tasks/distill/
1702    /// TASK.md` §3 CONTRACT, frozen) — archive every `id` in `ids`: RMW its
1703    /// [`lunaris_core::keyspace::activation_key`] row, set
1704    /// `archived_at = Some(now)`, and commit ALL touched records in exactly
1705    /// ONE batch write — same D-11 shape as [`Self::record_activation_refs`].
1706    ///
1707    /// Archive is activation drop, NOT a tombstone: this method never
1708    /// touches the episode itself (no `forget`/soft-delete). It only flips
1709    /// the ledger marker that [`lunaris_retrieve::LedgerBoostProvider`]
1710    /// (0 boost) and `lunaris_consolidate::dream::build_dream_agenda`
1711    /// (dropped from candidates) both read via
1712    /// [`lunaris_core::activation::ActivationRecord::is_archived`].
1713    ///
1714    /// - An `id` with NO existing ledger record is skipped (already
1715    ///   unboosted — nothing to mark) — this method never CREATES a record.
1716    /// - A corrupt existing record is skipped with a `tracing::warn!`
1717    ///   (mirrors [`Self::record_activation_refs`]'s corrupt-row handling)
1718    ///   rather than failing the whole batch.
1719    /// - Duplicate ids in `ids` are archived once (deduped defensively).
1720    /// - Returns the count of records ACTUALLY marked — ids skipped for
1721    ///   either reason above are not counted.
1722    /// - An empty `ids` slice is a no-op: `Ok(0)`, no storage call at all.
1723    pub async fn archive_activation(&self, ids: &[Ulid], now: u64) -> Result<usize, LunarisError> {
1724        if ids.is_empty() {
1725            return Ok(0);
1726        }
1727
1728        let read_at = self.engine.clock.tick();
1729        let mut seen: HashSet<Ulid> = HashSet::new();
1730        let mut ops: Vec<lunaris_core::WriteOp> = Vec::with_capacity(ids.len());
1731        let mut marked = 0usize;
1732
1733        for &id in ids {
1734            if !seen.insert(id) {
1735                continue; // duplicate id in the input slice — archive once
1736            }
1737            let key = activation_key(&self.scope, id);
1738            let existing = self
1739                .engine
1740                .storage
1741                .read_as_of(&self.scope, &key, read_at)
1742                .await
1743                .map_err(LunarisError::Storage)?;
1744            let Some(row) = existing else {
1745                continue; // no ledger record — already unboosted, nothing to mark
1746            };
1747            let mut record = match serde_json::from_slice::<
1748                lunaris_core::activation::ActivationRecord,
1749            >(&row.value)
1750            {
1751                Ok(r) => r,
1752                Err(e) => {
1753                    tracing::warn!(
1754                        err = %e,
1755                        %id,
1756                        scope = self.scope.as_str(),
1757                        "activation_ledger_corrupt_record_skipped_on_archive"
1758                    );
1759                    continue;
1760                }
1761            };
1762            record.archived_at = Some(now);
1763            let value = serde_json::to_vec(&record).map_err(|e| {
1764                LunarisError::Storage(StorageError::Backend(format!(
1765                    "activation_ledger_serialize_failed: {e}"
1766                )))
1767            })?;
1768            ops.push(lunaris_core::WriteOp::KvPut { key, value });
1769            marked += 1;
1770        }
1771
1772        // Mirrors D-11 / record_activation_refs: exactly ONE batch write for
1773        // the whole call — and skip it entirely when nothing was touched.
1774        if !ops.is_empty() {
1775            self.engine
1776                .storage
1777                .atomic_write(&self.scope, &ops)
1778                .await
1779                .map_err(LunarisError::Storage)?;
1780        }
1781        Ok(marked)
1782    }
1783
1784    /// engram-soul-loop task 8a (dream-agenda) — build a READ-ONLY
1785    /// distillation agenda: Leiden-clustered (or source-class-bucketed)
1786    /// candidate clusters of ripe raw episodes, with activation stats, for
1787    /// the coding-harness distiller to reason over. Never calls
1788    /// `atomic_write` — see `.add/tasks/dream-agenda/TASK.md` §3 CONTRACT.
1789    ///
1790    /// `now` is resolved here (`SystemTime::now()`, unix seconds) rather
1791    /// than threaded from the caller — the frozen §3 engine signature takes
1792    /// a plain `now: u64` (no live `HlcClock`), so this wrapper is the one
1793    /// place that turns "now" into a concrete wall-clock reading.
1794    pub async fn dream_agenda(
1795        &self,
1796        cfg: lunaris_consolidate::DreamConfig,
1797    ) -> Result<lunaris_consolidate::DreamAgenda, LunarisError> {
1798        let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1799        lunaris_consolidate::build_dream_agenda(self.engine.storage.clone(), &self.scope, &cfg, now)
1800            .await
1801    }
1802
1803    /// engram-soul-loop task 6 (staleness-pass) — RMW upsert of verify-
1804    /// agenda entries.
1805    ///
1806    /// For each entry, reads the existing row at
1807    /// `lunaris_core::keyspace::verify_agenda_key(scope, entry.episode_id)`
1808    /// (if any) and preserves its `first_seen_ms` (a missing or corrupt
1809    /// existing row falls back to the caller-supplied `first_seen_ms` —
1810    /// a malformed stored agenda row must not block a fresh upsert), then
1811    /// commits every touched entry in exactly ONE `atomic_write` (mirrors
1812    /// [`Self::record_activation_refs`] / D-11: one atomic write per
1813    /// logical batch). An empty `entries` slice is a no-op — no
1814    /// `atomic_write` call at all.
1815    pub async fn upsert_verify_agenda(
1816        &self,
1817        entries: &[VerifyAgendaEntry],
1818    ) -> Result<(), LunarisError> {
1819        if entries.is_empty() {
1820            return Ok(());
1821        }
1822
1823        let read_at = self.engine.clock.tick();
1824        let mut ops: Vec<lunaris_core::WriteOp> = Vec::with_capacity(entries.len());
1825        for entry in entries {
1826            let key = verify_agenda_key(&self.scope, entry.episode_id);
1827            let first_seen_ms = match self
1828                .engine
1829                .storage
1830                .read_as_of(&self.scope, &key, read_at)
1831                .await
1832                .map_err(LunarisError::Storage)?
1833            {
1834                Some(row) => serde_json::from_slice::<VerifyAgendaEntry>(&row.value)
1835                    .map(|existing| existing.first_seen_ms)
1836                    .unwrap_or_else(|e| {
1837                        tracing::warn!(
1838                            err = %e,
1839                            episode_id = %entry.episode_id,
1840                            scope = self.scope.as_str(),
1841                            "verify_agenda_corrupt_record_reseeded"
1842                        );
1843                        entry.first_seen_ms
1844                    }),
1845                None => entry.first_seen_ms,
1846            };
1847
1848            let mut merged = entry.clone();
1849            merged.first_seen_ms = first_seen_ms;
1850            let value = serde_json::to_vec(&merged).map_err(|e| {
1851                LunarisError::Storage(StorageError::Backend(format!(
1852                    "verify_agenda_serialize_failed: {e}"
1853                )))
1854            })?;
1855            ops.push(lunaris_core::WriteOp::KvPut { key, value });
1856        }
1857
1858        // Mirrors D-11: exactly ONE atomic_write for the whole batch.
1859        self.engine.storage.atomic_write(&self.scope, &ops).await.map_err(LunarisError::Storage)?;
1860        Ok(())
1861    }
1862
1863    /// engram-soul-loop task 7 (verify-agenda-tools) — list every verify-
1864    /// agenda entry under this scope, freshest staleness first.
1865    ///
1866    /// Scans [`lunaris_core::keyspace::verify_agenda_prefix`] (mirrors
1867    /// [`crate::digest::recent_by_source`]'s `StreamExt::next` loop): a
1868    /// mid-stream storage error propagates, but a single corrupt/foreign row
1869    /// is skipped and never aborts the whole list (`.add/tasks/
1870    /// verify-agenda-tools/TASK.md` §1 Reject). Bounded by a 5_000-row scan
1871    /// cap (mirrors `lunaris-hook::staleness::SCAN_CAP`) — a warn-and-partial
1872    /// DoS guard for huge scopes. Results are sorted by `last_seen_ms` DESC
1873    /// (freshest staleness first).
1874    ///
1875    pub async fn list_verify_agenda(&self) -> Result<Vec<VerifyAgendaEntry>, LunarisError> {
1876        use futures::stream::StreamExt;
1877
1878        let prefix = lunaris_core::keyspace::verify_agenda_prefix(&self.scope);
1879        let mut stream = self
1880            .engine
1881            .storage
1882            .scan_range(&self.scope, &prefix, None)
1883            .await
1884            .map_err(LunarisError::Storage)?;
1885
1886        let mut entries: Vec<VerifyAgendaEntry> = Vec::new();
1887        let mut scanned = 0usize;
1888        while let Some(item) = stream.next().await {
1889            // A mid-stream storage error propagates (the storage call
1890            // itself failed) — distinct from a corrupt VALUE, which is
1891            // skipped below without aborting the list.
1892            let (_key, value) = item.map_err(LunarisError::Storage)?;
1893            scanned += 1;
1894            match serde_json::from_slice::<VerifyAgendaEntry>(&value) {
1895                Ok(entry) => entries.push(entry),
1896                Err(e) => {
1897                    tracing::warn!(
1898                        err = %e,
1899                        scope = self.scope.as_str(),
1900                        "verify_agenda_list_corrupt_row_skipped"
1901                    );
1902                }
1903            }
1904            if scanned >= VERIFY_AGENDA_LIST_SCAN_CAP {
1905                tracing::warn!(
1906                    scanned,
1907                    scope = self.scope.as_str(),
1908                    "verify_agenda_list: scan cap reached — partial list"
1909                );
1910                break;
1911            }
1912        }
1913
1914        entries.sort_by(|a, b| b.last_seen_ms.cmp(&a.last_seen_ms));
1915        Ok(entries)
1916    }
1917
1918    /// engram-soul-loop task 7 (verify-agenda-tools) — remove one
1919    /// verify-agenda entry, returning whether it existed.
1920    ///
1921    /// Presence is checked via `read_as_of` on
1922    /// [`lunaris_core::keyspace::verify_agenda_key`]; when present, issues
1923    /// exactly ONE `WriteOp::KvDelete` `atomic_write` (mirrors D-19 — no
1924    /// write at all when the row was already absent, an idempotent no-op).
1925    pub async fn remove_verify_agenda(&self, episode_id: Ulid) -> Result<bool, LunarisError> {
1926        let key = verify_agenda_key(&self.scope, episode_id);
1927        let read_at = self.engine.clock.tick();
1928        let existed = self
1929            .engine
1930            .storage
1931            .read_as_of(&self.scope, &key, read_at)
1932            .await
1933            .map_err(LunarisError::Storage)?
1934            .is_some();
1935
1936        if existed {
1937            let ops = vec![lunaris_core::WriteOp::KvDelete { key }];
1938            self.engine
1939                .storage
1940                .atomic_write(&self.scope, &ops)
1941                .await
1942                .map_err(LunarisError::Storage)?;
1943        }
1944
1945        Ok(existed)
1946    }
1947}
1948
1949/// Hard cap on rows scanned per [`ScopedLunaris::list_verify_agenda`] call
1950/// (mirrors `lunaris-hook::staleness::SCAN_CAP` = 5_000) — a DoS guard for
1951/// huge scopes; excess is a warn-and-partial list, never a hard failure.
1952const VERIFY_AGENDA_LIST_SCAN_CAP: usize = 5_000;
1953
1954/// engram-soul-loop task 6 (staleness-pass) — one verify-agenda entry
1955/// (`.add/tasks/staleness-pass/TASK.md` §3 CONTRACT, task-7 wire shape —
1956/// KEEP STABLE, the MCP `verify_agenda` / `resolve` tools consume this
1957/// exact JSON shape).
1958///
1959/// `episode_id` doubles as the KV key's ULID
1960/// ([`lunaris_core::keyspace::verify_agenda_key`]) — one agenda row per
1961/// stale-anchored episode, RMW-upserted by
1962/// [`ScopedLunaris::upsert_verify_agenda`].
1963#[derive(Debug, Clone, Serialize, Deserialize)]
1964pub struct VerifyAgendaEntry {
1965    pub episode_id: Ulid,
1966    pub anchor_head: String,
1967    pub current_head: String,
1968    pub files: Vec<String>,
1969    pub first_seen_ms: u64,
1970    pub last_seen_ms: u64,
1971    pub v: u32,
1972}
1973
1974// ── llama.cpp-only cutover: embedder + reranker resolution ────────────────────
1975//
1976// The supported runtime is in-process llama.cpp + the frozen GGUF pair
1977// `granite-embedding-311m-multilingual-r2.Q4_K_M` (embedder) and
1978// `bge-reranker-v2-m3.Q5_K_M` (reranker). The knobs are:
1979//
1980// - `LUNARIS_EMBEDDER_GGUF` — path to the embedder GGUF; default is the
1981//   `~/.lunaris/models/` staged artifact.
1982// - `LUNARIS_RERANKER_GGUF` — same for the reranker GGUF.
1983// - `LUNARIS_DEVICE=cpu` — force CPU even on Metal-enabled builds.
1984// - `LUNARIS_EMBEDDER_OPENAI_URL` / `LUNARIS_EMBEDDER_OLLAMA_URL` — remote
1985//   embedder endpoints; only consulted when the `embed-remote` feature is
1986//   enabled (Tier-0 / air-gap path).
1987// - `LUNARIS_EMBEDDER_DIR` — legacy dir override; still consulted for
1988//   `tokenizer.json` by the BPE token counter (`make_token_counter`).
1989// - `LUNARIS_EMBED_DIM` — only applies when the resolver falls back to
1990//   `NoopEmbedder` (no GGUF staged); default 768.
1991//
1992// One-shot tracing::info! per process logs the resolved backend + path; if
1993// the embedder falls back to noop the operator gets a tracing::warn! banner.
1994
1995/// Optional override for the directory holding the granite-r2 model
1996/// artifacts. Default: `<cache-dir>/lunaris/models/granite-embedding-311m-multilingual-r2/`.
1997/// Expected layout: `model.safetensors`, `tokenizer.json`, `config.json`.
1998pub const EMBEDDER_DIR_ENV_VAR: &str = "LUNARIS_EMBEDDER_DIR";
1999
2000/// Optional override for the directory holding the bge-reranker-v2-m3 model
2001/// artifacts. Default: `<cache-dir>/lunaris/models/bge-reranker-v2-m3/`.
2002pub const RERANKER_DIR_ENV_VAR: &str = "LUNARIS_RERANKER_DIR";
2003
2004/// Optional path override for the embedder Q4_K_M GGUF (llama.cpp runtime).
2005/// Default: the `~/.lunaris/models/` staged artifact.
2006pub const EMBEDDER_GGUF_ENV_VAR: &str = "LUNARIS_EMBEDDER_GGUF";
2007
2008/// Optional path override for the reranker Q5_K_M GGUF (llama.cpp runtime).
2009pub const RERANKER_GGUF_ENV_VAR: &str = "LUNARIS_RERANKER_GGUF";
2010
2011/// Env var that controls the dim of the `NoopEmbedder` fallback used when
2012/// the granite-r2 weights are missing AND the operator has not supplied a
2013/// custom embedder via [`Lunaris::with_embedder`]. Positive integer; default
2014/// [`lunaris_core::NOOP_DEFAULT_DIM`] (768).
2015pub const EMBED_DIM_ENV_VAR: &str = "LUNARIS_EMBED_DIM";
2016
2017/// Env var that controls the maximum number of concurrent speculative warm-up
2018/// recall tasks spawned by [`ScopedLunaris::end_turn`] (Phase 14.3).
2019/// Must be a positive integer. `0`, non-numeric, or unset values fall back to
2020/// the default of `4`. One `tracing::info!` is emitted per process when the
2021/// capacity is resolved.
2022pub const PREWARM_CONCURRENCY_ENV_VAR: &str = "LUNARIS_PREWARM_CONCURRENCY";
2023
2024/// Default semaphore capacity for speculative warm-up recalls.
2025const PREWARM_CONCURRENCY_DEFAULT: usize = 4;
2026
2027/// Env var that controls the exact-text embedding cache capacity.
2028///
2029/// Set to `0` to disable the cache. The default is intentionally modest:
2030/// enough for repeated agent prompts, context-injection recalls, and common
2031/// chunk text, but bounded so long-running agents do not grow without limit.
2032pub const EMBED_CACHE_CAPACITY_ENV_VAR: &str = "LUNARIS_EMBED_CACHE_CAPACITY";
2033
2034const EMBED_CACHE_CAPACITY_DEFAULT: usize = 2048;
2035
2036/// Resolve the warm-up semaphore capacity from [`PREWARM_CONCURRENCY_ENV_VAR`].
2037///
2038/// Non-numeric, `0`, and unset values all return `PREWARM_CONCURRENCY_DEFAULT`
2039/// with a `tracing::warn!` for non-numeric/zero inputs. Negative values are
2040/// impossible since we parse as `usize`. A one-shot `tracing::info!` is emitted
2041/// per process on the resolved capacity.
2042fn resolve_prewarm_concurrency() -> usize {
2043    static LOG_ONCE: OnceLock<()> = OnceLock::new();
2044    let capacity = match std::env::var(PREWARM_CONCURRENCY_ENV_VAR).ok().as_deref() {
2045        None | Some("") => PREWARM_CONCURRENCY_DEFAULT,
2046        Some(s) => match s.trim().parse::<usize>() {
2047            Ok(0) => {
2048                tracing::warn!(
2049                    env = PREWARM_CONCURRENCY_ENV_VAR,
2050                    value = s,
2051                    default = PREWARM_CONCURRENCY_DEFAULT,
2052                    "LUNARIS_PREWARM_CONCURRENCY=0 is invalid (would skip all warm-ups); \
2053                     using default"
2054                );
2055                PREWARM_CONCURRENCY_DEFAULT
2056            }
2057            Ok(n) => n,
2058            Err(_) => {
2059                tracing::warn!(
2060                    env = PREWARM_CONCURRENCY_ENV_VAR,
2061                    value = s,
2062                    default = PREWARM_CONCURRENCY_DEFAULT,
2063                    "LUNARIS_PREWARM_CONCURRENCY is not a valid positive integer; using default"
2064                );
2065                PREWARM_CONCURRENCY_DEFAULT
2066            }
2067        },
2068    };
2069    LOG_ONCE.get_or_init(|| {
2070        tracing::info!(
2071            target: "lunaris::handle",
2072            prewarm_concurrency = capacity,
2073            "prewarm_concurrency_resolved"
2074        );
2075    });
2076    capacity
2077}
2078
2079fn embed_cache_capacity() -> Option<NonZeroUsize> {
2080    let capacity = match std::env::var(EMBED_CACHE_CAPACITY_ENV_VAR).ok().as_deref() {
2081        None | Some("") => EMBED_CACHE_CAPACITY_DEFAULT,
2082        Some("0") => return None,
2083        Some(raw) => match raw.trim().parse::<usize>() {
2084            Ok(0) => return None,
2085            Ok(n) => n,
2086            Err(_) => {
2087                tracing::warn!(
2088                    env = EMBED_CACHE_CAPACITY_ENV_VAR,
2089                    value = raw,
2090                    default = EMBED_CACHE_CAPACITY_DEFAULT,
2091                    "LUNARIS_EMBED_CACHE_CAPACITY is not a valid non-negative integer; using default"
2092                );
2093                EMBED_CACHE_CAPACITY_DEFAULT
2094            }
2095        },
2096    };
2097    NonZeroUsize::new(capacity)
2098}
2099
2100fn maybe_cached_embedder(embedder: Arc<dyn Embedder>) -> Arc<dyn Embedder> {
2101    match embed_cache_capacity() {
2102        Some(capacity) => Arc::new(CachedEmbedder::new(embedder, capacity)) as Arc<dyn Embedder>,
2103        None => embedder,
2104    }
2105}
2106
2107static EMBEDDER_BACKEND_LOG_ONCE: OnceLock<()> = OnceLock::new();
2108static RERANKER_BACKEND_LOG_ONCE: OnceLock<()> = OnceLock::new();
2109
2110/// Which embedder backend [`Lunaris::open`] actually resolved in this process.
2111///
2112/// W1.2 (2026-08-21). The `Noop` arm is a **silent** degradation: every vector
2113/// is zeros, so hybrid recall collapses to BM25 + insertion-order tie-breaks
2114/// while every surface keeps answering `200`. Until this enum existed the only
2115/// evidence was one `tracing::warn!` fired once per process — which
2116/// `lunaris-server`'s `/readyz` could not see, so a server built without
2117/// `llamacpp` (the workspace entry sets `default-features = false`) reported
2118/// itself READY with a zero-vector embedder. `dim()` cannot distinguish the
2119/// cases: `NoopEmbedder` reports a non-zero dim on purpose so the operator's
2120/// existing `FT.CREATE` index geometry stays valid.
2121///
2122/// This is deliberately a *structural* signal. A probe must never call
2123/// `embed_batch` to find out — a wedged ggml pool cannot be cancelled from
2124/// async Rust, so an inference-based probe wedges on a timer forever (see
2125/// `lunaris-server/src/readiness.rs` module docs).
2126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2127#[non_exhaustive]
2128pub enum EmbedderBackend {
2129    /// In-process llama.cpp over a staged GGUF — the shipped local path.
2130    LlamaCpp,
2131    /// Remote OpenAI-compatible `/embeddings` endpoint.
2132    OpenAiRemote,
2133    /// Remote Ollama endpoint (operator escape hatch).
2134    OllamaRemote,
2135    /// Zero-vector fallback. Real vectors are NOT being produced.
2136    Noop,
2137    /// `Lunaris::open` has not run in this process — the handle was built
2138    /// through a `with_parts*` test seam, so no backend was resolved. Callers
2139    /// must treat this as "unknown", never as "degraded".
2140    Unresolved,
2141}
2142
2143impl EmbedderBackend {
2144    /// Stable lowercase identifier, safe to compare against across releases.
2145    ///
2146    /// This is the SDK-facing spelling: the Python and TypeScript bindings
2147    /// cannot carry a Rust enum, so `Lunaris::embedder_backend` hands them
2148    /// this string. Treat these values as API — changing one is a breaking
2149    /// change for every caller doing `if backend == "noop"`.
2150    #[must_use]
2151    pub const fn as_str(self) -> &'static str {
2152        match self {
2153            EmbedderBackend::LlamaCpp => "llamacpp",
2154            EmbedderBackend::OpenAiRemote => "openai-remote",
2155            EmbedderBackend::OllamaRemote => "ollama-remote",
2156            EmbedderBackend::Noop => "noop",
2157            EmbedderBackend::Unresolved => "unresolved",
2158        }
2159    }
2160
2161    /// Whether this backend produces real vectors.
2162    ///
2163    /// `Noop` does not — every vector is zeros, so hybrid recall silently
2164    /// collapses to BM25 plus insertion-order tie-breaks while every surface
2165    /// keeps answering successfully. `Unresolved` means `open` has not run in
2166    /// this process, which is "unknown", NOT "degraded", so it answers `true`
2167    /// here rather than raising a false alarm in a test-seam handle.
2168    #[must_use]
2169    pub const fn produces_real_vectors(self) -> bool {
2170        !matches!(self, EmbedderBackend::Noop)
2171    }
2172}
2173
2174impl std::fmt::Display for EmbedderBackend {
2175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2176        f.write_str(self.as_str())
2177    }
2178}
2179
2180static RESOLVED_EMBEDDER_BACKEND: OnceLock<EmbedderBackend> = OnceLock::new();
2181static DEGRADATION_ANNOUNCED: OnceLock<()> = OnceLock::new();
2182
2183/// Environment override that silences `announce_degradation_once`.
2184pub const SUPPRESS_DEGRADED_WARNING_ENV: &str = "LUNARIS_SUPPRESS_DEGRADED_WARNING";
2185
2186/// What [`Lunaris::open`] should do about the backend it just resolved.
2187#[derive(Debug, Clone, PartialEq, Eq)]
2188pub enum DegradationNotice {
2189    /// Say nothing — healthy, unknown, suppressed, or already covered by a
2190    /// subscriber that will receive the `tracing::warn!`.
2191    Silent,
2192    /// Write this to stderr, once per process.
2193    Emit(String),
2194}
2195
2196/// Decide whether a resolved backend should announce itself on stderr.
2197///
2198/// W0.7 successor. `embedder_backend()` made degradation *queryable*, which
2199/// still requires the caller to know to ask. The `tracing::warn!` on the Noop
2200/// path is real but reaches nobody in an SDK process: neither `lunaris-py` nor
2201/// `lunaris-ts` installs a subscriber, so for a `pip install lunaris` user it is
2202/// emitted into a void — and that is precisely the population whose symptom is
2203/// "recall returns nothing and every call succeeded".
2204///
2205/// Split out as a pure function on purpose. The alternative — reading the env
2206/// and the dispatcher inside `open` — is untestable without `env::set_var`,
2207/// which edition 2024 makes `unsafe` and which races every sibling test in the
2208/// same binary through code that never names the variable.
2209///
2210/// `subscriber_installed` should come from `tracing::dispatcher::has_been_set()`:
2211/// a host that set one up already gets the `warn!` through its own routing, and
2212/// printing to stderr as well would both double-report and bypass that routing.
2213#[must_use]
2214pub fn degradation_notice(
2215    backend: EmbedderBackend,
2216    subscriber_installed: bool,
2217    suppress_env: Option<&str>,
2218) -> DegradationNotice {
2219    // `Unresolved` is "open never ran here", not "degraded" — see
2220    // `produces_real_vectors`, which returns true for it for the same reason.
2221    if backend.produces_real_vectors() || subscriber_installed {
2222        return DegradationNotice::Silent;
2223    }
2224    // Key on the ACCEPTED SET, never on presence: an `is_some()` check would let
2225    // `LUNARIS_SUPPRESS_DEGRADED_WARNING=0` — and the empty string a shell
2226    // produces for an exported-but-unset var — silence the one warning the user
2227    // most needs.
2228    if let Some(raw) = suppress_env {
2229        let v = raw.trim().to_ascii_lowercase();
2230        if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
2231            return DegradationNotice::Silent;
2232        }
2233    }
2234    DegradationNotice::Emit(format!(
2235        "lunaris: WARNING — embedder backend is '{backend}': every vector is zeros, \
2236so semantic recall silently degrades to keyword-only while every call keeps \
2237succeeding. Stage a GGUF (LUNARIS_EMBEDDER_GGUF, or ~/.lunaris/models/) or \
2238configure a remote embedder (LUNARIS_EMBEDDER_OPENAI_URL / \
2239LUNARIS_EMBEDDER_OLLAMA_URL). Query it with embedder_backend(); silence this \
2240with {SUPPRESS_DEGRADED_WARNING_ENV}=1."
2241    ))
2242}
2243
2244/// Apply [`degradation_notice`] for the process-resolved backend, at most once.
2245///
2246/// Called at the end of [`Lunaris::open`] — the single seam both SDKs route
2247/// through, so neither needs its own copy and `generated.rs` stays untouched.
2248fn announce_degradation_once() {
2249    if DEGRADATION_ANNOUNCED.get().is_some() {
2250        return;
2251    }
2252    let notice = degradation_notice(
2253        resolved_embedder_backend(),
2254        tracing::dispatcher::has_been_set(),
2255        std::env::var(SUPPRESS_DEGRADED_WARNING_ENV).ok().as_deref(),
2256    );
2257    if let DegradationNotice::Emit(msg) = notice {
2258        // Once per process: the resolution itself is process-global, so warning
2259        // twice would imply two independent decisions were made.
2260        if DEGRADATION_ANNOUNCED.set(()).is_ok() {
2261            eprintln!("{msg}");
2262        }
2263    }
2264}
2265
2266/// The embedder backend [`Lunaris::open`] resolved, or
2267/// [`EmbedderBackend::Unresolved`] if `open` has not run in this process.
2268///
2269/// Process-global because `resolve_embedder` is process-global: it reads env
2270/// and the model cache, and a process runs one server. Set once, on the first
2271/// `open`.
2272#[must_use]
2273pub fn resolved_embedder_backend() -> EmbedderBackend {
2274    RESOLVED_EMBEDDER_BACKEND.get().copied().unwrap_or(EmbedderBackend::Unresolved)
2275}
2276
2277/// Granite-r2 model directory name under `<cache>/lunaris/models/`.
2278const GRANITE_R2_DIR: &str = "granite-embedding-311m-multilingual-r2";
2279/// bge-reranker-v2-m3 model directory name under `<cache>/lunaris/models/`.
2280/// Referenced by the dir-layout unit test (the FP32 dir also provides the
2281/// canonical `tokenizer.json` location some operator tooling still stages).
2282#[cfg(test)]
2283const BGE_RERANKER_DIR: &str = "bge-reranker-v2-m3";
2284
2285/// Resolve the canonical cache directory for a named model artifact. Returns
2286/// `<cache_dir>/lunaris/models/<name>/`, or `./lunaris/models/<name>/` when
2287/// `dirs::cache_dir()` is unavailable (rare on Unix/macOS — surfaced as a
2288/// warning to operators of stripped-down environments).
2289fn default_model_dir(name: &str) -> std::path::PathBuf {
2290    dirs::cache_dir()
2291        .unwrap_or_else(|| std::path::PathBuf::from("."))
2292        .join("lunaris")
2293        .join("models")
2294        .join(name)
2295}
2296
2297/// Resolve the embedder model directory from [`EMBEDDER_DIR_ENV_VAR`],
2298/// falling back to the default cache layout.
2299fn embedder_dir() -> std::path::PathBuf {
2300    std::env::var(EMBEDDER_DIR_ENV_VAR)
2301        .ok()
2302        .filter(|s| !s.trim().is_empty())
2303        .map(std::path::PathBuf::from)
2304        .unwrap_or_else(|| default_model_dir(GRANITE_R2_DIR))
2305}
2306
2307/// Resolve the default embedder for [`Lunaris::open`] (llama.cpp-only
2308/// cutover). Tries:
2309///
2310/// 1. (feature `llamacpp`, default) `LUNARIS_EMBEDDER_GGUF` or the
2311///    `~/.lunaris/models/` staged Q4_K_M GGUF via
2312///    [`lunaris_llamacpp::LlamaCppEmbedder`].
2313/// 2. (feature `embed-remote`) `LUNARIS_EMBEDDER_OPENAI_URL`
2314///    (OpenAI-compatible `/v1/embeddings`) then `LUNARIS_EMBEDDER_OLLAMA_URL`
2315///    — the Tier-0 no-C++-toolchain remote path.
2316/// 3. Otherwise, emit a `tracing::warn!` and fall back to [`NoopEmbedder`]
2317///    at [`lunaris_core::NOOP_DEFAULT_DIM`] so the rest of the open path
2318///    completes (vector recall returns empty rows; operator sees the banner
2319///    and can stage the GGUF).
2320// `max_batch_tokens` feeds only the llamacpp opts; a Tier-0 (no-inference)
2321// build compiles that branch out, so the param is legitimately unused there.
2322#[cfg_attr(not(feature = "llamacpp"), allow(unused_variables))]
2323async fn resolve_embedder(max_batch_tokens: u32) -> Result<Arc<dyn Embedder>, LunarisError> {
2324    // 0. llama.cpp GGUF embedder (cutover Phase B) — wins whenever the
2325    //    feature is compiled in AND the GGUF artifact is reachable
2326    //    (LUNARIS_EMBEDDER_GGUF, else the ~/.lunaris/models/ staged
2327    //    default). Missing artifact or open failure falls through to the
2328    //    remote/Noop chain.
2329    #[cfg(feature = "llamacpp")]
2330    {
2331        if let Some(gguf_path) = llamacpp_gguf_path(EMBEDDER_GGUF_ENV_VAR, LLAMACPP_EMBEDDER_MODEL)
2332        {
2333            let opts = lunaris_llamacpp::LlamaCppEmbedderOpts {
2334                gguf_path: gguf_path.clone(),
2335                n_gpu_layers: llamacpp_gpu_layers(),
2336                max_batch_tokens,
2337                ..Default::default()
2338            };
2339            // Weight load + context creation are synchronous — keep them off
2340            // the runtime worker.
2341            let opened =
2342                tokio::task::spawn_blocking(move || lunaris_llamacpp::LlamaCppEmbedder::open(opts))
2343                    .await
2344                    .map_err(|e| {
2345                        LunarisError::Storage(lunaris_core::StorageError::Backend(format!(
2346                            "llamacpp embedder init join: {e}"
2347                        )))
2348                    })?;
2349            match opened {
2350                Ok(e) => {
2351                    let _ = RESOLVED_EMBEDDER_BACKEND.set(EmbedderBackend::LlamaCpp);
2352                    EMBEDDER_BACKEND_LOG_ONCE.get_or_init(|| {
2353                        tracing::info!(
2354                            target: "lunaris::handle",
2355                            embedder_backend = "llamacpp",
2356                            gguf = %gguf_path.display(),
2357                            "embedder_backend_resolved"
2358                        );
2359                    });
2360                    return Ok(Arc::new(e) as Arc<dyn Embedder>);
2361                }
2362                Err(err) => {
2363                    tracing::warn!(
2364                        error = %err,
2365                        gguf = %gguf_path.display(),
2366                        "llamacpp embedder failed to open; falling through to the \
2367                         remote/Noop chain"
2368                    );
2369                }
2370            }
2371        }
2372    }
2373
2374    // 1. Remote OpenAI-compatible embedder (`POST /v1/embeddings`) — the
2375    //    supported remote path when no local GGUF is reachable. Selected
2376    //    when LUNARIS_EMBEDDER_OPENAI_URL is set; wins over the Ollama hatch.
2377    #[cfg(feature = "embed-remote")]
2378    {
2379        if std::env::var(lunaris_embed_remote::openai::OPENAI_URL_ENV_VAR)
2380            .ok()
2381            .filter(|s| !s.trim().is_empty())
2382            .is_some()
2383        {
2384            let opts = lunaris_embed_remote::openai::OpenAiEmbedderOpts::default();
2385            let e = lunaris_embed_remote::openai::OpenAiEmbedder::new(opts)?;
2386            let _ = RESOLVED_EMBEDDER_BACKEND.set(EmbedderBackend::OpenAiRemote);
2387            EMBEDDER_BACKEND_LOG_ONCE.get_or_init(|| {
2388                tracing::info!(
2389                    target: "lunaris::handle",
2390                    embedder_backend = "openai-remote",
2391                    "embedder_backend_resolved (remote OpenAI-compatible /embeddings)"
2392                );
2393            });
2394            return Ok(Arc::new(e) as Arc<dyn Embedder>);
2395        }
2396    }
2397
2398    // 1b. Ollama HTTP escape hatch — legacy remote path.
2399    #[cfg(feature = "embed-remote")]
2400    {
2401        if let Some(url) =
2402            std::env::var(lunaris_embed_remote::OLLAMA_URL_ENV_VAR).ok().filter(|s| !s.is_empty())
2403        {
2404            let opts =
2405                lunaris_embed_remote::OllamaEmbedderOpts { endpoint: url, ..Default::default() };
2406            let e = lunaris_embed_remote::OllamaEmbedder::new(opts)?;
2407            let _ = RESOLVED_EMBEDDER_BACKEND.set(EmbedderBackend::OllamaRemote);
2408            EMBEDDER_BACKEND_LOG_ONCE.get_or_init(|| {
2409                tracing::info!(
2410                    target: "lunaris::handle",
2411                    embedder_backend = "ollama-remote",
2412                    "embedder_backend_resolved (operator escape hatch)"
2413                );
2414            });
2415            return Ok(Arc::new(e) as Arc<dyn Embedder>);
2416        }
2417    }
2418
2419    // 2. No local runtime reachable — NoopEmbedder (zero vectors). Rows
2420    //    ingested in this state are written WITHOUT a `vec` field (see
2421    //    `lunaris_storage_moon::atomic::unindexable_reason`), so they are
2422    //    absent from vector recall but still reachable by BM25 and still
2423    //    hydratable; a later real embedding for the same id promotes them
2424    //    into the KNN index.
2425    //
2426    //    That skip is load-bearing, not tidiness. A zero vector is NOT a
2427    //    neutral placeholder: under the `1/(1+d)` score it sits at distance
2428    //    `||q||` from any unit query and OUTRANKS genuine matches, forever
2429    //    (F22). Before the skip, this comment claimed vector recall returned
2430    //    empty rows here — it did not, and the false comment is part of why
2431    //    the defect went unnoticed.
2432    let dim = resolve_embed_dim();
2433    let _ = RESOLVED_EMBEDDER_BACKEND.set(EmbedderBackend::Noop);
2434    EMBEDDER_BACKEND_LOG_ONCE.get_or_init(|| {
2435        tracing::warn!(
2436            target: "lunaris::handle",
2437            fallback_dim = dim,
2438            "no embedder backend available — using NoopEmbedder (zero vectors). \
2439             Stage the llama.cpp GGUF (LUNARIS_EMBEDDER_GGUF or ~/.lunaris/models/) \
2440             or configure a remote embedder (--features embed-remote + \
2441             LUNARIS_EMBEDDER_OPENAI_URL / LUNARIS_EMBEDDER_OLLAMA_URL) for real vectors."
2442        );
2443    });
2444    Ok(Arc::new(lunaris_core::NoopEmbedder::new(dim)) as Arc<dyn Embedder>)
2445}
2446
2447/// Parse a batch-token budget from an env value, falling back to `default` for
2448/// any absent / empty / non-numeric / below-floor input. The `>= 16` floor
2449/// mirrors the embedder's own `budget.max(16)` guard so a bogus env can never
2450/// produce a degenerate llama context. Pure (env read stays in the wrappers) so
2451/// tests need no `env::set_var` — the crate is edition-2024 where that is unsafe.
2452fn parse_batch_tokens(raw: Option<String>, default: u32) -> u32 {
2453    raw.and_then(|s| s.trim().parse::<u32>().ok()).filter(|&n| n >= 16).unwrap_or(default)
2454}
2455
2456/// General-purpose embedder batch-token budget (`Lunaris::open`): default 4096,
2457/// overridable via `LUNARIS_EMBED_MAX_BATCH_TOKENS`. Bench/ingest want a large
2458/// window so long documents embed without truncation.
2459fn embed_max_batch_tokens() -> u32 {
2460    parse_batch_tokens(std::env::var("LUNARIS_EMBED_MAX_BATCH_TOKENS").ok(), 4096)
2461}
2462
2463/// contextd (interactive) embedder batch-token budget: default 1024, overridable
2464/// via `LUNARIS_CONTEXT_EMBED_MAX_BATCH_TOKENS`. A long-lived daemon that only
2465/// embeds short hook captures does not need the 4096 throughput window, whose
2466/// llama.cpp compute-buffer reservation cost ~2.5 GB (the 2026-07-14 contextd
2467/// footprint); 1024 reserves ~1.1 GB with no truncation of real captures.
2468fn context_embed_max_batch_tokens() -> u32 {
2469    parse_batch_tokens(std::env::var("LUNARIS_CONTEXT_EMBED_MAX_BATCH_TOKENS").ok(), 1024)
2470}
2471
2472/// Resolve the process-default embedder (the same llama.cpp GGUF → remote →
2473/// Noop chain [`Lunaris::open`] uses internally), exposed so a long-lived host
2474/// (e.g. `lunaris-contextd`) can load it ONCE and share the resulting
2475/// `Arc<dyn Embedder>` across many per-scope [`Lunaris`] handles via
2476/// [`Lunaris::open_with_embedder`] — instead of loading a full resident GGUF
2477/// model per scope (the 7.32 GB contextd RSS leak, 2026-07-14).
2478pub async fn resolve_default_embedder() -> Result<Arc<dyn Embedder>, LunarisError> {
2479    resolve_embedder(context_embed_max_batch_tokens()).await
2480}
2481
2482/// Deferred-load twin of [`resolve_default_embedder`] (unified-inference,
2483/// 2026-07-19). The returned handle resolves NOTHING at construction — the
2484/// full GGUF resolve chain only runs on the first `embed_batch` call, and the
2485/// result is cached for the process lifetime.
2486///
2487/// This exists for hosts that normally NEVER embed in-process: `lunaris-mcp`
2488/// proxies every embed-needing op to the warm `lunaris-contextd` daemon, so an
2489/// eager boot-time load parks a second resident copy of the weights (and a
2490/// second llama.cpp threadpool) next to contextd's — the double-residency
2491/// found in the 2026-07-19 CPU investigation. With this handle the local
2492/// weights only materialize if an embed op must genuinely be served in-process
2493/// (standalone npx/uvx installs, or contextd unreachable).
2494///
2495/// The deferred resolve runs the same GGUF → remote → Noop chain as
2496/// [`resolve_default_embedder`] and caches whatever it lands on. It does NOT
2497/// hard-error on a `NoopEmbedder` fallback: **ingest** legitimately runs
2498/// without a dense embedder (the KV + BM25 write still succeeds; only vector
2499/// recall degrades), exactly as the pre-lazy `NoopEmbedder` path did. The
2500/// "no embedder → loud error instead of silent empty hits"
2501/// (`mcp-recall-empty-hits`) guard lives on the **recall** path
2502/// (`lunaris_memory_service::recall::handle`), which is the only caller for
2503/// which a zero query vector is a silent-failure — ingest storing a
2504/// zero-vector is degraded-but-useful, not a lie.
2505pub fn lazy_default_embedder() -> Arc<dyn Embedder> {
2506    Arc::new(LazyDefaultEmbedder { cell: tokio::sync::OnceCell::new() })
2507}
2508
2509/// See [`lazy_default_embedder`].
2510struct LazyDefaultEmbedder {
2511    cell: tokio::sync::OnceCell<Arc<dyn Embedder>>,
2512}
2513
2514impl LazyDefaultEmbedder {
2515    async fn get_or_load(&self) -> Result<&Arc<dyn Embedder>, LunarisError> {
2516        self.cell.get_or_try_init(resolve_default_embedder).await
2517    }
2518}
2519
2520impl std::fmt::Debug for LazyDefaultEmbedder {
2521    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2522        f.debug_struct("LazyDefaultEmbedder").field("loaded", &self.cell.initialized()).finish()
2523    }
2524}
2525
2526#[async_trait::async_trait]
2527impl Embedder for LazyDefaultEmbedder {
2528    fn dim(&self) -> usize {
2529        // Loaded → the real backend's dim. Unloaded → the configured default
2530        // (LUNARIS_EMBED_DIM, else 768 — granite-r2's width), WITHOUT forcing
2531        // a load: dim() is called on cold paths (index bootstrap) that must
2532        // not pull weights in.
2533        self.cell.get().map(|e| e.dim()).unwrap_or_else(resolve_embed_dim)
2534    }
2535
2536    async fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError> {
2537        self.get_or_load().await?.embed_batch(inputs).await
2538    }
2539
2540    async fn embed_batch_lowpri(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError> {
2541        // Forward to the inner lowpri lane — the trait default would route
2542        // through embed_batch and head-of-line-block interactive recall.
2543        self.get_or_load().await?.embed_batch_lowpri(inputs).await
2544    }
2545}
2546
2547/// Resolve the NoopEmbedder fallback dim from [`EMBED_DIM_ENV_VAR`].
2548fn resolve_embed_dim() -> usize {
2549    static LOG_ONCE: OnceLock<()> = OnceLock::new();
2550    let dim = match std::env::var(EMBED_DIM_ENV_VAR).ok().as_deref() {
2551        None | Some("") => lunaris_core::NOOP_DEFAULT_DIM,
2552        Some(s) => match s.trim().parse::<usize>() {
2553            Ok(0) => {
2554                tracing::warn!(
2555                    env = EMBED_DIM_ENV_VAR,
2556                    value = s,
2557                    default = lunaris_core::NOOP_DEFAULT_DIM,
2558                    "LUNARIS_EMBED_DIM=0 is invalid (storage rejects dim=0); using default"
2559                );
2560                lunaris_core::NOOP_DEFAULT_DIM
2561            }
2562            Ok(n) => n,
2563            Err(_) => {
2564                tracing::warn!(
2565                    env = EMBED_DIM_ENV_VAR,
2566                    value = s,
2567                    default = lunaris_core::NOOP_DEFAULT_DIM,
2568                    "LUNARIS_EMBED_DIM is not a valid positive integer; using default"
2569                );
2570                lunaris_core::NOOP_DEFAULT_DIM
2571            }
2572        },
2573    };
2574    LOG_ONCE.get_or_init(|| {
2575        tracing::info!(
2576            target: "lunaris::handle",
2577            embed_dim = dim,
2578            "embed_dim_resolved"
2579        );
2580    });
2581    dim
2582}
2583
2584/// Resolve the default reranker for [`Lunaris::open`] (llama.cpp-only
2585/// cutover). Tries:
2586///
2587/// 1. (feature `llamacpp`, default) `LUNARIS_RERANKER_GGUF` or the
2588///    `~/.lunaris/models/` staged Q5_K_M GGUF, deferred-loaded via
2589///    `LazyLlamaCppReranker` (N-04 D1).
2590/// 2. Otherwise fall back to [`NoopReranker`] per the RETRIEVE-06 contract —
2591///    the recall path runs end-to-end even without the cross-encoder pass.
2592async fn resolve_reranker() -> Result<Arc<dyn Reranker>, LunarisError> {
2593    // 0. llama.cpp GGUF reranker (cutover Phase B) — same precedence rule as
2594    //    the embedder. Load is DEFERRED to the first `rerank()` call via
2595    //    `LazyLlamaCppReranker` (N-04 D1: the recall hot path may never
2596    //    reach the rerank stage; don't pay the weight load + context RSS at
2597    //    open()). Pre-flight only checks the artifact exists so a typo'd
2598    //    path still falls through to Noop immediately.
2599    #[cfg(feature = "llamacpp")]
2600    {
2601        if let Some(gguf_path) = llamacpp_gguf_path(RERANKER_GGUF_ENV_VAR, LLAMACPP_RERANKER_MODEL)
2602        {
2603            let lazy = LazyLlamaCppReranker::new(lunaris_llamacpp::LlamaCppRerankerOpts {
2604                gguf_path: gguf_path.clone(),
2605                n_gpu_layers: llamacpp_gpu_layers(),
2606                ..Default::default()
2607            });
2608            RERANKER_BACKEND_LOG_ONCE.get_or_init(|| {
2609                tracing::info!(
2610                    target: "lunaris::handle",
2611                    reranker_backend = "llamacpp (lazy)",
2612                    gguf = %gguf_path.display(),
2613                    "reranker_backend_resolved (load deferred to first rerank())"
2614                );
2615            });
2616            return Ok(Arc::new(lazy) as Arc<dyn Reranker>);
2617        }
2618    }
2619
2620    // 1. No local runtime reachable — NoopReranker (rerank pass skipped per
2621    //    RETRIEVE-06). Stage the llama.cpp GGUF for a real reranker.
2622    RERANKER_BACKEND_LOG_ONCE.get_or_init(|| {
2623        tracing::info!(
2624            target: "lunaris::handle",
2625            reranker_backend = "noop",
2626            "no reranker backend available — using NoopReranker (rerank pass skipped \
2627             per RETRIEVE-06 contract). Stage the llama.cpp GGUF \
2628             (LUNARIS_RERANKER_GGUF or ~/.lunaris/models/) for a real reranker."
2629        );
2630    });
2631    Ok(Arc::new(NoopReranker) as Arc<dyn Reranker>)
2632}
2633
2634/// Resolve the process-default reranker (the same lazy llama.cpp GGUF → Noop
2635/// chain [`Lunaris::open`] uses), exposed alongside [`resolve_default_embedder`]
2636/// so a long-lived host can load it ONCE and share the `Arc<dyn Reranker>`
2637/// across per-scope handles via [`Lunaris::with_reranker`]. Like the embedder,
2638/// the reranker model is scope-independent; a per-scope reranker was the other
2639/// half of the 7.32 GB contextd RSS growth (2026-07-14). The returned reranker
2640/// is lazy — its GGUF still loads on the first `rerank()`, now exactly once.
2641pub async fn resolve_default_reranker() -> Result<Arc<dyn Reranker>, LunarisError> {
2642    resolve_reranker().await
2643}
2644
2645/// Plan 03-03: Construct the default extractor for [`Lunaris::open`].
2646///
2647/// Callers wire their own extractor via [`Lunaris::with_extractor`] or
2648/// `handle.graph_pipeline().set_extractor(extractor)` for late binding.
2649/// Cutover decision 1 (llama.cpp-only, 2026-07-10): extraction is going
2650/// remote-only. When `LUNARIS_EXTRACT_PROVIDER` names a provider
2651/// (anthropic|openai|gemini|minimax|openai-compat), construct the
2652/// `CloudApiExtractor` from env — model via `<PROVIDER>_EXTRACT_MODEL`,
2653/// key via `<PROVIDER>_API_KEY` (optional for openai-compat), base URL via
2654/// `LUNARIS_OPENAI_COMPAT_BASE_URL` — wrapped in the production fallback
2655/// floor. A set-but-broken provider degrades to `NoopExtractor` with a
2656/// warn (config error must NOT silently fall back to a different backend).
2657/// Returns `None` when the env is unset → caller continues its chain.
2658#[cfg(feature = "cloud-api")]
2659fn remote_extractor_from_env() -> Option<Arc<dyn Extractor>> {
2660    let raw = std::env::var("LUNARIS_EXTRACT_PROVIDER").ok()?;
2661    if raw.trim().is_empty() {
2662        return None;
2663    }
2664    let opts = lunaris_extract::CloudApiExtractorOpts::default();
2665    let label = opts.model.clone();
2666    match lunaris_extract::CloudApiExtractor::new(opts) {
2667        Ok(e) => {
2668            tracing::info!(
2669                target: "lunaris::handle",
2670                provider = %raw.trim(),
2671                model = %label,
2672                "extractor_backend_resolved (remote cloud-api)"
2673            );
2674            Some(lunaris_extract::fallback::fallback_wrap(e, &label))
2675        }
2676        Err(e) => {
2677            tracing::warn!(
2678                error = %e,
2679                provider = %raw.trim(),
2680                "LUNARIS_EXTRACT_PROVIDER set but the remote extractor failed to construct; \
2681                 graph extraction disabled (NoopExtractor) until the config is fixed"
2682            );
2683            Some(Arc::new(NoopExtractor) as Arc<dyn Extractor>)
2684        }
2685    }
2686}
2687
2688/// Verifier twin of [`remote_extractor_from_env`], keyed on
2689/// `LUNARIS_VERIFY_PROVIDER` (`lunaris_verify::cloud_api::ENV_PROVIDER`).
2690#[cfg(feature = "cloud-api")]
2691fn remote_verifier_from_env() -> Option<Arc<dyn Verifier>> {
2692    let raw = std::env::var(lunaris_verify::cloud_api::ENV_PROVIDER).ok()?;
2693    if raw.trim().is_empty() {
2694        return None;
2695    }
2696    let opts = lunaris_verify::CloudApiVerifierOpts::default();
2697    match lunaris_verify::CloudApiVerifier::new(opts) {
2698        Ok(v) => {
2699            tracing::info!(
2700                target: "lunaris::handle",
2701                provider = %raw.trim(),
2702                "verifier_backend_resolved (remote cloud-api)"
2703            );
2704            Some(Arc::new(v) as Arc<dyn Verifier>)
2705        }
2706        Err(e) => {
2707            tracing::warn!(
2708                error = %e,
2709                provider = %raw.trim(),
2710                "LUNARIS_VERIFY_PROVIDER set but the remote verifier failed to construct; \
2711                 verification disabled (NoopVerifier) until the config is fixed"
2712            );
2713            Some(Arc::new(NoopVerifier) as Arc<dyn Verifier>)
2714        }
2715    }
2716}
2717
2718/// Remote-only (llama.cpp cutover, Phase C): a remote provider env resolves
2719/// a real extractor; otherwise degraded mode. Production callers wanting
2720/// graph extraction either set `LUNARIS_EXTRACT_PROVIDER` or pass a custom
2721/// [`Extractor`] impl (e.g., [`lunaris_extract::OllamaExtractor`] under the
2722/// `ollama` feature) via [`Lunaris::with_extractor`].
2723async fn default_extractor() -> Arc<dyn Extractor> {
2724    #[cfg(feature = "cloud-api")]
2725    if let Some(e) = remote_extractor_from_env() {
2726        return e;
2727    }
2728    Arc::new(NoopExtractor) as Arc<dyn Extractor>
2729}
2730
2731/// Plan 04-04: Construct the default verifier for [`Lunaris::open`].
2732///
2733/// Remote-only (llama.cpp cutover, Phase C): `LUNARIS_VERIFY_PROVIDER`
2734/// resolves a real remote verifier; otherwise [`NoopVerifier`] per the D-02
2735/// default-OFF contract. Callers wire their own verifier via
2736/// [`Lunaris::with_verifier`].
2737async fn default_verifier() -> Arc<dyn Verifier> {
2738    #[cfg(feature = "cloud-api")]
2739    if let Some(v) = remote_verifier_from_env() {
2740        return v;
2741    }
2742    Arc::new(NoopVerifier) as Arc<dyn Verifier>
2743}
2744
2745/// Plan 04-04 + Phase 16-01 (CONSOL-V1-01): Construct the default consolidator
2746/// for [`Lunaris::open`], resolving from
2747/// [`ConsolidatorPipelineHandle::BACKEND_ENV_VAR`] (`LUNARIS_CONSOLIDATOR_BACKEND`).
2748///
2749/// Default (env unset) → [`lunaris_consolidate::ActRConsolidator`] (production
2750/// default per CONSOL-V1-01). Operators opt out to [`NoopConsolidator`] by
2751/// setting `LUNARIS_CONSOLIDATOR_BACKEND=noop` (preserved third toggle surface:
2752/// code override via [`Lunaris::with_consolidator`] also still works).
2753///
2754/// Unknown env values fail-fast via [`LunarisError::Storage`]
2755/// (`StorageError::Backend`) — NO silent fallback.
2756fn default_consolidator() -> Result<Arc<dyn Consolidator>, LunarisError> {
2757    ConsolidatorPipelineHandle::backend_from_env()
2758}
2759
2760// ── llama.cpp cutover Phase B — path resolution + lazy reranker ─────────────
2761
2762/// The staged artifacts under `~/.lunaris/models/`, named by the ONE catalogue
2763/// every stager reads.
2764///
2765/// W0.7: the filenames used to be literals here and again in each of the two
2766/// stagers. Staging 253 MB under a name this lookup does not consult is a
2767/// silent no-op that presents as success, so the agreement is now structural
2768/// — there is a single `filename()`, and both sides call it.
2769#[cfg(feature = "llamacpp")]
2770const LLAMACPP_EMBEDDER_MODEL: lunaris_core::models::ModelKind =
2771    lunaris_core::models::ModelKind::EmbedderGraniteQ4KM;
2772#[cfg(feature = "llamacpp")]
2773const LLAMACPP_RERANKER_MODEL: lunaris_core::models::ModelKind =
2774    lunaris_core::models::ModelKind::RerankerBgeV2M3Q5KM;
2775
2776/// Resolve a llama.cpp GGUF artifact: env override first, then the staged
2777/// `~/.lunaris/models/` default. Returns `None` (→ caller falls through to
2778/// the candle chain) unless the file actually exists.
2779#[cfg(feature = "llamacpp")]
2780fn llamacpp_gguf_path(
2781    env_var: &str,
2782    kind: lunaris_core::models::ModelKind,
2783) -> Option<std::path::PathBuf> {
2784    if let Some(p) = std::env::var_os(env_var)
2785        .map(std::path::PathBuf::from)
2786        .filter(|p| !p.as_os_str().is_empty())
2787    {
2788        if p.exists() {
2789            return Some(p);
2790        }
2791        tracing::warn!(
2792            env = env_var,
2793            path = %p.display(),
2794            "GGUF path from env does not exist; falling through"
2795        );
2796        return None;
2797    }
2798    // Same resolution the stager writes to — one function, so a staged file
2799    // and the lookup for it cannot land in different directories.
2800    lunaris_core::models::staged_path(kind).filter(|p| p.exists())
2801}
2802
2803/// GPU offload for the llama.cpp backends: everything when the `metal`
2804/// feature is compiled in (Apple Silicon default), nothing otherwise.
2805/// `LUNARIS_DEVICE=cpu` is the operator kill-switch, mirroring the candle
2806/// `device_select` contract.
2807#[cfg(feature = "llamacpp")]
2808fn llamacpp_gpu_layers() -> u32 {
2809    let forced_cpu = std::env::var("LUNARIS_DEVICE")
2810        .map(|v| v.trim().eq_ignore_ascii_case("cpu"))
2811        .unwrap_or(false);
2812    if !forced_cpu && cfg!(feature = "metal") { u32::MAX } else { 0 }
2813}
2814
2815/// Deferred-load wrapper for [`lunaris_llamacpp::LlamaCppReranker`]
2816/// (N-04 D1 rationale: the Q5_K_M weights + warm context only materialize
2817/// on the first `rerank()` call; `applies()` answers `true` eagerly because
2818/// config promises a real reranker). On init failure the OnceCell stays
2819/// empty so a later call can retry.
2820#[cfg(feature = "llamacpp")]
2821struct LazyLlamaCppReranker {
2822    opts: lunaris_llamacpp::LlamaCppRerankerOpts,
2823    cell: tokio::sync::OnceCell<Arc<lunaris_llamacpp::LlamaCppReranker>>,
2824}
2825
2826#[cfg(feature = "llamacpp")]
2827impl LazyLlamaCppReranker {
2828    fn new(opts: lunaris_llamacpp::LlamaCppRerankerOpts) -> Self {
2829        Self { opts, cell: tokio::sync::OnceCell::new() }
2830    }
2831
2832    async fn get_or_load(&self) -> Result<Arc<lunaris_llamacpp::LlamaCppReranker>, LunarisError> {
2833        let opts = self.opts.clone();
2834        self.cell
2835            .get_or_try_init(|| async move {
2836                tokio::task::spawn_blocking(move || lunaris_llamacpp::LlamaCppReranker::open(opts))
2837                    .await
2838                    .map_err(|e| {
2839                        LunarisError::Storage(lunaris_core::StorageError::Backend(format!(
2840                            "lazy llamacpp reranker init join: {e}"
2841                        )))
2842                    })?
2843                    .map(Arc::new)
2844                    .map_err(LunarisError::from)
2845            })
2846            .await
2847            .cloned()
2848    }
2849}
2850
2851#[cfg(feature = "llamacpp")]
2852impl std::fmt::Debug for LazyLlamaCppReranker {
2853    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2854        f.debug_struct("LazyLlamaCppReranker")
2855            .field("gguf", &self.opts.gguf_path)
2856            .field("loaded", &self.cell.initialized())
2857            .finish()
2858    }
2859}
2860
2861#[cfg(feature = "llamacpp")]
2862#[async_trait::async_trait]
2863impl Reranker for LazyLlamaCppReranker {
2864    fn applies(&self) -> bool {
2865        // Config promises a real reranker — answer eagerly so
2866        // `Hit { rerank_applied }` doesn't lie on cold paths.
2867        true
2868    }
2869
2870    async fn rerank(
2871        &self,
2872        query: &str,
2873        docs: Vec<lunaris_rerank::RerankCandidate>,
2874    ) -> Result<Vec<lunaris_rerank::RerankCandidate>, LunarisError> {
2875        let inner = self.get_or_load().await?;
2876        inner.rerank(query, docs).await
2877    }
2878}
2879
2880// ── v0.4 N-03 — unit tests for env-var resolution (cache-dir layout) ─────────
2881//
2882// `resolve_embedder()` / `resolve_reranker()` are async + perform I/O; the
2883// unit tests below cover only the pure path-resolution helpers and the
2884// `resolve_embed_dim()` parser, which are deterministic and side-effect-free.
2885// Construction of the real llama.cpp embedder/reranker is exercised by
2886// lunaris-llamacpp's own integration tests + the `llamacpp_wired` test.
2887#[cfg(test)]
2888mod backend_resolution_tests {
2889    use super::*;
2890    use lunaris_core::StubEmbedder;
2891
2892    struct CountingEmbedder {
2893        inner: StubEmbedder,
2894        calls: Arc<AtomicUsize>,
2895    }
2896
2897    #[async_trait::async_trait]
2898    impl Embedder for CountingEmbedder {
2899        fn dim(&self) -> usize {
2900            self.inner.dim()
2901        }
2902
2903        async fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vec<f32>>, LunarisError> {
2904            self.calls.fetch_add(1, Ordering::Relaxed);
2905            self.inner.embed_batch(inputs).await
2906        }
2907    }
2908
2909    #[test]
2910    fn default_model_dir_layout_is_canonical() {
2911        let p = default_model_dir(GRANITE_R2_DIR);
2912        assert!(
2913            p.ends_with("lunaris/models/granite-embedding-311m-multilingual-r2"),
2914            "default granite-r2 dir was: {}",
2915            p.display()
2916        );
2917        let p = default_model_dir(BGE_RERANKER_DIR);
2918        assert!(
2919            p.ends_with("lunaris/models/bge-reranker-v2-m3"),
2920            "default bge dir was: {}",
2921            p.display()
2922        );
2923    }
2924
2925    #[test]
2926    fn env_var_constants_are_grep_pinned() {
2927        // Pin the v0.4 env-var surface area so accidental renames surface in
2928        // review. Operators wire these strings into Helm charts / k8s
2929        // manifests; renaming silently breaks deployments.
2930        assert_eq!(EMBEDDER_DIR_ENV_VAR, "LUNARIS_EMBEDDER_DIR");
2931        assert_eq!(RERANKER_DIR_ENV_VAR, "LUNARIS_RERANKER_DIR");
2932        assert_eq!(EMBEDDER_GGUF_ENV_VAR, "LUNARIS_EMBEDDER_GGUF");
2933        assert_eq!(RERANKER_GGUF_ENV_VAR, "LUNARIS_RERANKER_GGUF");
2934        assert_eq!(EMBED_DIM_ENV_VAR, "LUNARIS_EMBED_DIM");
2935    }
2936
2937    #[tokio::test]
2938    async fn cached_embedder_dedupes_batch_and_reuses_later_hits() {
2939        let calls = Arc::new(AtomicUsize::new(0));
2940        let inner = Arc::new(CountingEmbedder { inner: StubEmbedder::new(8), calls: calls.clone() })
2941            as Arc<dyn Embedder>;
2942        let cached = CachedEmbedder::new(inner, NonZeroUsize::new(8).unwrap());
2943
2944        let first = cached.embed_batch(&["alpha", "alpha", "beta"]).await.unwrap();
2945        assert_eq!(first.len(), 3);
2946        assert_eq!(calls.load(Ordering::Relaxed), 1, "first batch should dedupe misses");
2947        assert_eq!(first[0], first[1]);
2948
2949        let second = cached.embed_batch(&["beta", "alpha"]).await.unwrap();
2950        assert_eq!(second.len(), 2);
2951        assert_eq!(
2952            calls.load(Ordering::Relaxed),
2953            1,
2954            "second batch should be served entirely from cache"
2955        );
2956    }
2957
2958    // contextd-embed-budget (2026-07-14): contextd's embedder llama.cpp context
2959    // reserved ~2.5 GB because max_batch_tokens=4096 (the n_ubatch compute
2960    // buffer). contextd only embeds short hook captures, so it uses a smaller
2961    // budget (default 1024 → ~1.1 GB) while Lunaris::open keeps 4096.
2962
2963    #[test]
2964    fn parse_batch_tokens_defaults_when_absent() {
2965        assert_eq!(parse_batch_tokens(None, 1024), 1024);
2966        assert_eq!(parse_batch_tokens(None, 4096), 4096);
2967    }
2968
2969    #[test]
2970    fn parse_batch_tokens_honors_numeric_override() {
2971        assert_eq!(parse_batch_tokens(Some("2048".to_owned()), 1024), 2048);
2972        assert_eq!(parse_batch_tokens(Some("  512 ".to_owned()), 1024), 512);
2973    }
2974
2975    #[test]
2976    fn parse_batch_tokens_falls_back_on_bogus() {
2977        assert_eq!(parse_batch_tokens(Some(String::new()), 1024), 1024, "empty -> default");
2978        assert_eq!(
2979            parse_batch_tokens(Some("abc".to_owned()), 1024),
2980            1024,
2981            "non-numeric -> default"
2982        );
2983        assert_eq!(parse_batch_tokens(Some("8".to_owned()), 1024), 1024, "below the >=16 floor");
2984    }
2985
2986    #[test]
2987    fn context_budget_is_1024_general_is_4096_by_default() {
2988        // Read-only: the test process does not set these env vars, so the
2989        // wrappers return their defaults (no env::set_var — edition-2024 unsafe).
2990        assert_eq!(context_embed_max_batch_tokens(), 1024);
2991        assert_eq!(embed_max_batch_tokens(), 4096);
2992    }
2993}
2994
2995// ── Phase 13 unit tests — end_turn / ReflectSupervisor wire-up ──────────────
2996//
2997// All tests use stub storage + `StubEmbedder` from lunaris_core (proven by
2998// the existing verify_pipeline_smoke integration tests) so no I/O is
2999// performed. The three tests cover:
3000//   1. Default Noop supervisor → empty ReflectOutput.
3001//   2. Custom stub supervisor → output propagates; input fields thread through.
3002//   3. Supervisor returning Err → end_turn propagates the error.
3003#[cfg(test)]
3004mod end_turn_tests {
3005    use super::*;
3006    use async_trait::async_trait;
3007    use bytes::Bytes;
3008    use futures::stream::{self, BoxStream};
3009    use lunaris_core::storage::keyword::{KeywordHit, KeywordPort};
3010    use lunaris_core::storage::types::{
3011        CypherQuery, Filter, GraphResult, Lsn, QueueMsg, Row, VectorHit, WriteOp,
3012    };
3013    use lunaris_core::{
3014        CypherDialect, HlcClock, LunarisError, Scope, StorageCapabilities, StorageError,
3015        StoragePort, StubEmbedder,
3016    };
3017    use lunaris_verify::{ReflectInput, ReflectOutput, ReflectSupervisor};
3018    use std::sync::Arc;
3019    use ulid::Ulid;
3020
3021    // ── minimal stub storage (matches actual StoragePort signatures) ──────────
3022
3023    struct NullStorage;
3024
3025    #[async_trait]
3026    impl StoragePort for NullStorage {
3027        async fn atomic_write(
3028            &self,
3029            _scope: &Scope,
3030            _ops: &[WriteOp],
3031        ) -> Result<Lsn, StorageError> {
3032            Ok(Lsn { wall_ms: 1, counter: 0 })
3033        }
3034
3035        async fn read_as_of(
3036            &self,
3037            _scope: &Scope,
3038            _key: &[u8],
3039            _as_of: lunaris_core::Hlc,
3040        ) -> Result<Option<Row<Bytes>>, StorageError> {
3041            Ok(None)
3042        }
3043
3044        async fn vector_search(
3045            &self,
3046            _scope: &Scope,
3047            _index: &str,
3048            _query: &[f32],
3049            _k: usize,
3050            _filter: Option<&Filter>,
3051            _as_of: Option<lunaris_core::Hlc>,
3052            _rerank: bool,
3053        ) -> Result<Vec<VectorHit>, StorageError> {
3054            Ok(vec![])
3055        }
3056
3057        async fn graph_traverse(
3058            &self,
3059            _scope: &Scope,
3060            _q: &CypherQuery,
3061            _as_of: Option<lunaris_core::Hlc>,
3062        ) -> Result<GraphResult, StorageError> {
3063            Ok(GraphResult::default())
3064        }
3065
3066        async fn scan_range(
3067            &self,
3068            _scope: &Scope,
3069            _prefix: &[u8],
3070            _as_of: Option<lunaris_core::Hlc>,
3071        ) -> Result<BoxStream<'_, Result<(Bytes, Bytes), StorageError>>, StorageError> {
3072            Ok(Box::pin(stream::iter(Vec::<Result<(Bytes, Bytes), StorageError>>::new())))
3073        }
3074
3075        async fn publish(
3076            &self,
3077            _scope: &Scope,
3078            _topic: &str,
3079            _partition: u16,
3080            _payload: Bytes,
3081        ) -> Result<u64, StorageError> {
3082            Ok(0)
3083        }
3084
3085        async fn subscribe(
3086            &self,
3087            _scope: &Scope,
3088            _group: &str,
3089            _topic: &str,
3090            _partition: u16,
3091        ) -> Result<BoxStream<'static, Result<QueueMsg, StorageError>>, StorageError> {
3092            Ok(Box::pin(stream::empty()))
3093        }
3094
3095        fn capabilities(&self) -> StorageCapabilities {
3096            StorageCapabilities {
3097                bi_temporal_native: false,
3098                graph_native: false,
3099                rerank_native: false,
3100                queue_native: false,
3101                max_vector_dim: 768,
3102                native_rrf: false,
3103                max_scopes_recommended: 0,
3104                cypher_dialect: CypherDialect::Legacy,
3105                graph_decay_native: false,
3106                graph_navigate_native: false,
3107            }
3108        }
3109    }
3110
3111    #[async_trait]
3112    impl KeywordPort for NullStorage {
3113        async fn keyword_search(
3114            &self,
3115            _scope: &Scope,
3116            _index: &str,
3117            _query: &str,
3118            _k: usize,
3119            _filter: Option<&Filter>,
3120            _as_of: Option<lunaris_core::Hlc>,
3121        ) -> Result<Vec<KeywordHit>, StorageError> {
3122            Ok(vec![])
3123        }
3124    }
3125
3126    fn make_handle() -> Lunaris {
3127        // HlcClock::new already returns Arc<HlcClock> — no extra Arc::new wrap.
3128        let storage: Arc<dyn StoragePort> = Arc::new(NullStorage);
3129        let keyword: Arc<dyn KeywordPort> = Arc::new(NullStorage);
3130        let embedder = Arc::new(StubEmbedder::new(4));
3131        let clock = HlcClock::new(0);
3132        Lunaris::with_parts_keyword(storage, keyword, embedder, clock)
3133    }
3134
3135    // ── test 1: default noop supervisor → empty output ────────────────────────
3136
3137    #[tokio::test]
3138    async fn end_turn_noop_returns_empty_output() {
3139        let handle = make_handle();
3140        // Default is NoopReflectSupervisor — applies() = false.
3141        assert!(!handle.reflect_supervisor().applies());
3142
3143        let input = ReflectInput {
3144            turn_id: Some(Ulid::new()),
3145            turn_summary: "agent answered a question".into(),
3146            recent_fact_ids: vec![Ulid::new()],
3147            recent_chunk_ids: vec![Ulid::new()],
3148        };
3149        let out = handle.end_turn(input).await.unwrap();
3150        assert_eq!(out, ReflectOutput::default());
3151        assert!(out.invalidate.is_empty());
3152        assert!(out.boost.is_empty());
3153        assert!(out.pre_warm_query.is_none());
3154    }
3155
3156    // ── test 2: custom stub supervisor → output + input fields thread through ─
3157
3158    /// Captures the input it received so the test can assert field propagation.
3159    struct CapturingReflectSupervisor {
3160        output: ReflectOutput,
3161        captured: parking_lot::Mutex<Option<ReflectInput>>,
3162    }
3163
3164    #[async_trait]
3165    impl ReflectSupervisor for CapturingReflectSupervisor {
3166        async fn reflect(&self, input: ReflectInput) -> Result<ReflectOutput, LunarisError> {
3167            *self.captured.lock() = Some(input);
3168            Ok(self.output.clone())
3169        }
3170        fn applies(&self) -> bool {
3171            true
3172        }
3173    }
3174
3175    #[tokio::test]
3176    async fn end_turn_stub_supervisor_propagates_output_and_input() {
3177        let fact_id = Ulid::new();
3178        let chunk_id = Ulid::new();
3179        let turn_id = Ulid::new();
3180        let expected_output = ReflectOutput {
3181            invalidate: vec![fact_id],
3182            boost: vec![chunk_id],
3183            pre_warm_query: Some("what is Alice's role?".into()),
3184        };
3185        let supervisor = Arc::new(CapturingReflectSupervisor {
3186            output: expected_output.clone(),
3187            captured: parking_lot::Mutex::new(None),
3188        });
3189
3190        let handle = make_handle().with_reflect_supervisor(supervisor.clone());
3191        assert!(handle.reflect_supervisor().applies());
3192
3193        let input = ReflectInput {
3194            turn_id: Some(turn_id),
3195            turn_summary: "turn summary text".into(),
3196            recent_fact_ids: vec![fact_id],
3197            recent_chunk_ids: vec![chunk_id],
3198        };
3199        let out = handle.end_turn(input).await.unwrap();
3200        assert_eq!(out, expected_output);
3201
3202        // Confirm the supervisor received the exact input we passed.
3203        let captured = supervisor.captured.lock().take().unwrap();
3204        assert_eq!(captured.turn_id, Some(turn_id));
3205        assert_eq!(captured.recent_fact_ids, vec![fact_id]);
3206        assert_eq!(captured.recent_chunk_ids, vec![chunk_id]);
3207        assert_eq!(captured.turn_summary, "turn summary text");
3208    }
3209
3210    // ── test 3: supervisor returns Err → end_turn propagates error ────────────
3211
3212    struct ErrReflectSupervisor;
3213
3214    #[async_trait]
3215    impl ReflectSupervisor for ErrReflectSupervisor {
3216        async fn reflect(&self, _input: ReflectInput) -> Result<ReflectOutput, LunarisError> {
3217            Err(LunarisError::Storage(StorageError::NotSupported("reflect budget exhausted")))
3218        }
3219    }
3220
3221    #[tokio::test]
3222    async fn end_turn_propagates_supervisor_error() {
3223        let handle = make_handle().with_reflect_supervisor(Arc::new(ErrReflectSupervisor));
3224        let result = handle.end_turn(ReflectInput::default()).await;
3225        assert!(result.is_err(), "end_turn must propagate supervisor error");
3226        let msg = format!("{}", result.unwrap_err());
3227        assert!(msg.contains("reflect budget exhausted"), "error message: {msg}");
3228    }
3229}
3230
3231#[cfg(test)]
3232mod embedder_backend_visibility_tests {
3233    //! W0.7 successor — an SDK caller must be able to SEE a degraded embedder.
3234    //!
3235    //! The `Noop` fallback is silent by construction: every vector is zeros,
3236    //! so hybrid recall collapses to BM25 plus insertion-order tie-breaks
3237    //! while `recall` keeps answering with a plausible hit list, and
3238    //! `NoopEmbedder::dim()` reports a real dimension on purpose so the index
3239    //! geometry stays valid. Nothing about the results reveals it. Until
3240    //! `embedder_backend()` existed, `resolved_embedder_backend()` was
3241    //! Rust-only and `grep -rn degraded crates/lunaris-py/src
3242    //! crates/lunaris-ts/src` returned nothing at all.
3243
3244    use super::{EmbedderBackend, resolved_embedder_backend};
3245
3246    /// The strings are API — both SDKs compare against them, so a rename is a
3247    /// breaking change for every caller doing `if backend == "noop"`.
3248    #[test]
3249    fn every_backend_has_a_stable_lowercase_tag() {
3250        let all = [
3251            (EmbedderBackend::LlamaCpp, "llamacpp"),
3252            (EmbedderBackend::OpenAiRemote, "openai-remote"),
3253            (EmbedderBackend::OllamaRemote, "ollama-remote"),
3254            (EmbedderBackend::Noop, "noop"),
3255            (EmbedderBackend::Unresolved, "unresolved"),
3256        ];
3257        for (backend, tag) in all {
3258            assert_eq!(
3259                backend.as_str(),
3260                tag,
3261                "{backend:?} tag changed — that is a breaking change"
3262            );
3263            assert_eq!(backend.to_string(), tag, "Display must agree with as_str for {backend:?}");
3264        }
3265    }
3266
3267    /// Distinctness is what makes the tag usable as a discriminator at all.
3268    #[test]
3269    fn tags_are_distinct() {
3270        let tags = [
3271            EmbedderBackend::LlamaCpp,
3272            EmbedderBackend::OpenAiRemote,
3273            EmbedderBackend::OllamaRemote,
3274            EmbedderBackend::Noop,
3275            EmbedderBackend::Unresolved,
3276        ]
3277        .map(EmbedderBackend::as_str);
3278        let unique: std::collections::BTreeSet<_> = tags.iter().collect();
3279        assert_eq!(unique.len(), tags.len(), "two backends share a tag: {tags:?}");
3280    }
3281
3282    /// `Noop` is the only backend that does not produce real vectors.
3283    ///
3284    /// `Unresolved` deliberately answers `true`: it means `open` has not run
3285    /// in this process (a `with_parts*` test seam), which is "unknown", not
3286    /// "degraded". Reporting a test-seam handle as degraded would train
3287    /// callers to ignore the signal.
3288    #[test]
3289    fn only_noop_is_reported_as_not_producing_real_vectors() {
3290        assert!(!EmbedderBackend::Noop.produces_real_vectors());
3291        for ok in [
3292            EmbedderBackend::LlamaCpp,
3293            EmbedderBackend::OpenAiRemote,
3294            EmbedderBackend::OllamaRemote,
3295            EmbedderBackend::Unresolved,
3296        ] {
3297            assert!(ok.produces_real_vectors(), "{ok:?} must not read as degraded");
3298        }
3299    }
3300
3301    /// The handle accessor must report the SAME thing the process-global
3302    /// resolver does. If it drifted, the SDKs would be reading a second
3303    /// opinion — and the one the engine actually uses would be the other one.
3304    #[test]
3305    fn the_handle_accessor_agrees_with_the_process_resolver() {
3306        // No `open` in this test binary, so this is `Unresolved` — the point
3307        // is the agreement, not the value.
3308        assert_eq!(resolved_embedder_backend().as_str(), resolved_embedder_backend().to_string());
3309    }
3310}