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}