Skip to main content

lunaris/primitives/
working_memory.rs

1//! Phase 9 Plan 09-03 PRIM-04 (structural half) — `WorkingMemory` primitive.
2//!
3//! Scope-prefixed scratchpad for agentic working memory. Every `write` /
4//! `read` / `grep` call scopes through a caller-supplied `scope_prefix` —
5//! either via prefix concatenation on the Episode `source` field (write) or
6//! via [`Filter::Eq`] / [`Filter::StartsWith`] at recall time (read / grep).
7//! No SQL LIKE strings; no global state; no duplicate vector / BM25 libraries
8//! (CLAUDE.md constraint — Moon native `FT.*` is canonical).
9//!
10//! ## Relocation note (Phase 12 Option A)
11//!
12//! This type moved from `lunaris-recipes::working_memory` into
13//! `lunaris::primitives::working_memory` so that Phase 12 `CodingSessionMemory`
14//! (which lives in the `lunaris` crate) can compose over it without
15//! introducing a `lunaris → lunaris-recipes` dependency cycle. The
16//! `lunaris-recipes` crate re-exports this type verbatim — every Phase 9 /
17//! 10 / 11 caller that imports `lunaris_recipes::WorkingMemory` keeps
18//! compiling unchanged. Phase 13's proper primitives-crate extraction
19//! subsumes this location.
20
21#![forbid(unsafe_code)]
22#![deny(rust_2018_idioms, unreachable_pub)]
23
24use std::sync::Arc;
25use std::time::Duration;
26
27use futures::StreamExt;
28use lunaris_consolidate::{
29    CONSOLIDATE_CONSUMER_GROUP, CONSOLIDATE_TOPIC, ConsolidateEvent, ConsolidationReport,
30};
31use lunaris_core::keyspace::{episode_key, source_index_key};
32use lunaris_core::storage::types::{Filter, Lsn, WriteOp};
33use lunaris_core::{Episode, HlcClock, LunarisError, Scope, StorageError, StoragePort};
34use lunaris_retrieve::{Hit, Keyword, Query, Vector};
35use ulid::Ulid;
36
37use crate::Lunaris;
38
39/// Phase 9.1 Plan 01 Task 3 — maximum events drained per
40/// [`WorkingMemory::consolidate`] call. Bounds T-09-1-01-04 DoS surface:
41/// heavy callers should invoke repeatedly rather than raising the cap.
42const DRAIN_CAP: usize = 1024;
43
44/// Phase 9.1 Plan 01 Task 3 — per-pull timeout on the drain stream. The
45/// drain exits on the first timeout, stream end, or error; combined with
46/// [`DRAIN_CAP`] this guarantees the drain terminates within
47/// `DRAIN_CAP × PULL_TIMEOUT_MS` ms (worst case ≈ 51 s when the broker
48/// keeps delivering events at exactly the timeout boundary).
49const PULL_TIMEOUT_MS: u64 = 50;
50
51/// Default `top_k` the `read` / `grep` recall paths use. Chosen to match
52/// Plan 09-01 MessageStream's `DEFAULT_TOP_K` + CodingSessionMemory's `READ_TOP`
53/// (both 8) so conversational wrappers that compose WorkingMemory with
54/// MessageStream / scratchpad primitives inherit the same breadth.
55const DEFAULT_TOP_K: usize = 8;
56
57/// Fan-out multiplier applied to each branch of the fused plan before RRF
58/// fuses them. Matches Plan 09-01 MessageStream + Plan 09-02 DocumentCorpus
59/// (`3`) so the three Phase 9 primitives share a consistent pre-fusion
60/// window size.
61const FANOUT: usize = 3;
62
63/// RRF constant from Cormack et al. (2009). Matches
64/// `DocumentCorpus::DEFAULT_RRF_K` (60). Shared across every Phase 9 primitive
65/// that fuses Vector + Keyword.
66const RRF_K: u32 = 60;
67
68/// Key-prefixed scratchpad. Stores `(k, v)` pairs under `{scope_prefix}{k}`
69/// as [`Episode`]s on the Episode `source` field.
70#[derive(Clone)]
71pub struct WorkingMemory {
72    lunaris: Arc<Lunaris>,
73    scope: Scope,
74    scope_prefix: String,
75}
76
77impl WorkingMemory {
78    /// Construct a new scratchpad bound to `scope` (RFC 0001 partition key)
79    /// and `scope_prefix` (source-key namespace). The two concepts are
80    /// orthogonal: `scope` partitions the KV / FT keyspace, while
81    /// `scope_prefix` namespaces the `source` field on each Episode so a
82    /// single scope can host multiple WorkingMemory instances (e.g.,
83    /// `"helios:fs/"` vs `"chat:user-42/"`).
84    pub fn new(lunaris: Arc<Lunaris>, scope: Scope, scope_prefix: impl Into<String>) -> Self {
85        Self { lunaris, scope, scope_prefix: scope_prefix.into() }
86    }
87
88    /// Write `(k, v)` under `{scope_prefix}{k}` as an [`Episode`].
89    pub async fn write(&self, k: &str, v: serde_json::Value) -> Result<Lsn, LunarisError> {
90        self.write_inner(k, v, None).await
91    }
92
93    /// [`Self::write`] with the payload's real-world reference time stamped
94    /// as [`Episode::t_ref`].
95    ///
96    /// `t_ref` is the date the CONTENT is from (a chat session's date, a
97    /// document's authored date) — distinct from the ingest-time HLC the
98    /// clock stamps on `bt`. Graph-ON ingest threads it into the extraction
99    /// prompt as `REFERENCE_TIME` so extracted `valid_from`/`valid_to`
100    /// dates are grounded in the content's timeline instead of the model's
101    /// "today" (Mechanism B, 2026-07-29 LME diagnosis).
102    pub async fn write_dated(
103        &self,
104        k: &str,
105        v: serde_json::Value,
106        t_ref: chrono::DateTime<chrono::Utc>,
107    ) -> Result<Lsn, LunarisError> {
108        self.write_inner(k, v, Some(t_ref)).await
109    }
110
111    async fn write_inner(
112        &self,
113        k: &str,
114        v: serde_json::Value,
115        t_ref: Option<chrono::DateTime<chrono::Utc>>,
116    ) -> Result<Lsn, LunarisError> {
117        let source = self.scope_key(k);
118        let content = serde_json::to_string(&v)
119            .map_err(|e| LunarisError::from(lunaris_core::StorageError::from(e)))?;
120        let mut episode = Episode::new(
121            self.scope.clone(),
122            source.clone(),
123            content,
124            self.lunaris.clock().as_ref(),
125        );
126        episode.t_ref = t_ref;
127        let episode_id = episode.id;
128        let lsn = self.lunaris.ingest(episode).await?;
129        self.record_source_index(&source, episode_id).await;
130        Ok(lsn)
131    }
132
133    /// Record `source -> episode_id` in the secondary index that makes
134    /// [`Self::read`] an exact-key read (F40).
135    ///
136    /// BEST-EFFORT, and deliberately so — it mirrors
137    /// `StoragePort::insert_dedupe_key`, the established precedent for a
138    /// sidecar written AFTER the pipeline's single `atomic_write`. Two
139    /// consequences worth stating rather than discovering:
140    ///
141    /// * **INGEST-04 is untouched.** This is a separate write by a caller of
142    ///   `ingest`, not a second `atomic_write` inside the ingest pipeline.
143    /// * **A failure here must not fail the write.** The value IS stored; only
144    ///   the fast path to it is missing, and [`Self::read`] falls back to the
145    ///   ranked search when the index has no entry. Turning a successful write
146    ///   into an error because an optimization sidecar failed would be a strict
147    ///   regression. It is logged at `warn` so the degradation is visible.
148    async fn record_source_index(&self, source: &str, episode_id: Ulid) {
149        let key = source_index_key(&self.scope, source);
150        let op = WriteOp::KvPut { key, value: episode_id.to_bytes().to_vec() };
151        if let Err(err) = self.lunaris.storage().atomic_write(&self.scope, &[op]).await {
152            tracing::warn!(
153                error = %err,
154                source = %source,
155                "working_memory_source_index_write_failed; read() falls back to the ranked path"
156            );
157        }
158    }
159
160    /// Read the value for `k` scoped under `scope_prefix`, if present.
161    ///
162    /// Recovers the VERBATIM value from the parent Episode `content`, NOT from
163    /// the lossy chunk `text` (the markdown chunker's smart-punctuation pass
164    /// rewrites quotes / dashes and corrupts JSON values — see
165    /// `Self::recover_value`).
166    pub async fn read(&self, k: &str) -> Result<Option<serde_json::Value>, LunarisError> {
167        self.read_at(k, None).await
168    }
169
170    /// [`Self::read`] pinned to `as_of`, or to the live snapshot when `None`.
171    ///
172    /// F42 — this is the ONE read implementation. `CodingSessionMemory` used to
173    /// carry a second one (a free `read_at` that concatenated `Hit::text`
174    /// across every hit), and it was wrong in three independent ways this path
175    /// is right in by construction:
176    ///
177    /// * **Content.** Chunk text is a LOSSY projection — the chunker parses
178    ///   with `pulldown_cmark::Options::all()` (`ENABLE_SMART_PUNCTUATION`) and
179    ///   rebuilds text from the event stream, so `--` becomes an en dash and
180    ///   ASCII quotes become typographic ones AT INGEST. Recovering from the
181    ///   parent Episode payload is the only way back to the written bytes.
182    /// * **Version.** Every write mints a NEW Episode under the same `source`.
183    ///   Concatenating every hit glued superseded bodies onto the answer, in
184    ///   proportion to how often the path had been edited. Resolving to ONE
185    ///   episode is what makes the read a read.
186    /// * **Query text.** The index path needs none, so a path whose NAME
187    ///   analyses to an empty FT query (`big`, `state`) is no longer
188    ///   write-OK / read-IMPOSSIBLE.
189    ///
190    /// On a backend with no KV version chain a historical `as_of` is REFUSED by
191    /// `read_as_of` rather than answered with present-time data (Moon 0.6.2
192    /// task 9). That is unchanged here and deliberately so: this path hits the
193    /// same guard the old one did, so the honest 501 survives the fix.
194    /// Crate-internal on purpose. `WorkingMemory`'s public surface is capped at
195    /// 7 symbols by the PRIM-04 contract, and this method's only callers —
196    /// [`Self::read`] and `AsOfScratchpad::read` — are both in this crate.
197    /// Spending a capped public symbol on it would need a reason beyond "it
198    /// would be a nice API"; if an external caller ever needs an as-of
199    /// scratchpad read, that is a contract change to make deliberately.
200    pub(crate) async fn read_at(
201        &self,
202        k: &str,
203        as_of: Option<lunaris_core::Hlc>,
204    ) -> Result<Option<serde_json::Value>, LunarisError> {
205        let source = self.scope_key(k);
206
207        // Exact-key path (F40). One KV get on the source index, then one on the
208        // episode. No embedding, no ranking, no top-k window — so this answers
209        // correctly on a build with no usable embedder, which the ranked path
210        // below cannot.
211        if let Some(id) = self.lookup_source_index(&source, as_of).await? {
212            // A hit here is authoritative for presence, but the episode row it
213            // names can still be gone (a `forget` tombstones the episode and
214            // does not sweep this sidecar). `recover_value` returning None then
215            // means "deleted", and falling through to the ranked path would not
216            // find it either — so return the None rather than re-searching.
217            return self.recover_value(&id, as_of).await;
218        }
219
220        // Fallback: entries written before this index existed have no sidecar,
221        // and a sidecar write can fail (it is best-effort by design). The
222        // ranked path is what those reads used and it still works wherever it
223        // worked before — this is strictly additive.
224        let filter =
225            Filter::Eq { field: "source".into(), value: serde_json::Value::String(source) };
226        // F42 — `.next()` took the TOP-RANKED hit, which is not the same as
227        // the CURRENT one: all versions of a path stay indexed under the same
228        // `source`, so ranking could hand back a superseded body. Episode ids
229        // are ULIDs and ULIDs sort by mint time, so the greatest id IS the
230        // newest version — no extra read to find it.
231        match self.find(k, filter).await?.into_iter().max_by(|a, b| a.episode_id.cmp(&b.episode_id))
232        {
233            Some(h) => self.recover_value(&h.episode_id, as_of).await,
234            None => Ok(None),
235        }
236    }
237
238    /// Resolve `source` to an episode id through the F40 secondary index.
239    ///
240    /// `Ok(None)` means the index has no entry — which is NOT the same as "the
241    /// key does not exist", because entries written before the index existed
242    /// have none. The caller falls back rather than concluding absence.
243    async fn lookup_source_index(
244        &self,
245        source: &str,
246        as_of: Option<lunaris_core::Hlc>,
247    ) -> Result<Option<Vec<u8>>, LunarisError> {
248        let key = source_index_key(&self.scope, source);
249        // The sidecar is OVERWRITTEN per write, so reading IT as-of is what
250        // names the episode visible at `as_of` — on a backend that versions KV.
251        // On one that does not, this read is refused, which is the honest
252        // answer and the same one the caller got before F42.
253        let snapshot = as_of.unwrap_or_else(|| HlcClock::new(0).tick());
254        match self.lunaris.storage().read_as_of(&self.scope, &key, snapshot).await {
255            Ok(Some(row)) if row.value.len() == 16 => Ok(Some(row.value.to_vec())),
256            // A row of the wrong width is corruption, not absence. Fall back to
257            // the ranked path rather than hand `recover_value` bytes it will
258            // silently reject with `Ok(None)` — which would read as "deleted".
259            Ok(Some(row)) => {
260                tracing::warn!(
261                    len = row.value.len(),
262                    source = %source,
263                    "working_memory_source_index_bad_width; falling back to the ranked path"
264                );
265                Ok(None)
266            }
267            Ok(None) => Ok(None),
268            // A fresh scope has no index yet: the KV namespace does not exist,
269            // which is "not found", never an error (same contract as `find`'s
270            // missing-index arm below). Classified on the `StorageError` itself
271            // — `is_ft_index_missing` takes a `LunarisError` and `StorageError`
272            // is not `Clone`, so wrapping it to ask would move it out of the
273            // arm that still needs to return it.
274            Err(err) if lunaris_retrieve::missing_index::is_index_absent(&err) => Ok(None),
275            Err(err) => Err(LunarisError::from(err)),
276        }
277    }
278
279    /// Return all `(source, value)` pairs whose `source` starts with
280    /// `{scope_prefix}{pattern}`. Values are recovered verbatim from the
281    /// parent Episode `content` (see `Self::recover_value`).
282    pub async fn grep(
283        &self,
284        pattern: &str,
285    ) -> Result<Vec<(String, serde_json::Value)>, LunarisError> {
286        let filter = Filter::StartsWith { field: "source".into(), prefix: self.scope_key(pattern) };
287        let hits = self.find(pattern, filter).await?;
288        // A value large enough to chunk-split yields multiple hits sharing one
289        // parent `episode_id` (and `source`). Recover each DISTINCT episode
290        // exactly once, in rank order, so grep returns one entry per key rather
291        // than one per chunk — and never re-reads the same Episode KV row.
292        // Without this, a single large value crowds the whole `top_k` window
293        // with identical entries and can starve out distinct sibling keys.
294        let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
295        let mut out = Vec::with_capacity(hits.len());
296        for h in hits {
297            if !seen.insert(h.episode_id.clone()) {
298                continue;
299            }
300            if let Some(v) = self.recover_value(&h.episode_id, None).await? {
301                out.push((h.source, v));
302            }
303        }
304        Ok(out)
305    }
306
307    /// Locate scratchpad hits via the fused Vector+Keyword(BM25) plan, falling
308    /// back to vector-only when the backend's `keyword_search` is
309    /// `NotSupported` (the embedded / sqlite backend — the `lunaris-mcp`
310    /// default — has no FTS5 BM25 yet). This is the SINGLE find path: callers
311    /// MUST NOT re-implement a second fallback (the `Filter` is enforced at the
312    /// SQL boundary on the vector branch, so exact-key / prefix scoping holds).
313    async fn find(&self, query: &str, filter: Filter) -> Result<Vec<Hit>, LunarisError> {
314        let fused = Vector::new("chunks", DEFAULT_TOP_K * FANOUT)
315            .and(Keyword::bm25("chunks", DEFAULT_TOP_K * FANOUT))
316            .fuse_rrf(RRF_K)
317            .top(DEFAULT_TOP_K);
318        // Thread the bound scope (RFC 0001) — `Lunaris::recall()` alone seeds
319        // `Scope::dev()`, which would read a different partition than `write`
320        // ingested into. `with_scope` is the canonical scoped-recall entry.
321        let hits = match self
322            .lunaris
323            .recall()
324            .with_scope(self.scope.clone())
325            .with_root(fused)
326            .filter(filter.clone())
327            .execute(Query::text(query))
328            .await
329        {
330            Ok(hits) => hits,
331            // Fresh scope: nothing was ever ingested, so the scope's FT index
332            // doesn't exist yet. An exact-key read on it means "not found",
333            // never an error (ADD task moon-parity-honesty).
334            Err(err) if is_ft_index_missing(&err) => return Ok(Vec::new()),
335            // Keyword leg unusable — either the backend has no keyword_search
336            // (embedded/sqlite) or Moon's FT analyzer reduced the KEY text to
337            // an empty query (stopword-like keys such as "state" were
338            // write-OK/read-IMPOSSIBLE before this arm). Retry vector-only:
339            // the Filter::Eq/StartsWith on `source` carries exactness, the
340            // query text is only a ranking signal here.
341            Err(err) if is_keyword_not_supported(&err) || is_ft_query_unusable(&err) => {
342                match self
343                    .lunaris
344                    .recall()
345                    .with_scope(self.scope.clone())
346                    .with_root(Vector::new("chunks", DEFAULT_TOP_K * FANOUT))
347                    .filter(filter.clone())
348                    .execute(Query::text(query))
349                    .await
350                {
351                    Ok(hits) => hits,
352                    Err(err) if is_ft_index_missing(&err) => return Ok(Vec::new()),
353                    Err(err) => return Err(err),
354                }
355            }
356            Err(err) => return Err(err),
357        };
358        // Post-enforce the source filter: on Moon's native HYBRID path the
359        // pushed-down filter constrains only the BM25 branch — the dense KNN
360        // branch ignores it, so non-matching sources can leak through RRF
361        // fusion (the `memory.recall` tool self-protects the same way for its
362        // `source_prefix`). Defense-in-depth: keep the push-down for ranking
363        // quality, enforce correctness here on the hydrated `source`.
364        Ok(hits.into_iter().filter(|h| source_filter_matches(&filter, &h.source)).collect())
365    }
366
367    /// Recover the verbatim stored value from a hit's parent Episode `content`.
368    ///
369    /// `WorkingMemory::write` stores the value as `serde_json::to_string(&v)`
370    /// on the Episode `content` field. The chunk `text` carried on a [`Hit`] is
371    /// a smart-punctuation-rewritten projection of that content (the markdown
372    /// chunker runs `pulldown_cmark` with `ENABLE_SMART_PUNCTUATION`), so
373    /// deserialising the value from `Hit::text` corrupts any JSON object /
374    /// array. We read the Episode KV row directly and parse its `content`,
375    /// which is never chunked — lossless on every backend.
376    ///
377    /// `episode_id` is the 16-byte parent-episode ULID carried on each hydrated
378    /// hit. An empty / malformed id (hit produced outside the main hydration
379    /// path) yields `None` rather than an error.
380    async fn recover_value(
381        &self,
382        episode_id: &[u8],
383        as_of: Option<lunaris_core::Hlc>,
384    ) -> Result<Option<serde_json::Value>, LunarisError> {
385        let bytes: [u8; 16] = match episode_id.try_into() {
386            Ok(b) => b,
387            Err(_) => return Ok(None),
388        };
389        let key = episode_key(&self.scope, Ulid::from_bytes(bytes));
390        // Live snapshot — mirrors `lunaris_retrieve::hydrate`'s `as_of = None`
391        // idiom (read the latest visible version without perturbing the engine
392        // clock).
393        let snapshot = as_of.unwrap_or_else(|| HlcClock::new(0).tick());
394        match self.lunaris.storage().read_as_of(&self.scope, &key, snapshot).await? {
395            Some(row) => {
396                let episode: Episode = serde_json::from_slice(&row.value)
397                    .map_err(|e| LunarisError::from(StorageError::from(e)))?;
398                let value = serde_json::from_str(&episode.content)
399                    .map_err(|e| LunarisError::from(StorageError::from(e)))?;
400                Ok(Some(value))
401            }
402            None => Ok(None),
403        }
404    }
405
406    /// Phase 9.1 Plan 01 Task 3 — run one consolidation pass scoped to
407    /// `self.scope_prefix`.
408    ///
409    /// ## Foreign-event preservation
410    ///
411    /// `drain_consolidate_events` subscribes to [`CONSOLIDATE_TOPIC`] and
412    /// consumes ALL pending events for the scope in one pass — it is not
413    /// prefix-aware.  Without an explicit re-queue step, calling
414    /// `consolidate_scoped(Some(prefix))` on the drained batch silently drops
415    /// the non-matching ("foreign") events: they are consumed from the
416    /// consumer-group queue and never seen by a subsequent pass (ADD task
417    /// `consolidate-prefix-drop`).
418    ///
419    /// Fix: after draining, partition events by
420    /// `source.starts_with(scope_prefix)`.  Matching events are forwarded to
421    /// `consolidate_scoped` (which therefore receives an already-filtered
422    /// batch and must NOT double-filter — hence `None` prefix in the call).
423    /// Foreign events are re-published verbatim to [`CONSOLIDATE_TOPIC`] so
424    /// that the next `consolidate_unfiltered` (or the background worker) can
425    /// pick them up.  A publish error on re-queue is loud-not-fatal: the call
426    /// still returns `Ok` with the matching report (matching consolidation
427    /// already happened and its result must not be discarded).
428    pub async fn consolidate(&self) -> Result<ConsolidationReport, LunarisError> {
429        let storage: Arc<dyn StoragePort> = self.lunaris.storage();
430
431        let events = drain_consolidate_events(&storage, &self.scope).await?;
432
433        // Partition into events belonging to this namespace vs. all others.
434        // `scope_prefix` is captured by value (String → &str borrow is safe
435        // because self outlives this function).
436        let scope_prefix: &str = &self.scope_prefix;
437        let mut matching: Vec<ConsolidateEvent> = Vec::with_capacity(events.len());
438        let mut foreign: Vec<ConsolidateEvent> = Vec::new();
439        for ev in events {
440            if ev.source.starts_with(scope_prefix) {
441                matching.push(ev);
442            } else {
443                foreign.push(ev);
444            }
445        }
446
447        // Re-publish foreign events verbatim so they remain available for
448        // a subsequent `consolidate_unfiltered` pass or the background worker.
449        // Errors are surfaced via tracing but never propagate — the matching
450        // consolidation work must not be rolled back because of a re-queue
451        // failure.
452        let mut lost: usize = 0;
453        for ev in &foreign {
454            match serde_json::to_vec(ev) {
455                Ok(payload) => {
456                    if let Err(e) =
457                        storage.publish(&self.scope, CONSOLIDATE_TOPIC, 0, payload.into()).await
458                    {
459                        tracing::warn!(
460                            source = %ev.source,
461                            error = %e,
462                            "consolidate: failed to re-queue foreign event; \
463                             it will be lost for this scope's pass"
464                        );
465                        lost += 1;
466                    }
467                }
468                Err(e) => {
469                    // Serialisation of ConsolidateEvent is infallible in
470                    // practice (all fields are JSON-safe primitives); log and
471                    // count as lost rather than panic.
472                    tracing::warn!(
473                        source = %ev.source,
474                        error = %e,
475                        "consolidate: serde failure serialising foreign event for re-queue"
476                    );
477                    lost += 1;
478                }
479            }
480        }
481        if lost > 0 {
482            tracing::warn!(
483                lost,
484                scope_prefix,
485                "consolidate: {} foreign event(s) could not be re-queued and will be lost",
486                lost
487            );
488        }
489
490        let pipeline = self.lunaris.consolidator_pipeline();
491        let consolidator = match pipeline.snapshot_consolidator() {
492            Some(c) => c,
493            None => {
494                return Ok(ConsolidationReport::default());
495            }
496        };
497
498        // The matching batch is already filtered — pass None so consolidate_scoped
499        // does NOT double-filter (every event in `matching` already satisfies
500        // `starts_with(scope_prefix)`).
501        let report = consolidator.consolidate_scoped(storage.clone(), &matching, None).await?;
502
503        // T1b fix (260609-dvi): emit BOTH promotion AND archive audit events.
504        // Replaces the promotion-only loop with publish_per_event_audits, which
505        // matches the background worker's emit behavior (D-22 verbatim).
506        lunaris_consolidate::publish_per_event_audits(&storage, &self.scope, &report).await;
507
508        Ok(report)
509    }
510
511    /// Whole-scope variant of [`Self::consolidate`]: drains and consolidates
512    /// ALL pending events for the scope, ignoring `self.scope_prefix`.
513    ///
514    /// The drain is scope-wide either way; `consolidate_scoped(Some(prefix))`
515    /// then FILTERS the drained events and the non-matching ones are already
516    /// consumed from the queue — dropped, not re-queued. Callers that cannot
517    /// tolerate that loss (e.g. the MCP session-handover, which runs
518    /// implicitly and must not eat other namespaces' pending events) use this
519    /// variant; it consolidates exactly what the background worker would.
520    pub async fn consolidate_unfiltered(&self) -> Result<ConsolidationReport, LunarisError> {
521        let storage: Arc<dyn StoragePort> = self.lunaris.storage();
522
523        let events = drain_consolidate_events(&storage, &self.scope).await?;
524
525        let pipeline = self.lunaris.consolidator_pipeline();
526        let consolidator = match pipeline.snapshot_consolidator() {
527            Some(c) => c,
528            None => {
529                return Ok(ConsolidationReport::default());
530            }
531        };
532
533        let report = consolidator.consolidate_scoped(storage.clone(), &events, None).await?;
534
535        lunaris_consolidate::publish_per_event_audits(&storage, &self.scope, &report).await;
536
537        Ok(report)
538    }
539
540    fn scope_key(&self, k: &str) -> String {
541        format!("{}{}", self.scope_prefix, k)
542    }
543}
544
545/// `true` when `err` is the embedded/sqlite backend reporting that
546/// `keyword_search` (FTS5 BM25) is `NotSupported`. Drives [`WorkingMemory::find`]'s
547/// vector-only fallback. Mirrors `lunaris_mcp::tools::staging::is_keyword_not_supported`
548/// — duplicated (not shared) because `lunaris` cannot depend on `lunaris-mcp`.
549fn is_keyword_not_supported(err: &LunarisError) -> bool {
550    matches!(
551        err,
552        LunarisError::Storage(StorageError::NotSupported(msg))
553            if msg.contains("keyword_search") || msg.contains("keyword")
554    )
555}
556
557/// `true` when Moon's FT analyzer reduced the query text to nothing —
558/// `ERR empty query after analysis`. For an exact-key scratchpad read the
559/// query is the KEY string, so stopword-like keys ("state") hit this on
560/// every read; the Filter on `source` still identifies the row exactly, so
561/// the caller retries vector-only (ADD task moon-parity-honesty).
562///
563/// String-matched on `StorageError::Backend` by necessity — Moon surfaces FT
564/// errors as opaque RESP error text. Pinned by the live test
565/// `scratchpad_stopword_key_reads_back_moon` so wording drift is caught.
566fn is_ft_query_unusable(err: &LunarisError) -> bool {
567    matches!(
568        err,
569        LunarisError::Storage(StorageError::Backend(msg))
570            if msg.contains("empty query after analysis")
571    )
572}
573
574/// `true` when the scope's FT index does not exist yet — a brand-new scope
575/// with zero ingested rows. Reads resolve to "not found", never an error.
576/// Pinned by `scratchpad_read_fresh_scope_returns_none_moon`.
577///
578/// F1: the predicate now comes from `lunaris_retrieve::missing_index`, which
579/// is also what every recall leg uses. This copy matched two of Moon's three
580/// spellings for the same condition, and `operators::tree` matched only the
581/// third — four call sites, three predicates, one rule. One shared predicate
582/// means a newly-observed spelling is fixed everywhere at once.
583fn is_ft_index_missing(err: &LunarisError) -> bool {
584    matches!(
585        err,
586        LunarisError::Storage(e) if lunaris_retrieve::missing_index::is_index_absent(e)
587    )
588}
589
590/// `true` when a hit's hydrated `source` satisfies the `source` predicate of
591/// `filter`. Non-`source` predicates (and unknown variants) pass — they were
592/// already enforced by the backend push-down; this guard exists because Moon's
593/// native HYBRID path applies the pushed-down filter to the BM25 branch only,
594/// letting dense-KNN hits with foreign sources leak through RRF fusion.
595fn source_filter_matches(filter: &Filter, source: &str) -> bool {
596    match filter {
597        Filter::Eq { field, value } if field == "source" => value.as_str() == Some(source),
598        Filter::StartsWith { field, prefix } if field == "source" => source.starts_with(prefix),
599        Filter::And(xs) => xs.iter().all(|f| source_filter_matches(f, source)),
600        Filter::Or(xs) => xs.iter().any(|f| source_filter_matches(f, source)),
601        _ => true,
602    }
603}
604
605/// Phase 9.1 Plan 01 Task 3 — drain up to [`DRAIN_CAP`] recent
606/// [`ConsolidateEvent`]s from [`CONSOLIDATE_TOPIC`].
607///
608/// T1a fix (260609-dvi): subscribes under the caller's real `scope` rather than
609/// `Scope::dev()`. Events published under the server scope are now correctly consumed.
610async fn drain_consolidate_events(
611    storage: &Arc<dyn StoragePort>,
612    scope: &Scope,
613) -> Result<Vec<ConsolidateEvent>, LunarisError> {
614    let pull_timeout = Duration::from_millis(PULL_TIMEOUT_MS);
615
616    let mut stream = storage
617        .subscribe(scope, CONSOLIDATE_CONSUMER_GROUP, CONSOLIDATE_TOPIC, 0)
618        .await
619        .map_err(LunarisError::Storage)?;
620
621    let mut events = Vec::with_capacity(64);
622    while events.len() < DRAIN_CAP {
623        match tokio::time::timeout(pull_timeout, stream.next()).await {
624            Ok(Some(Ok(msg))) => {
625                if let Ok(ev) = serde_json::from_slice::<ConsolidateEvent>(&msg.payload) {
626                    events.push(ev);
627                }
628            }
629            Ok(Some(Err(_))) | Ok(None) | Err(_) => break,
630        }
631    }
632    Ok(events)
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    /// PRIM-04 ≤ 30 LOC public-surface contract.
640    #[test]
641    fn working_memory_public_surface_under_30_loc() {
642        let src = include_str!("./working_memory.rs");
643        let production = src.split("#[cfg(test)]").next().unwrap_or(src);
644        let pub_fns = production.matches("    pub fn ").count()
645            + production.matches("    pub async fn ").count();
646        assert!(
647            pub_fns <= 7,
648            "PRIM-04 ≤30-LOC contract: WorkingMemory has {pub_fns} pub fns; cap is 7 \
649             (write_dated added for Mechanism-B session-date grounding, 2026-07-29)"
650        );
651        assert!(
652            pub_fns >= 3,
653            "PRIM-04 contract: WorkingMemory needs at least 3 public methods; got {pub_fns}"
654        );
655    }
656
657    #[test]
658    fn working_memory_scope_key_prefix_concatenation() {
659        fn scope_key(prefix: &str, k: &str) -> String {
660            format!("{prefix}{k}")
661        }
662        assert_eq!(scope_key("helios:fs/", "note-1"), "helios:fs/note-1");
663        assert_eq!(scope_key("chat:user-42/", "draft"), "chat:user-42/draft");
664        assert_eq!(scope_key("", "raw-key"), "raw-key");
665    }
666
667    #[test]
668    fn working_memory_grep_uses_starts_with_filter() {
669        let prefix = "chat:user-42/draft-";
670        let filter = Filter::StartsWith { field: "source".into(), prefix: prefix.into() };
671        match filter {
672            Filter::StartsWith { field, prefix: p } => {
673                assert_eq!(field, "source");
674                assert_eq!(p, "chat:user-42/draft-");
675            }
676            other => panic!("expected StartsWith variant; got {other:?}"),
677        }
678    }
679
680    /// Moon HYBRID filter-bypass guard: dense-KNN hits whose source does not
681    /// satisfy the pushed-down `source` filter must be rejected post-recall.
682    #[test]
683    fn source_filter_rejects_foreign_sources() {
684        let eq = Filter::Eq {
685            field: "source".into(),
686            value: serde_json::Value::String("scratchpad/sess-b/plan".into()),
687        };
688        assert!(source_filter_matches(&eq, "scratchpad/sess-b/plan"));
689        assert!(!source_filter_matches(&eq, "scratchpad/sess-a/plan"), "Eq must reject leaks");
690        assert!(!source_filter_matches(&eq, "scratchpad/sess-a/blocker"));
691
692        let sw = Filter::StartsWith { field: "source".into(), prefix: "scratchpad/sess-b/".into() };
693        assert!(source_filter_matches(&sw, "scratchpad/sess-b/anything"));
694        assert!(!source_filter_matches(&sw, "scratchpad/sess-a/plan"), "prefix must reject leaks");
695
696        // Non-source predicates pass through (already enforced by the backend).
697        let other =
698            Filter::Eq { field: "kind".into(), value: serde_json::Value::String("x".into()) };
699        assert!(source_filter_matches(&other, "scratchpad/sess-a/plan"));
700    }
701
702    #[test]
703    fn working_memory_construction_records_scope() {
704        let s = format!("{}{}", "helios:fs/", "k");
705        assert!(s.starts_with("helios:fs/"));
706        assert!(s.ends_with("k"));
707        assert_eq!(s, "helios:fs/k");
708    }
709}