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}