Skip to main content

wm_tools/expansion/
memory_ops.rs

1//! Memory operation tools — consolidate, decay, batch_read, update, tag, stats, hybrid_recall.
2
3#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::collections::HashMap;
9use std::fmt::Write;
10use std::sync::Arc;
11use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
12use wm_memory::{
13    AssociationStore, MemoryStore, RecallEngine, SearchEngine, episodic::detect_conflicts,
14};
15
16use super::common::{
17    bool_prop, galaxy_name, galaxy_search_arg, int_prop, num_prop, parse_galaxy, parse_galaxy_or,
18    schema, str_prop,
19};
20
21/// Recall-mode label for the fused search route.
22///
23/// The fusion path runs whenever a real embedder is wired, but with the
24/// vector weight configured to zero it ranks by BM25 alone. The disclosure
25/// must follow the configured weights, not the code path taken (T0 finding
26/// F-T0-2: `bm25-baseline` disclosed `hybrid` while its ranking was BM25).
27fn fused_mode_label(vector_weight: f32) -> &'static str {
28    if vector_weight > 0.0 {
29        "hybrid"
30    } else {
31        "bm25"
32    }
33}
34
35/// Attach the navigation disclosure to a scrubbed excerpt result. The source
36/// stays exact and `memory.read` remains the complete-read path.
37fn with_navigation_disclosure(mut result: serde_json::Value, original: &str) -> serde_json::Value {
38    let limit = wm_memory::search::MAX_INDEX_CONTENT_LEN;
39    let scrubbed = result
40        .get("content")
41        .and_then(serde_json::Value::as_str)
42        .is_none_or(|navigation| navigation != original);
43    if let Some(obj) = result.as_object_mut() {
44        obj.insert(
45            "content_representation".into(),
46            json!("scrubbed_navigation"),
47        );
48        obj.insert("content_character_limit".into(), json!(limit));
49        obj.insert(
50            "content_truncated".into(),
51            json!(original.chars().nth(limit).is_some()),
52        );
53        obj.insert("content_scrubbed".into(), json!(scrubbed));
54        obj.insert("exact_read_available".into(), json!(true));
55        // mcp-input-boundary (2026-09-21): content that reads as
56        // instructions is disclosed on surfacing, not blocked at the write.
57        // The model (and any UI) can treat it as untrusted data; exact read
58        // stays available for humans and audit.
59        if let Some(pattern) = wm_memory::detect_injection(original) {
60            obj.insert("instruction_shaped".into(), json!(true));
61            obj.insert("instruction_pattern".into(), json!(pattern));
62        }
63    }
64    result
65}
66
67/// Absolute-BM25 abstention floor (`WM_RECALL_ABSTENTION_FLOOR`); 0 = off.
68fn abstention_floor() -> f32 {
69    std::env::var("WM_RECALL_ABSTENTION_FLOOR")
70        .ok()
71        .and_then(|value| value.trim().parse::<f32>().ok())
72        .filter(|value| *value > 0.0)
73        .unwrap_or(0.0)
74}
75
76/// Minimum matched-term coverage (`WM_RECALL_ABSTENTION_COVERAGE`, 0..=1); 0 = off.
77fn abstention_coverage() -> f32 {
78    std::env::var("WM_RECALL_ABSTENTION_COVERAGE")
79        .ok()
80        .and_then(|value| value.trim().parse::<f32>().ok())
81        .filter(|value| (0.0..=1.0).contains(value) && *value > 0.0)
82        .unwrap_or(0.0)
83}
84
85/// Weak-evidence abstention over already-assembled result JSON.
86///
87/// Per-query normalization makes the top hit's `score` 1.0 no matter how weak
88/// the match (2026-09-22 hosted-lane finding). Each result now also carries
89/// `raw_score` (absolute BM25) or `matched_terms` (episodic coverage); these
90/// opt-in knobs turn that absolute signal into an explicit abstention object.
91/// Results are never dropped here — the caller can still inspect them.
92fn weak_evidence_abstention(
93    results: &[serde_json::Value],
94    query: &str,
95) -> Option<serde_json::Value> {
96    weak_evidence_abstention_with(results, query, abstention_floor(), abstention_coverage())
97}
98
99/// Parameterized core of [`weak_evidence_abstention`] (deterministic tests).
100fn weak_evidence_abstention_with(
101    results: &[serde_json::Value],
102    query: &str,
103    floor: f32,
104    coverage_floor: f32,
105) -> Option<serde_json::Value> {
106    if floor <= 0.0 && coverage_floor <= 0.0 {
107        return None;
108    }
109    let top = results.first()?;
110    let source = top
111        .get("source")
112        .and_then(serde_json::Value::as_str)
113        .unwrap_or("");
114    match source {
115        "hybrid" | "bm25" | "fts" => {
116            let raw = top
117                .get("raw_score")
118                .and_then(serde_json::Value::as_f64)
119                .unwrap_or(0.0);
120            // `raw == 0.0` marks vector-only evidence (cosine scale, not BM25):
121            // the BM25 floor does not apply — disclosed, not guessed.
122            if floor > 0.0 && raw > 0.0 && (raw as f32) < floor {
123                return Some(json!({
124                    "status": "insufficient_evidence",
125                    "reason": "top_below_floor",
126                    "scope": "retrieval",
127                    "signal": "bm25",
128                    "top_score": raw,
129                    "floor": floor,
130                }));
131            }
132        }
133        "episodic" => {
134            let matched = top
135                .get("matched_terms")
136                .and_then(serde_json::Value::as_u64)
137                .unwrap_or(0);
138            let query_terms = wm_memory::strip_stopwords(query).split_whitespace().count() as u64;
139            if coverage_floor > 0.0 && query_terms > 0 {
140                let coverage = matched as f64 / query_terms as f64;
141                if coverage < f64::from(coverage_floor) {
142                    return Some(json!({
143                        "status": "insufficient_evidence",
144                        "reason": "coverage_below_floor",
145                        "scope": "retrieval",
146                        "signal": "coverage",
147                        "coverage": coverage,
148                        "matched_terms": matched,
149                        "query_terms": query_terms,
150                        "floor": coverage_floor,
151                    }));
152                }
153            }
154        }
155        _ => {}
156    }
157    None
158}
159
160/// Compact per-result evidence bundle (v0): exact identity, retrieval reason,
161/// source time, integrity, visibility, and coverage. Cold-only records report
162/// their time as unavailable rather than guessing.
163fn build_evidence_bundle(store: &MemoryStore, results: &[serde_json::Value]) -> serde_json::Value {
164    let entries: Vec<serde_json::Value> = results
165        .iter()
166        .map(|r| {
167            let id = r
168                .get("id")
169                .and_then(serde_json::Value::as_str)
170                .unwrap_or_default()
171                .to_string();
172            let galaxy = r
173                .get("galaxy")
174                .and_then(serde_json::Value::as_str)
175                .unwrap_or_default()
176                .to_string();
177            let resolved = uuid::Uuid::parse_str(&id)
178                .ok()
179                .and_then(|key| resolve_memory_across_galaxies(store, key));
180            let (source_time, visibility, history) = match &resolved {
181                Some((_, mem)) => {
182                    let revisions = store
183                        .revisions(mem.metadata.galaxy, mem.metadata.id)
184                        .unwrap_or_default();
185                    let chain_valid =
186                        wm_memory::revision::verify_chain(&revisions, &mem.metadata.content_hash)
187                            .valid;
188                    (
189                        json!({
190                            "created_at": mem.metadata.created_at,
191                            "basis": "recorded_at",
192                            "event_time": serde_json::Value::Null,
193                            "event_time_basis": "not_tracked",
194                        }),
195                        json!({
196                            "private": mem.metadata.is_private,
197                            "model_exclude": mem.metadata.model_exclude,
198                        }),
199                        json!({
200                            "revision_count": revisions.len(),
201                            "superseded": !revisions.is_empty(),
202                            "chain_valid": chain_valid,
203                            "current": true,
204                        }),
205                    )
206                }
207                None => (
208                    json!({
209                        "created_at": serde_json::Value::Null,
210                        "basis": "unavailable_cold_record",
211                        "event_time": serde_json::Value::Null,
212                        "event_time_basis": "not_tracked",
213                    }),
214                    json!({
215                        "private": false,
216                        "model_exclude": !r
217                            .get("model_visible")
218                            .and_then(serde_json::Value::as_bool)
219                            .unwrap_or(false),
220                    }),
221                    json!({
222                        "revision_count": 0,
223                        "superseded": false,
224                        "chain_valid": serde_json::Value::Null,
225                        "current": true,
226                        "basis": "unavailable_cold_record",
227                    }),
228                ),
229            };
230            let mut retrieval = json!({
231                "route": r.get("source").cloned().unwrap_or(serde_json::Value::Null),
232                "score": r.get("score").cloned().unwrap_or(serde_json::Value::Null),
233            });
234            if let Some(terms) = r.get("matched_terms") {
235                retrieval["matched_terms"] = terms.clone();
236            }
237            if let Some(via) = r.get("via") {
238                retrieval["via"] = via.clone();
239            }
240            json!({
241                "id": id,
242                "galaxy": galaxy,
243                "retrieval": retrieval,
244                "source_time": source_time,
245                "history": history,
246                "integrity": r
247                    .get("integrity")
248                    .cloned()
249                    .unwrap_or_else(|| json!("source_read")),
250                "visibility": visibility,
251                "coverage": {
252                    "representation": r
253                        .get("content_representation")
254                        .cloned()
255                        .unwrap_or(serde_json::Value::Null),
256                    "truncated": r
257                        .get("content_truncated")
258                        .cloned()
259                        .unwrap_or(serde_json::Value::Null),
260                    "exact_read_available": r
261                        .get("exact_read_available")
262                        .cloned()
263                        .unwrap_or(json!(false)),
264                },
265            })
266        })
267        .collect();
268    let shims: Vec<wm_memory::episodic::EpisodicSearchResult> = results
269        .iter()
270        .filter_map(|r| {
271            let id = r.get("id").and_then(serde_json::Value::as_str)?;
272            let key = uuid::Uuid::parse_str(id).ok()?;
273            let record = store.episodic().get(key).ok().flatten()?;
274            Some(wm_memory::episodic::EpisodicSearchResult {
275                record,
276                score: 0.0,
277                matched_terms: 0,
278            })
279        })
280        .collect();
281    let conflicts = detect_conflicts(&shims);
282    let pairs: Vec<serde_json::Value> = conflicts
283        .iter()
284        .map(|c| {
285            json!({
286                "later": c.later_record.to_string(),
287                "earlier": c.earlier_record.to_string(),
288                "marker": c.marker,
289                "shared_terms": c.shared_terms,
290            })
291        })
292        .collect();
293    json!({
294        "version": "v0",
295        "count": entries.len(),
296        "entries": entries,
297        "conflicts": {"count": pairs.len(), "pairs": pairs},
298    })
299}
300
301/// Resolve a memory id across all memory galaxies. Associations may point at
302/// records in any galaxy; callers should not have to guess which one.
303fn resolve_memory_across_galaxies(
304    store: &MemoryStore,
305    id: uuid::Uuid,
306) -> Option<(wm_core::Galaxy, wm_memory::Memory)> {
307    for galaxy in wm_core::Galaxy::memory_galaxies() {
308        if let Ok(Some(mem)) = store.get(galaxy, id) {
309            return Some((galaxy, mem));
310        }
311    }
312    None
313}
314
315/// `memory.consolidate` — deduplicate memories by content_hash within a galaxy.
316pub struct MemoryConsolidateTool {
317    store: Arc<MemoryStore>,
318    search: Option<Arc<SearchEngine>>,
319    stats: ToolStats,
320    effects: EffectRow,
321}
322
323impl MemoryConsolidateTool {
324    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
325        Self {
326            store,
327            search,
328            stats: ToolStats::default(),
329            effects: EffectRow {
330                writes: super::common::memory_galaxy_writes(),
331                reads: super::common::memory_galaxy_reads(),
332                destructive: true,
333                ..Default::default()
334            },
335        }
336    }
337}
338
339#[async_trait]
340impl Tool for MemoryConsolidateTool {
341    fn input_schema(&self) -> Value {
342        schema(
343            &json!({
344                "galaxy": super::common::str_prop("Galaxy to consolidate (optional; default codex)"),
345            }),
346            &[],
347        )
348    }
349    fn name(&self) -> &str {
350        "memory.consolidate"
351    }
352    fn gana(&self) -> Gana {
353        Gana::Encampment
354    }
355    fn effects(&self) -> &EffectRow {
356        &self.effects
357    }
358    fn description(&self) -> &str {
359        "Deduplicate memories by content_hash within a galaxy"
360    }
361    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
362        let galaxy = args
363            .get("galaxy")
364            .and_then(|v| v.as_str())
365            .unwrap_or("codex");
366        let galaxy = parse_galaxy(galaxy)?;
367        // Full scan: research/sessions galaxies exceed the legacy 10k scan cap,
368        // which silently left the tail un-consolidated (B5 heritage dedupe).
369        let memories = self.store.scan_all(galaxy)?;
370        let mut seen_hashes: HashMap<String, uuid::Uuid> = HashMap::new();
371        let mut duplicates = 0u32;
372        for mem in &memories {
373            let hash = &mem.metadata.content_hash;
374            if let Some(existing_id) = seen_hashes.get(hash) {
375                if *existing_id != mem.metadata.id {
376                    self.store.delete(galaxy, mem.metadata.id)?;
377                    super::common::deindex(self.search.as_deref(), &mem.metadata.id.to_string());
378                    duplicates += 1;
379                }
380            } else {
381                seen_hashes.insert(hash.clone(), mem.metadata.id);
382            }
383        }
384        Ok(json!({
385            "status": "success",
386            "galaxy": galaxy_name(galaxy),
387            "scanned": memories.len(),
388            "duplicates_removed": duplicates,
389        }))
390    }
391    fn stats(&self) -> &ToolStats {
392        &self.stats
393    }
394}
395
396/// `memory.decay` — lower importance of old, low-access memories.
397pub struct MemoryDecayTool {
398    store: Arc<MemoryStore>,
399    stats: ToolStats,
400    effects: EffectRow,
401}
402
403impl MemoryDecayTool {
404    pub fn new(store: Arc<MemoryStore>) -> Self {
405        Self {
406            store,
407            stats: ToolStats::default(),
408            effects: EffectRow {
409                writes: super::common::memory_galaxy_writes(),
410                reads: super::common::memory_galaxy_reads(),
411                ..Default::default()
412            },
413        }
414    }
415}
416
417#[async_trait]
418impl Tool for MemoryDecayTool {
419    fn input_schema(&self) -> Value {
420        schema(
421            &json!({
422                "galaxy": super::common::str_prop("Galaxy to decay (optional; default codex)"),
423                "importance_threshold": super::common::num_prop("Decay memories below this importance (0-1)"),
424                "decay_factor": super::common::num_prop("Multiplier applied to importance (0-1)"),
425            }),
426            &[],
427        )
428    }
429    fn name(&self) -> &str {
430        "memory.decay"
431    }
432    fn gana(&self) -> Gana {
433        Gana::WinnowingBasket
434    }
435    fn effects(&self) -> &EffectRow {
436        &self.effects
437    }
438    fn description(&self) -> &str {
439        "Lower importance of old, low-access memories (never deletes)"
440    }
441    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
442        let galaxy = args
443            .get("galaxy")
444            .and_then(|v| v.as_str())
445            .unwrap_or("codex");
446        let galaxy = parse_galaxy(galaxy)?;
447        let threshold = args
448            .get("importance_threshold")
449            .and_then(serde_json::Value::as_f64)
450            .unwrap_or(0.3) as f32;
451        let decay_factor = args
452            .get("decay_factor")
453            .and_then(serde_json::Value::as_f64)
454            .unwrap_or(0.9) as f32;
455        let memories = self.store.scan(galaxy, 10_000)?;
456        let mut decayed = 0u32;
457        for mem in &memories {
458            if mem.metadata.importance < threshold {
459                let mut updated = mem.clone();
460                updated.metadata.importance =
461                    (updated.metadata.importance * decay_factor).clamp(0.0, 1.0);
462                if (updated.metadata.importance - mem.metadata.importance).abs() > 0.001 {
463                    self.store.put(galaxy, &updated)?;
464                    decayed += 1;
465                }
466            }
467        }
468        Ok(json!({
469            "status": "success",
470            "galaxy": galaxy_name(galaxy),
471            "scanned": memories.len(),
472            "decayed": decayed,
473        }))
474    }
475    fn stats(&self) -> &ToolStats {
476        &self.stats
477    }
478}
479
480/// `memory.batch_read` — read multiple memories by ID.
481pub struct MemoryBatchReadTool {
482    store: Arc<MemoryStore>,
483    stats: ToolStats,
484    effects: EffectRow,
485}
486
487impl MemoryBatchReadTool {
488    pub fn new(store: Arc<MemoryStore>) -> Self {
489        Self {
490            store,
491            stats: ToolStats::default(),
492            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
493        }
494    }
495}
496
497#[async_trait]
498impl Tool for MemoryBatchReadTool {
499    fn name(&self) -> &str {
500        "memory.batch_read"
501    }
502    fn gana(&self) -> Gana {
503        Gana::WinnowingBasket
504    }
505    fn effects(&self) -> &EffectRow {
506        &self.effects
507    }
508    fn description(&self) -> &str {
509        "Read multiple memories by ID from a galaxy"
510    }
511    fn input_schema(&self) -> Value {
512        super::common::schema(
513            &json!({
514                "ids": super::common::str_array_prop("Memory UUIDs to read"),
515                "galaxy": super::common::str_prop("Galaxy (default: codex)"),
516            }),
517            &["ids"],
518        )
519    }
520    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
521        let galaxy = args
522            .get("galaxy")
523            .and_then(|v| v.as_str())
524            .unwrap_or("codex");
525        let galaxy = parse_galaxy(galaxy)?;
526        let ids = args
527            .get("ids")
528            .and_then(|v| v.as_array())
529            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'ids' array".into()))?;
530        let mut results = Vec::new();
531        let mut misses = 0u32;
532        for id_val in ids {
533            if let Some(id_str) = id_val.as_str() {
534                if let Ok(id) = uuid::Uuid::parse_str(id_str) {
535                    match self.store.get(galaxy, id)? {
536                        Some(mem)
537                            if crate::expansion::common::mcp_visible(&mem)
538                                && crate::expansion::common::validity_visible(&mem) =>
539                        {
540                            results.push(json!({
541                                "id": mem.metadata.id,
542                                "content": mem.content,
543                                "tags": mem.metadata.tags,
544                                "importance": mem.metadata.importance,
545                            }));
546                        }
547                        // Private memories are treated like misses — they never
548                        // appear in MCP responses.
549                        Some(_) => {
550                            misses += 1;
551                        }
552                        None => {
553                            misses += 1;
554                        }
555                    }
556                }
557            }
558        }
559        Ok(json!({
560            "status": "success",
561            "galaxy": galaxy_name(galaxy),
562            "found": results.len(),
563            "misses": misses,
564            "memories": results,
565        }))
566    }
567    fn stats(&self) -> &ToolStats {
568        &self.stats
569    }
570}
571
572/// `memory.update` — update tags or importance of a memory.
573///
574/// If a `SearchEngine` is provided, the updated memory is re-indexed into
575/// Tantivy (delete old doc, add new doc, commit).
576pub struct MemoryUpdateTool {
577    store: Arc<MemoryStore>,
578    search: Option<Arc<SearchEngine>>,
579    stats: ToolStats,
580    effects: EffectRow,
581}
582
583impl MemoryUpdateTool {
584    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
585        Self {
586            store,
587            search,
588            stats: ToolStats::default(),
589            effects: EffectRow {
590                writes: super::common::memory_galaxy_writes(),
591                reads: super::common::memory_galaxy_reads(),
592                // Landlock v1 first batch (P-SANDBOX-3): store-root-only body.
593                sandbox: wm_core::Sandbox::StoreScoped,
594                ..Default::default()
595            },
596        }
597    }
598}
599
600#[async_trait]
601impl Tool for MemoryUpdateTool {
602    fn name(&self) -> &str {
603        "memory.update"
604    }
605    fn gana(&self) -> Gana {
606        Gana::Encampment
607    }
608    fn effects(&self) -> &EffectRow {
609        &self.effects
610    }
611    fn description(&self) -> &str {
612        "Update tags, importance, title/topic, or content of an existing memory"
613    }
614    fn input_schema(&self) -> Value {
615        super::common::schema(
616            &json!({
617                "id": super::common::str_prop("Memory UUID to update"),
618                "content": super::common::str_prop("New content (optional)"),
619                "tags": super::common::str_array_prop("Replacement tags (optional)"),
620                "importance": super::common::bounded_num_prop("New importance 0.0-1.0 (optional)", 0.0, 1.0),
621                "title": super::common::str_prop("New title (optional)"),
622                "topic": super::common::str_prop("New topic label (optional)"),
623                "galaxy": super::common::str_prop("Galaxy (default: codex)"),
624            }),
625            &["id"],
626        )
627    }
628    async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
629        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
630        let id_str = args
631            .get("id")
632            .and_then(|v| v.as_str())
633            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
634        let id = uuid::Uuid::parse_str(id_str)
635            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
636        if let Some(search) = &self.search {
637            if search.is_readonly() {
638                return Err(wm_core::CoreError::InvalidArgs(
639                    "read-only mode: memory.update disabled (another process owns the index)"
640                        .into(),
641                ));
642            }
643        }
644        let mut mem = self.store.get(galaxy, id)?.ok_or_else(|| {
645            wm_core::CoreError::NotFound(format!(
646                "Memory {id} not found in {}",
647                galaxy_name(galaxy)
648            ))
649        })?;
650        let previous_hash = mem.metadata.content_hash.clone();
651        let content_changed = args.get("content").and_then(|v| v.as_str()).is_some();
652        if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
653            mem.metadata.tags = tags
654                .iter()
655                .filter_map(|t| t.as_str().map(String::from))
656                .collect();
657        }
658        // Importance is applied verbatim: class ceilings/floors live in
659        // the pipeline write gate (V8 S5/S11d), the single seam every
660        // dispatch passes through. Direct tool calls bypass the gate by
661        // construction — same contract as the create path. Range validation
662        // still applies here: out-of-interval values are caller errors.
663        if args.get("importance").is_some() && !args["importance"].is_null() {
664            let importance =
665                wm_dispatch::write_gate::parse_importance_value(args.get("importance"))
666                    .map_err(wm_core::CoreError::InvalidArgs)?;
667            if let Some(importance) = importance {
668                mem.metadata.importance = importance;
669            }
670        }
671        // Envelope v2 (S4): title/topic are settable and clearable
672        // (explicit null clears; absent leaves untouched).
673        if let Some(title) = args.get("title") {
674            mem.metadata.title = title
675                .as_str()
676                .map(str::trim)
677                .filter(|s| !s.is_empty())
678                .map(String::from);
679        }
680        if let Some(topic) = args.get("topic") {
681            mem.metadata.topic = topic
682                .as_str()
683                .map(str::trim)
684                .filter(|s| !s.is_empty())
685                .map(String::from);
686        }
687        if let Some(content) = args.get("content").and_then(|v| v.as_str()) {
688            mem.content = content.to_string();
689            // Content changes invalidate the hash — keep it in sync so
690            // dedup and content-hash lookups stay truthful.
691            mem.metadata.content_hash = wm_memory::content_hash(content);
692            // V8 S11c: content changes bump the revision counter; the
693            // chain entry itself is recorded after the row lands.
694            mem.metadata.revision_count = mem.metadata.revision_count.saturating_add(1);
695        }
696        self.store.put(galaxy, &mem)?;
697
698        // V8 S11c: append the revision entry — seq, hashes, and the
699        // attributed actor from the dispatch context. A chain failure
700        // degrades loud (warn + disclosure), never silently.
701        let mut revision_disclosure = None;
702        if content_changed {
703            let actor = wm_memory::RevisionActor {
704                session: ctx.session_id.map(|sid| sid.to_string()),
705                user: ctx.user_id.clone(),
706                compartment: ctx.compartment.clone(),
707            };
708            match self.store.record_revision(
709                galaxy,
710                id,
711                &previous_hash,
712                &mem.metadata.content_hash,
713                actor,
714            ) {
715                Ok(rev) => {
716                    revision_disclosure = Some(serde_json::json!({
717                        "seq": rev.seq,
718                        "old_hash": rev.old_hash,
719                        "new_hash": rev.new_hash,
720                    }));
721                }
722                Err(e) => {
723                    tracing::warn!(error = %e, "revision chain record failed for {id_str}");
724                    revision_disclosure = Some(serde_json::json!({"record_failed": e.to_string()}));
725                }
726            }
727        }
728
729        // Phase 3 secrets hygiene: an update that introduces credential-
730        // shaped content gets the same boundary warning as memory.create.
731        let cred_kinds = args
732            .get("content")
733            .and_then(serde_json::Value::as_str)
734            .map(wm_memory::credential_shaped_content)
735            .unwrap_or_default();
736
737        // mcp-input-boundary (2026-09-21): instruction-shaped content is
738        // flagged (never rejected) exactly like credential-shaped content.
739        let instruction_pattern = wm_memory::detect_injection(&mem.content);
740
741        // Re-index in Tantivy if search engine is available (non-fatal, but a
742        // failure is recorded in the durable pending-index ledger).
743        super::common::replace_memory_index(&self.store, self.search.as_deref(), &mem);
744
745        let mut response = json!({
746            "status": "success",
747            "id": mem.metadata.id,
748            "galaxy": galaxy_name(galaxy),
749            "tags": mem.metadata.tags,
750            "importance": mem.metadata.importance,
751            // V8 S11a: the write-audit journal scrapes `content_hash` from
752            // tool output (pipeline record_write_audit), so disclosing it
753            // here gives every update a hash-timeline journal entry;
754            // `prev_content_hash` is the agent/human-facing amendment trail.
755            "content_hash": mem.metadata.content_hash,
756        });
757        if content_changed {
758            response["prev_content_hash"] = json!(previous_hash);
759        }
760        if let Some(rev) = revision_disclosure {
761            response["revision"] = rev;
762        }
763        if !cred_kinds.is_empty() || instruction_pattern.is_some() {
764            let mut warnings: Vec<String> = cred_kinds
765                .iter()
766                .map(|k| {
767                    format!(
768                        "content looks like a credential ({k}) — {}",
769                        wm_memory::CREDENTIAL_ADVICE
770                    )
771                })
772                .collect();
773            if let Some(pattern) = instruction_pattern {
774                warnings.push(format!(
775                    "content contains an instruction-shaped pattern ({pattern}) — stored as data; \
776                     review it before trusting it as context"
777                ));
778            }
779            response["warnings"] = json!(warnings);
780        }
781        Ok(response)
782    }
783    fn stats(&self) -> &ToolStats {
784        &self.stats
785    }
786}
787
788/// `memory.revisions` — list or verify a memory's content revision chain
789/// (V8 S11c).
790///
791/// `action: "list"` returns the entries; `action: "verify"` grades the
792/// chain against the memory's current content hash (seq continuity, hash
793/// linkage, head match) — the tamper-evidence walk.
794pub struct MemoryRevisionsTool {
795    store: Arc<MemoryStore>,
796    stats: ToolStats,
797    effects: EffectRow,
798}
799
800impl MemoryRevisionsTool {
801    pub fn new(store: Arc<MemoryStore>) -> Self {
802        Self {
803            store,
804            stats: ToolStats::default(),
805            effects: EffectRow {
806                reads: super::common::memory_galaxy_reads(),
807                ..Default::default()
808            },
809        }
810    }
811}
812
813#[async_trait]
814impl Tool for MemoryRevisionsTool {
815    fn name(&self) -> &str {
816        "memory.revisions"
817    }
818    fn gana(&self) -> Gana {
819        Gana::WinnowingBasket
820    }
821    fn effects(&self) -> &EffectRow {
822        &self.effects
823    }
824    fn description(&self) -> &str {
825        "List or verify a memory's content revision chain (tamper evidence)"
826    }
827    fn input_schema(&self) -> Value {
828        super::common::schema(
829            &json!({
830                "id": super::common::str_prop("Memory UUID to inspect"),
831                "action": super::common::str_prop("Action: list (default) | verify"),
832                "galaxy": super::common::str_prop("Galaxy (default: codex)"),
833            }),
834            &["id"],
835        )
836    }
837    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
838        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
839        let id_str = args
840            .get("id")
841            .and_then(|v| v.as_str())
842            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
843        let id = uuid::Uuid::parse_str(id_str)
844            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
845        let action = args
846            .get("action")
847            .and_then(|v| v.as_str())
848            .unwrap_or("list");
849        let revisions = self.store.revisions(galaxy, id)?;
850        match action {
851            "verify" => {
852                let mem = self.store.get(galaxy, id)?.ok_or_else(|| {
853                    wm_core::CoreError::NotFound(format!(
854                        "Memory {id} not found in {}",
855                        galaxy_name(galaxy)
856                    ))
857                })?;
858                let report =
859                    self.store
860                        .verify_revision_chain(galaxy, id, &mem.metadata.content_hash)?;
861                Ok(json!({
862                    "status": "success",
863                    "id": id,
864                    "galaxy": galaxy_name(galaxy),
865                    "action": "verify",
866                    "valid": report.valid,
867                    "entries": report.entries,
868                    "matches_head": report.matches_head,
869                    "breaks": report.breaks,
870                    "note": if report.entries == 0 {
871                        "no revisions recorded (never content-updated or pre-S11c)"
872                    } else { "" },
873                }))
874            }
875            "list" => Ok(json!({
876                "status": "success",
877                "id": id,
878                "galaxy": galaxy_name(galaxy),
879                "action": "list",
880                "count": revisions.len(),
881                "revisions": revisions,
882            })),
883            other => Err(wm_core::CoreError::InvalidArgs(format!(
884                "Unknown action '{other}' (expected 'list' or 'verify')"
885            ))),
886        }
887    }
888    fn stats(&self) -> &ToolStats {
889        &self.stats
890    }
891}
892
893/// `memory.tag` — add or remove tags from a memory.
894pub struct MemoryTagTool {
895    store: Arc<MemoryStore>,
896    stats: ToolStats,
897    effects: EffectRow,
898}
899
900impl MemoryTagTool {
901    pub fn new(store: Arc<MemoryStore>) -> Self {
902        Self {
903            store,
904            stats: ToolStats::default(),
905            effects: EffectRow {
906                writes: super::common::memory_galaxy_writes(),
907                reads: super::common::memory_galaxy_reads(),
908                ..Default::default()
909            },
910        }
911    }
912}
913
914#[async_trait]
915impl Tool for MemoryTagTool {
916    fn name(&self) -> &str {
917        "memory.tag"
918    }
919    fn gana(&self) -> Gana {
920        Gana::Net
921    }
922    fn effects(&self) -> &EffectRow {
923        &self.effects
924    }
925    fn description(&self) -> &str {
926        "Add or remove tags from a memory"
927    }
928    fn input_schema(&self) -> Value {
929        super::common::schema(
930            &json!({
931                "id": super::common::str_prop("Memory UUID to tag"),
932                "tags": super::common::str_array_prop("Tags to apply"),
933                "action": super::common::str_prop("Action: add (default) | remove"),
934                "galaxy": super::common::str_prop("Galaxy (default: codex)"),
935            }),
936            &["id", "tags"],
937        )
938    }
939    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
940        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
941        let id_str = args
942            .get("id")
943            .and_then(|v| v.as_str())
944            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
945        let id = uuid::Uuid::parse_str(id_str)
946            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
947        let mut mem = self
948            .store
949            .get(galaxy, id)?
950            .ok_or_else(|| wm_core::CoreError::NotFound(format!("Memory {id} not found")))?;
951        let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("add");
952        let tags = args
953            .get("tags")
954            .and_then(|v| v.as_array())
955            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'tags' array".into()))?;
956        let tag_list: Vec<String> = tags
957            .iter()
958            .filter_map(|t| t.as_str().map(String::from))
959            .collect();
960        match action {
961            "remove" => {
962                mem.metadata.tags.retain(|t| !tag_list.contains(t));
963            }
964            _ => {
965                for t in &tag_list {
966                    if !mem.metadata.tags.contains(t) {
967                        mem.metadata.tags.push(t.clone());
968                    }
969                }
970            }
971        }
972        self.store.put(galaxy, &mem)?;
973        Ok(json!({
974            "status": "success",
975            "id": mem.metadata.id,
976            "action": action,
977            "tags": mem.metadata.tags,
978        }))
979    }
980    fn stats(&self) -> &ToolStats {
981        &self.stats
982    }
983}
984
985/// `memory.stats` — statistics for a galaxy.
986pub struct MemoryStatsTool {
987    store: Arc<MemoryStore>,
988    stats: ToolStats,
989    effects: EffectRow,
990}
991
992impl MemoryStatsTool {
993    pub fn new(store: Arc<MemoryStore>) -> Self {
994        Self {
995            store,
996            stats: ToolStats::default(),
997            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
998        }
999    }
1000}
1001
1002#[async_trait]
1003impl Tool for MemoryStatsTool {
1004    fn input_schema(&self) -> Value {
1005        schema(
1006            &json!({
1007                "galaxy": super::common::str_prop("Galaxy to summarize (optional; default codex)"),
1008            }),
1009            &[],
1010        )
1011    }
1012    fn name(&self) -> &str {
1013        "memory.stats"
1014    }
1015    fn gana(&self) -> Gana {
1016        Gana::WinnowingBasket
1017    }
1018    fn effects(&self) -> &EffectRow {
1019        &self.effects
1020    }
1021    fn description(&self) -> &str {
1022        "Statistics for a galaxy (count, avg importance, tag frequency)"
1023    }
1024    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1025        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
1026        let memories = self.store.scan(galaxy, 10_000)?;
1027        let total = memories.len();
1028        let avg_importance = if total > 0 {
1029            memories.iter().map(|m| m.metadata.importance).sum::<f32>() / total as f32
1030        } else {
1031            0.0
1032        };
1033        let mut tag_freq: HashMap<String, u32> = HashMap::new();
1034        for mem in &memories {
1035            for tag in &mem.metadata.tags {
1036                *tag_freq.entry(tag.clone()).or_insert(0) += 1;
1037            }
1038        }
1039        let top_tags: Vec<(String, u32)> = tag_freq.into_iter().filter(|(_, c)| *c >= 2).collect();
1040        Ok(json!({
1041            "status": "success",
1042            "galaxy": galaxy_name(galaxy),
1043            "count": total,
1044            "avg_importance": (avg_importance * 100.0).round() / 100.0,
1045            "tag_clusters": top_tags.len(),
1046            "top_tags": top_tags.into_iter().take(10).collect::<Vec<_>>(),
1047        }))
1048    }
1049    fn stats(&self) -> &ToolStats {
1050        &self.stats
1051    }
1052}
1053
1054/// Shared retrieval implementation used by `memory.search` (public verb)
1055/// and `memory.hybrid_recall` (compatibility alias).
1056pub struct MemoryHybridRecallTool {
1057    store: Arc<MemoryStore>,
1058    search: Option<Arc<SearchEngine>>,
1059    recall: Option<Arc<RecallEngine>>,
1060    associations: Option<Arc<AssociationStore>>,
1061    stats: ToolStats,
1062    effects: EffectRow,
1063    route_name: &'static str,
1064}
1065
1066impl MemoryHybridRecallTool {
1067    pub fn new(
1068        store: Arc<MemoryStore>,
1069        search: Option<Arc<SearchEngine>>,
1070        recall: Option<Arc<RecallEngine>>,
1071    ) -> Self {
1072        Self::named("memory.hybrid_recall", store, search, recall)
1073    }
1074
1075    /// Public retrieval verb. Same implementation as `memory.hybrid_recall`.
1076    pub fn as_search(
1077        store: Arc<MemoryStore>,
1078        search: Option<Arc<SearchEngine>>,
1079        recall: Option<Arc<RecallEngine>>,
1080    ) -> Self {
1081        Self::named("memory.search", store, search, recall)
1082    }
1083
1084    /// Attach the association graph for bounded spreading activation:
1085    /// top results seed a one-hop expansion over typed links, surfacing
1086    /// connected memories that lexical search alone cannot reach.
1087    #[must_use]
1088    pub fn with_associations(mut self, associations: Option<Arc<AssociationStore>>) -> Self {
1089        self.associations = associations;
1090        self
1091    }
1092
1093    fn named(
1094        route_name: &'static str,
1095        store: Arc<MemoryStore>,
1096        search: Option<Arc<SearchEngine>>,
1097        recall: Option<Arc<RecallEngine>>,
1098    ) -> Self {
1099        Self {
1100            store,
1101            search,
1102            recall,
1103            associations: None,
1104            stats: ToolStats::default(),
1105            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1106            route_name,
1107        }
1108    }
1109}
1110
1111/// `memory.reembed` — backfill per-memory vectors for memories that lack them.
1112///
1113/// Dry-run by default (plan only); `dry_run: false` persists vectors.
1114/// Bounded by `limit` (default 200) so an interactive dispatch never runs
1115/// unbounded; re-run to continue. Requires a real embedder — the tool
1116/// refuses to store stub noise.
1117pub struct MemoryReembedTool {
1118    recall: Option<Arc<RecallEngine>>,
1119    stats: ToolStats,
1120    effects: EffectRow,
1121}
1122
1123impl MemoryReembedTool {
1124    #[must_use]
1125    pub fn new(recall: Option<Arc<RecallEngine>>) -> Self {
1126        Self {
1127            recall,
1128            stats: ToolStats::default(),
1129            effects: EffectRow {
1130                writes: {
1131                    let mut writes = super::common::memory_galaxy_writes();
1132                    writes.push(Resource::Galaxy("embeddings".into()));
1133                    writes
1134                },
1135                reads: super::common::memory_galaxy_reads(),
1136                destructive: false,
1137                ..Default::default()
1138            },
1139        }
1140    }
1141}
1142
1143#[async_trait]
1144impl Tool for MemoryReembedTool {
1145    fn name(&self) -> &str {
1146        "memory.reembed"
1147    }
1148    fn gana(&self) -> Gana {
1149        Gana::WinnowingBasket
1150    }
1151    fn effects(&self) -> &EffectRow {
1152        &self.effects
1153    }
1154    fn stats(&self) -> &ToolStats {
1155        &self.stats
1156    }
1157    fn description(&self) -> &str {
1158        "Backfill per-memory embedding vectors for memories that have none (dry-run by default; requires a real embedder). Bounded by limit; re-run to continue. Populates the persistent vector index used by hybrid recall."
1159    }
1160    fn input_schema(&self) -> Value {
1161        schema(
1162            &json!({
1163                "galaxy": str_prop("Only this galaxy (optional; default: all memory galaxies)"),
1164                "limit": int_prop("Maximum vectors to embed this pass (default 200; 0 = no cap)"),
1165                "dry_run": bool_prop("Plan only, no writes (default true)"),
1166            }),
1167            &[],
1168        )
1169    }
1170    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1171        let galaxy = match args.get("galaxy").and_then(|v| v.as_str()) {
1172            Some(name) => Some(parse_galaxy(name)?),
1173            None => None,
1174        };
1175        let limit = args
1176            .get("limit")
1177            .and_then(serde_json::Value::as_u64)
1178            .map_or(200usize, |v| v as usize);
1179        let dry_run = args
1180            .get("dry_run")
1181            .and_then(serde_json::Value::as_bool)
1182            .unwrap_or(true);
1183
1184        let Some(recall) = self.recall.as_ref() else {
1185            return Ok(json!({
1186                "status": "error",
1187                "error": "no real embedder wired in this server — set WM_EMBEDDER_ENDPOINT (or the onnx backend) and restart; memory.reembed will not store stub noise",
1188            }));
1189        };
1190        let report = recall.backfill_embeddings(galaxy, limit, dry_run)?;
1191        let mut out = serde_json::to_value(&report).unwrap_or_else(|_| json!({}));
1192        if let Some(obj) = out.as_object_mut() {
1193            obj.insert("status".into(), json!("success"));
1194            obj.insert(
1195                "hint".into(),
1196                json!(if dry_run {
1197                    "dry-run only — call again with dry_run: false to persist vectors"
1198                } else {
1199                    "vectors persisted; the shared vector index is updated in this process, and restarted processes rehydrate it on first hybrid search"
1200                }),
1201            );
1202        }
1203        Ok(out)
1204    }
1205}
1206
1207/// Telemetry is evidence, not cognition: unfiltered recall must never surface
1208/// it, while an explicit galaxy filter (including `galaxy: "telemetry"`) stays
1209/// the only door. Applies to every retrieval phase — hybrid, episodic, FTS,
1210/// association expansion, and cold discovery. Regression: v9.1.5 default
1211/// `memory.search("invalid UUID")` returned RSI friction records from the
1212/// telemetry galaxy alongside project memory.
1213fn recall_visible(galaxy: Galaxy, galaxy_explicit: bool) -> bool {
1214    galaxy_explicit || galaxy != Galaxy::Telemetry
1215}
1216
1217/// Build the empty-result guidance message: name where the content actually
1218/// lives so callers do not hit the "silent zero" class of failure (e.g.
1219/// stores whose memories live in `sessions`/`research`, not the default
1220/// `codex`).
1221fn empty_result_hint(store: &MemoryStore, galaxy: Galaxy) -> String {
1222    let mut populated: Vec<String> = Vec::new();
1223    let mut requested_total = 0usize;
1224    for g in Galaxy::memory_galaxies() {
1225        if g == galaxy {
1226            requested_total = store.count(g).unwrap_or(0);
1227            continue;
1228        }
1229        let n = store.count(g).unwrap_or(0);
1230        if n > 0 {
1231            populated.push(format!("{} ({})", g.db_name(), n));
1232        }
1233    }
1234    let location = if requested_total == 0 {
1235        format!("galaxy '{}' contains no memories", galaxy_name(galaxy))
1236    } else {
1237        format!(
1238            "no matches for this query in '{}' ({} memories)",
1239            galaxy_name(galaxy),
1240            requested_total
1241        )
1242    };
1243    if populated.is_empty() {
1244        format!("{location}; the store is empty")
1245    } else {
1246        format!(
1247            "{}; other galaxies with content: {}. Pass an explicit \"galaxy\" to search there.",
1248            location,
1249            populated.join(", ")
1250        )
1251    }
1252}
1253
1254/// Same guidance for the galaxy-unfiltered search (no `galaxy` argument):
1255/// the query ran everywhere, so the hint reports the overall corpus shape.
1256fn empty_result_hint_all(store: &MemoryStore) -> String {
1257    let mut populated: Vec<String> = Vec::new();
1258    let mut total = 0usize;
1259    for g in Galaxy::memory_galaxies() {
1260        let n = store.count(g).unwrap_or(0);
1261        total += n;
1262        if n > 0 {
1263            populated.push(format!("{} ({})", g.db_name(), n));
1264        }
1265    }
1266    if populated.is_empty() {
1267        "no matches for this query; the store is empty".to_string()
1268    } else {
1269        format!(
1270            "no matches for this query across all memory galaxies ({} total); populated: {}",
1271            total,
1272            populated.join(", ")
1273        )
1274    }
1275}
1276
1277#[async_trait]
1278impl Tool for MemoryHybridRecallTool {
1279    fn name(&self) -> &str {
1280        self.route_name
1281    }
1282    fn gana(&self) -> Gana {
1283        Gana::WinnowingBasket
1284    }
1285    fn effects(&self) -> &EffectRow {
1286        &self.effects
1287    }
1288    fn description(&self) -> &str {
1289        "Search memories: hybrid BM25+vector fusion with a real embedder; otherwise the episodic deterministic route, falling back to BM25 full-text. Every result discloses recall_mode (hybrid|bm25|episodic|fts|importance|cold|none) — bm25 means the fusion ranked with the vector weight configured to zero (the query embed is skipped when the vector half cannot contribute). memory.hybrid_recall is a compatibility alias."
1290    }
1291    fn input_schema(&self) -> Value {
1292        schema(
1293            &json!({
1294                "query": str_prop("Full-text query"),
1295                "galaxy": str_prop("Galaxy filter (optional; default: search all memory galaxies, results labeled; \"all\" is accepted as an alias for the unfiltered default)"),
1296                "limit": super::common::positive_int_prop("Maximum results (default 10; must be >= 1)"),
1297                "min_importance": num_prop("Minimum memory importance (0-1)"),
1298                "min_score": num_prop("Absolute BM25 score floor"),
1299                "min_score_ratio": num_prop("Relative floor: reject hits below this fraction of the top score"),
1300                "min_trust": num_prop("Minimum source_trust (0-1): drop results below this trust floor"),
1301                "include_cold": bool_prop("Opt-in unranked cold recovery (no thaw). Trust/importance floors apply; BM25 floors do not apply to unscored recovery. Search content is scrubbed navigation capped at 8192 characters; read by id/galaxy for the exact original."),
1302                "cold_scan_limit": int_prop("Maximum cold records to scan when include_cold is set (default 2048)"),
1303            }),
1304            &["query"],
1305        )
1306    }
1307    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1308        let galaxy_arg = galaxy_search_arg(args.get("galaxy").and_then(|v| v.as_str()));
1309        let galaxy_explicit = galaxy_arg.is_some();
1310        let galaxy = parse_galaxy_or(galaxy_arg, Galaxy::Codex)?;
1311        let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
1312        // limit must be >= 1: zero used to reach Tantivy's TopDocs and panic
1313        // the process (2026-09-19 review). Caller error at the boundary.
1314        let limit = super::common::positive_usize_arg(&args, "limit", 10)
1315            .map_err(wm_core::CoreError::InvalidArgs)?;
1316        let include_cold = args
1317            .get("include_cold")
1318            .and_then(serde_json::Value::as_bool)
1319            .unwrap_or(false);
1320        let cold_scan_limit = args
1321            .get("cold_scan_limit")
1322            .and_then(serde_json::Value::as_u64)
1323            .map_or(2048, |v| v.clamp(1, 100_000) as usize);
1324        let min_importance = super::common::bounded_f64_arg(&args, "min_importance", 0.0, Some(1.0))
1325            .map_err(wm_core::CoreError::InvalidArgs)?
1326            .unwrap_or(0.0) as f32;
1327        // Absolute BM25 floor (0 / absent = disabled). Clients that set a
1328        // meaningful `minScore` finally get what they asked for. Negative
1329        // values are a caller error, not a silently disabled floor.
1330        let min_score = super::common::bounded_f64_arg(&args, "min_score", 0.0, None)
1331            .map_err(wm_core::CoreError::InvalidArgs)?
1332            .map(|v| v as f32)
1333            .filter(|v| *v > 0.0);
1334        // Relative floor: reject hits below `ratio * top_score`.
1335        // 0.0 or absent → use default 5%, or disable with an explicit 0.0.
1336        // Out-of-range values are caller errors (1.0+ previously fell back to
1337        // the 5% default, silently relaxing a stricter request).
1338        let min_score_ratio =
1339            super::common::bounded_f64_arg(&args, "min_score_ratio", 0.0, Some(1.0))
1340                .map_err(wm_core::CoreError::InvalidArgs)?
1341                .map_or(Some(0.05), |v| Some(v as f32));
1342        let mut results = Vec::new();
1343
1344        // V8.1 trust weighting (evidence-gated): 0.0 = off by default.
1345        // See wm_memory::trust_weighted_score — enable after the recall
1346        // benchmark re-run, once heritage source_trust stamps are corrected
1347        // (wm trust survey / wm trust correct).
1348        let trust_weight = std::env::var("WM_TRUST_WEIGHT")
1349            .ok()
1350            .and_then(|v| v.parse::<f32>().ok())
1351            .unwrap_or(0.0)
1352            .clamp(0.0, 1.0);
1353        // V8 S8 disclosures, populated by Phase 0 when applicable.
1354        let mut result_extra: Option<serde_json::Value> = None;
1355        let mut trust_disclosure: Option<serde_json::Value> = None;
1356
1357        // Recall-mode honesty (V8 ship list #1/#6): which route answered
1358        // this query is disclosed on the result — hybrid | bm25 | episodic |
1359        // fts | importance | none.
1360        let mut recall_mode = "none";
1361        // Hybrid fusion requires a REAL embedder: with the stub, vector
1362        // halves are noise, so a stub-wired engine must not claim the
1363        // hybrid route (the server already refuses to wire one; this gate
1364        // makes the tool honest even when constructed directly).
1365        let mut hybrid_available = self
1366            .recall
1367            .as_ref()
1368            .is_some_and(|recall| recall.embedder_is_real());
1369        // The fusion route with the vector weight configured to zero ranks
1370        // by BM25 alone (T0 finding F-T0-2: the tool used to disclose
1371        // `hybrid` regardless). Disclose `bm25` in that case — the label
1372        // follows the configured weights, not the code path taken.
1373        let fused_mode = fused_mode_label(
1374            self.recall
1375                .as_ref()
1376                .map_or(1.0, |recall| recall.config().vector_weight),
1377        );
1378
1379        // Phase 0: If RecallEngine with a real embedder is available, use
1380        // hybrid BM25 + vector search for fused ranking. Trust weighting
1381        // lives INSIDE the fusion since V8 S8 (single application point —
1382        // applying it again here would double-count); the per-result
1383        // trust_factor + conformal set disclosure come straight from the
1384        // engine.
1385        if hybrid_available {
1386            let recall = self.recall.as_ref().expect("hybrid_available checked");
1387            if !query.is_empty() {
1388                let (hybrid_results, conformal) = recall.hybrid_search_with_disclosure(
1389                    query,
1390                    limit * 2,
1391                    galaxy_explicit.then_some(galaxy),
1392                );
1393                for hr in hybrid_results {
1394                    if !recall_visible(hr.galaxy, galaxy_explicit) {
1395                        continue;
1396                    }
1397                    if let Ok(Some(mem)) = self.store.get(hr.galaxy, hr.memory_id) {
1398                        if mem.metadata.importance >= min_importance
1399                            && crate::expansion::common::mcp_visible(&mem)
1400                            && crate::expansion::common::validity_visible(&mem)
1401                        {
1402                            let navigation = wm_memory::scrub_text(&mem.content);
1403                            results.push(with_navigation_disclosure(
1404                                json!({
1405                                    "id": mem.metadata.id,
1406                                    "galaxy": mem.metadata.galaxy.db_name(),
1407                                    "content": navigation,
1408                                    "importance": mem.metadata.importance,
1409                                    "score": hr.score,
1410                                    "raw_score": hr.raw_bm25_score,
1411                                    "trust_factor": hr.trust_factor,
1412                                    "corroboration": hr.corroboration,
1413                                    "in_conformal_set": hr.in_conformal_set,
1414                                    "bm25_score": hr.bm25_score,
1415                                    "vector_score": hr.vector_score,
1416                                    "trust": mem.metadata.source_trust,
1417                                    "source": fused_mode,
1418                                }),
1419                                &mem.content,
1420                            ));
1421                        }
1422                    }
1423                }
1424                // Set-level calibrated coverage disclosure (V8 S8) —
1425                // attached whenever conformal mode is configured, honest
1426                // about `uncalibrated` until feedback exists.
1427                if let Some(info) = conformal {
1428                    result_extra = serde_json::to_value(&info).ok();
1429                }
1430                if trust_weight > 0.0 {
1431                    trust_disclosure = Some(json!({
1432                        "wm_trust_weight": trust_weight,
1433                        "applied_in": "fuse_results",
1434                    }));
1435                }
1436                if !results.is_empty() {
1437                    recall_mode = fused_mode;
1438                }
1439            }
1440        }
1441
1442        // Phase 0b: degradation honesty. A configured embedder that cannot
1443        // answer (server down, model missing) means the hybrid route is
1444        // actually unavailable — probe once and fall through to the
1445        // episodic lane instead of skipping it to FTS. The probe runs only
1446        // when hybrid produced nothing, so the happy path pays nothing.
1447        let mut hybrid_degraded: Option<String> = None;
1448        if hybrid_available && results.is_empty() && !query.is_empty() {
1449            if let Some(recall) = self.recall.as_ref() {
1450                if let Err(error) = recall.embedder_probe() {
1451                    hybrid_degraded = Some(error.to_string());
1452                    hybrid_available = false;
1453                }
1454            }
1455        }
1456
1457        // Phase E: the episodic deterministic route (V8 ship list #1) —
1458        // preferred over plain FTS whenever the hybrid route is
1459        // unavailable. The episodic lane mirrors every v5 write
1460        // (capture_explicit_memory), its deterministic scorer measures
1461        // R@1 0.86 vs the BM25 fallback's 0.64 (LongMemEval-S 50q, S8
1462        // protocol 2026-09-01), and this wire is exactly the v26
1463        // "one fast brain" lesson: route to the best machinery by
1464        // default, disclose which one ran. Falls through to FTS only
1465        // when episodic yields nothing (legacy stores, empty lane,
1466        // genuine no-match). Pool 100 mirrors the acceptance protocol
1467        // (retrieve broad, truncate to `limit` below).
1468        if results.is_empty() && !query.is_empty() && !hybrid_available {
1469            const EPISODIC_RECALL_POOL: usize = 100;
1470            let pool = limit.max(EPISODIC_RECALL_POOL);
1471            // Degradation is never fatal: an episodic-lane error falls
1472            // through to the FTS phases like an empty lane would.
1473            let episodic_hits = match self
1474                .store
1475                .episodic()
1476                .search_with_limits(query, pool, pool, false)
1477            {
1478                Ok(hits) => hits,
1479                Err(error) => {
1480                    tracing::warn!("episodic default-route search failed: {error}");
1481                    Vec::new()
1482                }
1483            };
1484            for er in episodic_hits {
1485                // Record ids mirror the v5 memory id; resolve to carry
1486                // galaxy/importance/visibility from the source of truth.
1487                let Some((hit_galaxy, mem)) =
1488                    resolve_memory_across_galaxies(&self.store, er.record.id)
1489                else {
1490                    continue;
1491                };
1492                if galaxy_explicit && hit_galaxy != galaxy {
1493                    continue;
1494                }
1495                if !recall_visible(hit_galaxy, galaxy_explicit) {
1496                    continue;
1497                }
1498                if mem.metadata.importance < min_importance
1499                    || !crate::expansion::common::mcp_visible(&mem)
1500                    || !crate::expansion::common::validity_visible(&mem)
1501                {
1502                    continue;
1503                }
1504                let navigation = wm_memory::scrub_text(&mem.content);
1505                results.push(with_navigation_disclosure(
1506                    json!({
1507                        "id": mem.metadata.id,
1508                        "galaxy": hit_galaxy.db_name(),
1509                        "content": navigation,
1510                        "importance": mem.metadata.importance,
1511                        "score": er.score,
1512                        "matched_terms": er.matched_terms,
1513                        "trust": mem.metadata.source_trust,
1514                        "source": "episodic",
1515                    }),
1516                    &mem.content,
1517                ));
1518            }
1519            if !results.is_empty() {
1520                recall_mode = "episodic";
1521            }
1522        }
1523
1524        // Phase 1: full-text search (OR + token-coverage + score floors)
1525        // Only run if hybrid search didn't produce results or no RecallEngine
1526        if results.is_empty() {
1527            if let Some(ref search) = self.search {
1528                if !query.is_empty() {
1529                    let opts = wm_memory::SearchOptions {
1530                        limit: limit * 2,
1531                        min_score,
1532                        relative_floor: min_score_ratio,
1533                        // Explicit galaxy filters at the index. Without one,
1534                        // the query runs across every memory galaxy — the
1535                        // Tantivy query was always galaxy-blind, but hits
1536                        // were resolved against the default galaxy only,
1537                        // which silently hid sessions/research/dreams
1538                        // content (found by the post-cutover federated
1539                        // verification, 2026-08-29).
1540                        galaxy: galaxy_explicit.then_some(galaxy),
1541                        ..wm_memory::SearchOptions::default()
1542                    };
1543                    let hits = search.search_opt(query, &opts)?;
1544                    for hit in hits {
1545                        if let Ok(id) = uuid::Uuid::parse_str(&hit.memory_id) {
1546                            // Resolve each hit in the galaxy its index
1547                            // document declares — with no explicit filter
1548                            // this is the only correct resolution.
1549                            let hit_galaxy = if galaxy_explicit {
1550                                Some(galaxy)
1551                            } else {
1552                                wm_core::Galaxy::all()
1553                                    .into_iter()
1554                                    .find(|g| g.db_name() == hit.galaxy)
1555                            };
1556                            let Some(hit_galaxy) = hit_galaxy else {
1557                                continue;
1558                            };
1559                            if !recall_visible(hit_galaxy, galaxy_explicit) {
1560                                continue;
1561                            }
1562                            if let Ok(Some(mem)) = self.store.get(hit_galaxy, id) {
1563                                if mem.metadata.importance >= min_importance
1564                                    && crate::expansion::common::mcp_visible(&mem)
1565                                    && crate::expansion::common::validity_visible(&mem)
1566                                {
1567                                    let navigation = wm_memory::scrub_text(&mem.content);
1568                                    results.push(with_navigation_disclosure(
1569                                        json!({
1570                                            "id": mem.metadata.id,
1571                                            "galaxy": hit_galaxy.db_name(),
1572                                            "content": navigation,
1573                                            "importance": mem.metadata.importance,
1574                                            "score": wm_memory::trust_weighted_score(
1575                                                hit.score,
1576                                                mem.metadata.source_trust,
1577                                                trust_weight,
1578                                            ),
1579                                            "raw_score": hit.score,
1580                                            "normalized_score": hit.normalized_score,
1581                                            "trust": mem.metadata.source_trust,
1582                                            "source": "fts",
1583                                        }),
1584                                        &mem.content,
1585                                    ));
1586                                    if recall_mode == "none" {
1587                                        recall_mode = "fts";
1588                                    }
1589                                }
1590                            }
1591                        }
1592                    }
1593                }
1594            }
1595        }
1596        // Phase 2: only when NO query was given, return by importance.
1597        // (With a query, empty results are final — a score threshold must
1598        // not be bypassed by a scan-based fallback.)
1599        if results.is_empty() && query.is_empty() {
1600            let mut memories = self.store.scan(galaxy, 100)?;
1601            memories.sort_by(|a, b| {
1602                b.metadata
1603                    .importance
1604                    .partial_cmp(&a.metadata.importance)
1605                    .unwrap_or(std::cmp::Ordering::Equal)
1606            });
1607            for mem in memories
1608                .iter()
1609                .filter(|m| {
1610                    m.metadata.importance >= min_importance
1611                        && crate::expansion::common::mcp_visible(m)
1612                        && crate::expansion::common::validity_visible(m)
1613                })
1614                .take(limit)
1615            {
1616                results.push(json!({
1617                    "id": mem.metadata.id,
1618                    "content": &mem.content,
1619                    "importance": mem.metadata.importance,
1620                    "trust": mem.metadata.source_trust,
1621                    "score": mem.metadata.importance,
1622                    "source": "importance",
1623                    "content_representation": "verbatim",
1624                    "content_truncated": false,
1625                    "content_scrubbed": false,
1626                    "exact_read_available": true,
1627                }));
1628                if recall_mode == "none" {
1629                    recall_mode = "importance";
1630                }
1631            }
1632        }
1633        // Phase 3: bounded spreading activation over the association graph.
1634        // Top seeds from Phases 0-2 activate their one-hop neighbors; neighbors
1635        // surface as discounted results marked source=association. Read-only:
1636        // no Hebbian writes, one hop, at most 5 expansions.
1637        if !results.is_empty() {
1638            if let Some(assoc_store) = &self.associations {
1639                let mut anchors: Vec<(uuid::Uuid, f32)> = results
1640                    .iter()
1641                    .filter_map(|r| {
1642                        let id = r.get("id")?.as_str()?;
1643                        uuid::Uuid::parse_str(id).ok().map(|u| {
1644                            (
1645                                u,
1646                                r.get("score")
1647                                    .and_then(serde_json::Value::as_f64)
1648                                    .unwrap_or(0.0) as f32,
1649                            )
1650                        })
1651                    })
1652                    .collect();
1653                anchors.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1654                anchors.dedup_by(|a, b| a.0 == b.0);
1655                anchors.truncate(5);
1656
1657                let mut expansions: Vec<(uuid::Uuid, f32, f32, String, uuid::Uuid)> = Vec::new();
1658                for (seed_id, seed_score) in &anchors {
1659                    let mut links = Vec::new();
1660                    if let Ok(outgoing) = assoc_store.find_from(self.store.env(), *seed_id) {
1661                        links.extend(outgoing);
1662                    }
1663                    if let Ok(incoming) = assoc_store.find_to(self.store.env(), *seed_id) {
1664                        links.extend(incoming);
1665                    }
1666                    for assoc in links {
1667                        if assoc.weight < 0.05 {
1668                            continue;
1669                        }
1670                        let neighbor = if assoc.target == *seed_id {
1671                            assoc.source
1672                        } else {
1673                            assoc.target
1674                        };
1675                        let score = seed_score * assoc.weight * 0.5;
1676                        if score <= 0.0 {
1677                            continue;
1678                        }
1679                        expansions.push((
1680                            neighbor,
1681                            score,
1682                            assoc.weight,
1683                            assoc.link_type.as_str().to_string(),
1684                            *seed_id,
1685                        ));
1686                    }
1687                }
1688                expansions.sort_by(|a, b| {
1689                    b.1.partial_cmp(&a.1)
1690                        .unwrap_or(std::cmp::Ordering::Equal)
1691                        .then_with(|| a.0.cmp(&b.0))
1692                });
1693                expansions.dedup_by(|a, b| a.0 == b.0);
1694                expansions.truncate(5);
1695
1696                let direct_ids: Vec<String> = results
1697                    .iter()
1698                    .filter_map(|r| {
1699                        r.get("id")
1700                            .and_then(serde_json::Value::as_str)
1701                            .map(String::from)
1702                    })
1703                    .collect();
1704                for (neighbor_id, score, weight, link_type, seed_id) in expansions {
1705                    if direct_ids.iter().any(|id| id == &neighbor_id.to_string()) {
1706                        continue;
1707                    }
1708                    let Some((_, mem)) = resolve_memory_across_galaxies(&self.store, neighbor_id)
1709                    else {
1710                        continue;
1711                    };
1712                    if !recall_visible(mem.metadata.galaxy, galaxy_explicit) {
1713                        continue;
1714                    }
1715                    if mem.metadata.importance < min_importance
1716                        || !crate::expansion::common::mcp_visible(&mem)
1717                        || !crate::expansion::common::validity_visible(&mem)
1718                    {
1719                        continue;
1720                    }
1721                    let navigation = wm_memory::scrub_text(&mem.content);
1722                    results.push(with_navigation_disclosure(
1723                        json!({
1724                            "id": mem.metadata.id,
1725                            "content": navigation,
1726                            "importance": mem.metadata.importance,
1727                            "trust": mem.metadata.source_trust,
1728                            "score": score,
1729                            "weight": weight,
1730                            "link_type": link_type,
1731                            "via": seed_id.to_string(),
1732                            "source": "association",
1733                        }),
1734                        &mem.content,
1735                    ));
1736                }
1737            }
1738        }
1739        // Trust weighting re-orders (Phase 1 pushed in Tantivy's
1740        // unweighted order); re-sort so the cut at `limit` is honest.
1741        if trust_weight > 0.0 {
1742            results.sort_by(|a, b| {
1743                b.get("score")
1744                    .and_then(serde_json::Value::as_f64)
1745                    .partial_cmp(&a.get("score").and_then(serde_json::Value::as_f64))
1746                    .unwrap_or(std::cmp::Ordering::Equal)
1747            });
1748        }
1749        // V8 T-b: explicit trust floor — a FILTER, not a ranking weight
1750        // (WM_TRUST_WEIGHT reorders; min_trust removes). Post-resolution
1751        // on every route: results carry `trust`, so the floor applies
1752        // uniformly, including the trust-inert episodic route. Out-of-range
1753        // values are caller errors — watching a "stricter" floor silently
1754        // disable itself is exactly the failure this validation prevents.
1755        let min_trust = super::common::bounded_f64_arg(&args, "min_trust", 0.0, Some(1.0))
1756            .map_err(wm_core::CoreError::InvalidArgs)?;
1757        let pre_filter = results.len();
1758        if let Some(min) = min_trust {
1759            results.retain(
1760                |r| match r.get("trust").and_then(serde_json::Value::as_f64) {
1761                    Some(t) => (t as f32) >= min as f32,
1762                    None => false,
1763                },
1764            );
1765        }
1766        let min_trust_filtered = pre_filter - results.len();
1767        results.truncate(limit);
1768        // Empty-result guidance: when a query matched nothing, tell the caller
1769        // where the content actually lives. Prevents the "silent zero" class
1770        // of failure (e.g. stores like the vault whose memories live in
1771        // `sessions`/`research`, not the default `codex`).
1772        // Cold discovery (opt-in, bounded, identity-bound): hydrate and
1773        // authorize candidates from the cold payload rather than trusting
1774        // stale index entries. Private records never surface over MCP;
1775        // superseded and tamper-failing records are refused; nothing is
1776        // thawed or mutated. Appended only after hot routes settle, and
1777        // only into remaining `limit` headroom.
1778        let cold_discovery: Option<serde_json::Value> = if include_cold && !query.is_empty() {
1779            let terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
1780            let remaining = limit.saturating_sub(results.len());
1781            if remaining == 0 {
1782                Some(
1783                    json!({"enabled":true,"scanned":0,"candidates":0,"matched":0,"appended":0,"integrity_rejected":0,"private_skipped":0,"non_current_skipped":0,"eligibility_skipped":0,"stop_reason":"no_headroom","exhausted":false,"no_thaw":true,"ranked":false,"scan_order":"uuid_key","score_floors":"not_applicable_unscored_recovery"}),
1784                )
1785            } else {
1786                let existing: std::collections::HashSet<String> = results
1787                    .iter()
1788                    .filter_map(|r| {
1789                        r.get("id")
1790                            .and_then(serde_json::Value::as_str)
1791                            .map(str::to_string)
1792                    })
1793                    .collect();
1794                let outcome = self.store.find_cold_matching_eligible(
1795                    &terms,
1796                    if galaxy_explicit { Some(galaxy) } else { None },
1797                    remaining,
1798                    cold_scan_limit,
1799                    |mem| {
1800                        recall_visible(mem.metadata.galaxy, galaxy_explicit)
1801                            && mem.metadata.importance >= min_importance
1802                            && min_trust
1803                                .is_none_or(|floor| f64::from(mem.metadata.source_trust) >= floor)
1804                            && !existing.contains(&mem.metadata.id.to_string())
1805                    },
1806                )?;
1807                let mut appended = 0usize;
1808                for record in &outcome.records {
1809                    if appended >= remaining {
1810                        break;
1811                    }
1812                    let id = record.id.to_string();
1813                    if existing.contains(&id) {
1814                        continue;
1815                    }
1816                    let mem = record.decompress()?;
1817                    let navigation = wm_memory::search::scrub_text(&mem.content);
1818                    results.push(with_navigation_disclosure(
1819                        json!({
1820                            "id": id,
1821                            "galaxy": mem.metadata.galaxy.db_name(),
1822                            "content": navigation,
1823                            "importance": mem.metadata.importance,
1824                            "trust": mem.metadata.source_trust,
1825                            "score": serde_json::Value::Null,
1826                            "source": "cold",
1827                            "cold": true,
1828                            "integrity": "verified",
1829                            "model_visible": !mem.metadata.model_exclude,
1830                            "tags": &mem.metadata.tags,
1831                        }),
1832                        &mem.content,
1833                    ));
1834                    appended += 1;
1835                }
1836                if appended > 0 && recall_mode == "none" {
1837                    recall_mode = "cold";
1838                }
1839                Some(json!({
1840                    "enabled": true,
1841                    "scanned": outcome.scanned,
1842                    "candidates": outcome.candidates,
1843                    "matched": outcome.matched,
1844                    "appended": appended,
1845                    "integrity_rejected": outcome.integrity_rejected,
1846                    "private_skipped": outcome.private_skipped,
1847                    "non_current_skipped": outcome.non_current_skipped,
1848                    "eligibility_skipped": outcome.eligibility_skipped,
1849                    "stop_reason": outcome.stop_reason,
1850                    "exhausted": outcome.stop_reason == wm_memory::cold_storage::ColdDiscoveryStop::Exhausted,
1851                    "ranked": false,
1852                    "scan_order": "uuid_key",
1853                    "score_floors": "not_applicable_unscored_recovery",
1854                    "no_thaw": true,
1855                }))
1856            }
1857        } else {
1858            None
1859        };
1860        let evidence_bundle = build_evidence_bundle(&self.store, &results);
1861        // Token-ledger v0 (2026-09-21): recall rows — what the store held vs
1862        // what actually entered context. Local-only diagnostic; attribution
1863        // rules in docs/TOKEN_LEDGER.md (cache is context, memory is
1864        // attribution).
1865        let bytes_injected: usize = results
1866            .iter()
1867            .filter_map(|r| r.get("content").and_then(serde_json::Value::as_str))
1868            .map(str::len)
1869            .sum();
1870        let bytes_available: usize = results
1871            .iter()
1872            .filter_map(|r| r.get("id").and_then(serde_json::Value::as_str))
1873            .filter_map(|id| uuid::Uuid::parse_str(id).ok())
1874            .filter_map(|id| resolve_memory_across_galaxies(&self.store, id))
1875            .map(|(_, mem)| mem.content.len())
1876            .sum();
1877        super::common::append_savings_row(
1878            &self.store,
1879            &json!({
1880                "ts_ms": wm_core::time::now_unix_millis(),
1881                "op": "recall",
1882                "tool": self.route_name,
1883                "recall_mode": recall_mode,
1884                "results": results.len(),
1885                "bytes_available": bytes_available,
1886                "bytes_injected": bytes_injected,
1887            }),
1888        );
1889        let hint = if results.is_empty() && !query.is_empty() {
1890            Some(if galaxy_explicit {
1891                empty_result_hint(&self.store, galaxy)
1892            } else {
1893                empty_result_hint_all(&self.store)
1894            })
1895        } else {
1896            None
1897        };
1898        let mut out = json!({
1899            "status": "success",
1900            "galaxy": if galaxy_explicit {
1901                serde_json::Value::from(galaxy_name(galaxy))
1902            } else {
1903                serde_json::Value::from("all")
1904            },
1905            "count": results.len(),
1906            "recall_mode": recall_mode,
1907            "results": results,
1908            "hint": hint,
1909        });
1910        // V8 S8 disclosures: the conformal set claim (active/uncalibrated)
1911        // and, when the trust knob is on, where the weighting was applied.
1912        if let Some(extra) = result_extra {
1913            out["conformal_set"] = extra;
1914        }
1915        if let Some(td) = trust_disclosure {
1916            out["trust_weighting"] = td;
1917        }
1918        // Degradation disclosure: the configured embedder failed its probe,
1919        // so the route fell through to the episodic lane. Named so callers
1920        // do not misread "no matches" as "nothing exists".
1921        if let Some(reason) = hybrid_degraded {
1922            out["hybrid_degraded"] = json!(reason);
1923        }
1924        if let Some(min) = min_trust {
1925            out["min_trust"] = json!(min);
1926            out["min_trust_filtered"] = json!(min_trust_filtered);
1927        }
1928        if let Some(cd) = cold_discovery {
1929            out["cold_discovery"] = cd;
1930        }
1931        out["evidence_bundle"] = evidence_bundle;
1932        if !query.is_empty() && results.is_empty() {
1933            out["abstention"] = json!({
1934                "status": "insufficient_evidence",
1935                "reason": "no_results_above_floors",
1936                "scope": "retrieval",
1937            });
1938        } else if !query.is_empty() {
1939            // Absolute-evidence gate (hosted-lane finding, 2026-09-22): per-query
1940            // normalization makes the top hit score 1.0 no matter how weak the
1941            // match, so nonsense queries look confident. `raw_score` on each
1942            // result is the absolute signal; these opt-in knobs turn it into an
1943            // explicit abstention object without dropping results.
1944            if let Some(abstention) = weak_evidence_abstention(&results, query) {
1945                out["abstention"] = abstention;
1946            }
1947        }
1948        Ok(out)
1949    }
1950    fn stats(&self) -> &ToolStats {
1951        &self.stats
1952    }
1953}
1954
1955/// `memory.recall_feedback` — record relevance feedback into the recall
1956/// engine's conformal calibrator (V8 S8).
1957///
1958/// This is how retrieval earns the right to claim coverage: explicit
1959/// labels (human feedback or a harness with ground truth) become
1960/// calibration samples; results then carry set membership against a
1961/// threshold with a real guarantee. Refuses honestly when
1962/// `WM_RECALL_CONFORMAL_ALPHA` is unset — there is no calibrated set to
1963/// feed.
1964pub struct MemoryRecallFeedbackTool {
1965    recall: Option<Arc<RecallEngine>>,
1966    stats: ToolStats,
1967    effects: EffectRow,
1968}
1969
1970impl MemoryRecallFeedbackTool {
1971    #[must_use]
1972    pub fn new(recall: Option<Arc<RecallEngine>>) -> Self {
1973        Self {
1974            recall,
1975            stats: ToolStats::default(),
1976            // Persists the fitted classifier JSON to the store root when
1977            // the calibrator crosses its fit threshold — a filesystem
1978            // write outside LMDB, declared as a capability (usage is
1979            // conditional on WM_RECALL_CONFORMAL_ALPHA + fit state).
1980            effects: EffectRow {
1981                writes: vec![Resource::Filesystem],
1982                ..Default::default()
1983            },
1984        }
1985    }
1986}
1987
1988#[async_trait]
1989impl Tool for MemoryRecallFeedbackTool {
1990    fn name(&self) -> &str {
1991        "memory.recall_feedback"
1992    }
1993    fn gana(&self) -> Gana {
1994        Gana::WinnowingBasket
1995    }
1996    fn effects(&self) -> &EffectRow {
1997        &self.effects
1998    }
1999    fn description(&self) -> &str {
2000        "Record relevance feedback for conformal retrieval calibration (V8 S8). Args: samples (array of {score: number 0-1, relevant: bool}) or score+relevant for a single sample. Requires WM_RECALL_CONFORMAL_ALPHA."
2001    }
2002    fn input_schema(&self) -> Value {
2003        schema(
2004            &json!({
2005                "samples": {"type": "array", "items": {"type": "object"}, "description": "Feedback samples: [{score: 0-1 fused score, relevant: bool}]"},
2006                "score": num_prop("Single-sample fused score (0-1)"),
2007                "relevant": {"type": "boolean", "description": "Single-sample relevance label"},
2008            }),
2009            &[],
2010        )
2011    }
2012    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2013        let Some(ref recall) = self.recall else {
2014            return Ok(json!({
2015                "status": "error",
2016                "message": "no recall engine on this server (hybrid search unavailable) — nothing to calibrate",
2017            }));
2018        };
2019        let mut samples: Vec<(f32, bool)> = Vec::new();
2020        if let Some(list) = args.get("samples").and_then(Value::as_array) {
2021            for s in list {
2022                let score = s.get("score").and_then(Value::as_f64).unwrap_or(-1.0);
2023                let relevant = s.get("relevant").and_then(Value::as_bool);
2024                if !(0.0..=1.0).contains(&score) || relevant.is_none() {
2025                    return Err(wm_core::CoreError::InvalidArgs(
2026                        "each sample needs score in [0,1] and a boolean 'relevant'".into(),
2027                    ));
2028                }
2029                samples.push((score as f32, relevant.unwrap_or(false)));
2030            }
2031        } else if let Some(score) = args.get("score").and_then(Value::as_f64) {
2032            let relevant = args
2033                .get("relevant")
2034                .and_then(Value::as_bool)
2035                .ok_or_else(|| {
2036                    wm_core::CoreError::InvalidArgs("'relevant' is required with 'score'".into())
2037                })?;
2038            if !(0.0..=1.0).contains(&score) {
2039                return Err(wm_core::CoreError::InvalidArgs(
2040                    "'score' must be within [0,1]".into(),
2041                ));
2042            }
2043            samples.push((score as f32, relevant));
2044        } else {
2045            return Err(wm_core::CoreError::InvalidArgs(
2046                "provide 'samples' (array of {score, relevant}) or a single 'score' + 'relevant'"
2047                    .into(),
2048            ));
2049        }
2050
2051        let mut recorded = 0usize;
2052        let mut count = 0usize;
2053        for (score, relevant) in samples {
2054            count = recall.record_relevance_feedback(score, relevant)?;
2055            recorded += 1;
2056        }
2057        // Honest post-state disclosure so the caller can see whether the
2058        // calibrator crossed its fit threshold.
2059        let status = recall
2060            .conformal_disclosure(&mut Vec::new())?
2061            .map_or_else(|| "off".into(), |info| info.status);
2062        Ok(json!({
2063            "status": "success",
2064            "recorded": recorded,
2065            "calibration_samples": count,
2066            "conformal_status": status,
2067        }))
2068    }
2069    fn stats(&self) -> &ToolStats {
2070        &self.stats
2071    }
2072}
2073
2074/// `memory.episodic_search` — v6 raw episodic search for controlled evaluation.
2075///
2076/// This route is explicit-only and is not part of the curated v5 surface.
2077pub struct MemoryEpisodicSearchTool {
2078    store: Arc<MemoryStore>,
2079    stats: ToolStats,
2080    effects: EffectRow,
2081}
2082
2083impl MemoryEpisodicSearchTool {
2084    pub fn new(store: Arc<MemoryStore>) -> Self {
2085        Self {
2086            store,
2087            stats: ToolStats::default(),
2088            effects: EffectRow::read_only(vec![Resource::Galaxy("episodic_records".into())]),
2089        }
2090    }
2091}
2092
2093#[async_trait]
2094impl Tool for MemoryEpisodicSearchTool {
2095    fn name(&self) -> &str {
2096        "memory.episodic_search"
2097    }
2098    fn gana(&self) -> Gana {
2099        Gana::WinnowingBasket
2100    }
2101    fn effects(&self) -> &EffectRow {
2102        &self.effects
2103    }
2104    fn description(&self) -> &str {
2105        "[V6 Experimental] Search explicit episodic records with provenance and lifecycle filtering"
2106    }
2107    fn input_schema(&self) -> Value {
2108        schema(
2109            &json!({
2110                "query": str_prop("Full-text query"),
2111                "limit": int_prop("Maximum results (default 10)"),
2112                "candidate_limit": int_prop("Maximum candidates to score (default 2x limit). This is the deterministic retrieval width — keep it wide for tail recall; bound rerank embedding cost with rerank_pool instead (measured on the 50q set: wide retrieval + small pool beat narrowed retrieval on R@1 and R@10)"),
2113                "include_historical": {
2114                    "type": "boolean",
2115                    "description": "Include superseded, revoked, and archived records",
2116                },
2117                "rerank": {
2118                    "type": "boolean",
2119                    "description": "Enable vector reranking (requires embedder, default false)",
2120                },
2121                "rerank_alpha": {
2122                    "type": "number",
2123                    "description": "Rerank mode selector (default 0.7): <1.0 hybrid blend weight; >=1.0 near-tie cosine tiebreaker; >=2.0 protected top-K full cosine reorder (recall@limit preserved by construction when the candidate set is not narrowed). Blending alphas can drop correct items out of top-K — prefer >=2.0 when recall@limit matters",
2124                },
2125                "rerank_pool": int_prop("Embedding/reorder width for rerank (default 0 = auto: min(50, max(limit, candidate_limit)), capped at 50). Lower it on CPU embedders to cut latency without narrowing candidate_limit (the deterministic retrieval width)"),
2126                "min_score": {
2127                    "type": "number",
2128                    "description": "Minimum score threshold; results below this are dropped (abstention). Default 0.0 (no threshold)",
2129                },
2130                "min_coverage": {
2131                    "type": "number",
2132                    "description": "Minimum query-term coverage ratio (0.0-1.0); results with lower coverage are dropped. E.g. 0.5 requires at least half the query terms to match. Default 0.0 (no threshold)",
2133                },
2134            }),
2135            &["query"],
2136        )
2137    }
2138    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2139        let query = args.get("query").and_then(Value::as_str).unwrap_or("");
2140        let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
2141        let candidate_limit =
2142            args.get("candidate_limit")
2143                .and_then(Value::as_u64)
2144                .unwrap_or_else(|| limit.saturating_mul(2) as u64) as usize;
2145        let include_historical = args
2146            .get("include_historical")
2147            .and_then(Value::as_bool)
2148            .unwrap_or(false);
2149        let rerank = args.get("rerank").and_then(Value::as_bool).unwrap_or(false);
2150        let rerank_alpha = args
2151            .get("rerank_alpha")
2152            .and_then(Value::as_f64)
2153            .unwrap_or(0.7) as f32;
2154        let rerank_pool = args.get("rerank_pool").and_then(Value::as_u64).unwrap_or(0) as usize;
2155        let min_score = args
2156            .get("min_score")
2157            .and_then(Value::as_f64)
2158            .map(|v| v as f32);
2159        let min_coverage = args
2160            .get("min_coverage")
2161            .and_then(Value::as_f64)
2162            .map(|v| v as f32);
2163        // Compute query content-term count for coverage ratio.
2164        // We use a simple split on non-alphanumeric after removing common
2165        // stopwords, matching the episodic search tokenization.
2166        let query_term_count: usize = {
2167            const STOPWORDS: &[&str] = &[
2168                "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has",
2169                "had", "do", "does", "did", "will", "would", "could", "should", "may", "might",
2170                "must", "can", "shall", "to", "of", "in", "on", "at", "by", "for", "with", "about",
2171                "as", "into", "like", "through", "after", "over", "between", "out", "against",
2172                "during", "without", "before", "under", "around", "among", "i", "me", "my", "we",
2173                "us", "our", "you", "your", "he", "him", "his", "she", "her", "it", "its", "they",
2174                "them", "their", "what", "whats", "who", "when", "where", "why", "how", "and",
2175                "or", "but", "not", "no", "nor", "so", "yet", "both", "either", "neither", "this",
2176                "that", "these", "those", "there", "here", "now", "then", "than",
2177            ];
2178            query
2179                .split(|c: char| !c.is_alphanumeric())
2180                .filter(|t| t.len() > 1)
2181                .map(str::to_ascii_lowercase)
2182                .filter(|t| !STOPWORDS.contains(&t.as_str()))
2183                .collect::<std::collections::HashSet<_>>()
2184                .len()
2185        };
2186        let raw_results = if rerank {
2187            self.store.episodic().search_with_rerank(
2188                query,
2189                limit,
2190                candidate_limit,
2191                include_historical,
2192                rerank_alpha,
2193                rerank_pool,
2194            )?
2195        } else {
2196            self.store.episodic().search_with_limits(
2197                query,
2198                limit,
2199                candidate_limit,
2200                include_historical,
2201            )?
2202        };
2203        // Coverage-based abstention: if the query has 3+ content terms and
2204        // NO result matches 2+ terms, all matches are likely on a single
2205        // generic term (e.g. "favorite") rather than the actual topic.
2206        // In that case, abstain entirely. If even one result matches 2+
2207        // terms, keep all results (the query has real matches in the haystack).
2208        // Skip abstention for count-style queries ("how many") since they
2209        // need all candidates for count verification.
2210        let is_count_query = query.to_ascii_lowercase().contains("how many");
2211        let abstain = min_coverage.is_some()
2212            && !is_count_query
2213            && query_term_count >= 3
2214            && !raw_results.iter().any(|hit| hit.matched_terms >= 2);
2215        let visible: Vec<_> = raw_results
2216            .into_iter()
2217            .filter(|hit| !hit.record.is_private && !hit.record.model_exclude)
2218            .filter(|hit| min_score.is_none_or(|ms| hit.score >= ms))
2219            .filter(|_| !abstain)
2220            .take(limit)
2221            .collect();
2222        // Read-time contradiction detection over the visible results only
2223        // (TANGLE semantics: surface both sides with provenance, never
2224        // silently resolve).
2225        let conflicts = detect_conflicts(&visible);
2226        let results = visible
2227            .into_iter()
2228            .map(|hit| {
2229                json!({
2230                    "id": hit.record.id,
2231                    "content": wm_memory::scrub_text(&hit.record.content),
2232                    "score": hit.score,
2233                    "matched_terms": hit.matched_terms,
2234                    "session_id": hit.record.session_id,
2235                    "sequence": hit.record.sequence,
2236                    "created_at": hit.record.created_at,
2237                    "validity": hit.record.validity,
2238                    "provenance": hit.record.provenance,
2239                    "source": "episodic",
2240                })
2241            })
2242            .collect::<Vec<_>>();
2243        Ok(json!({
2244            "status": "success",
2245            "count": results.len(),
2246            // Temporal resolution: true when the query asked for the
2247            // current/latest value and the topic cluster was reordered by
2248            // deterministic chronology (see episodic::resolve_current).
2249            "current_resolution": wm_memory::episodic::is_current_query(query),
2250            // Detected contradictions among the results, when any: both
2251            // statements with provenance; the caller decides (TANGLE).
2252            "conflicts": conflicts,
2253            "results": results,
2254        }))
2255    }
2256    fn stats(&self) -> &ToolStats {
2257        &self.stats
2258    }
2259}
2260
2261/// `memory.aggregate` — post-retrieval aggregation over matching memories.
2262///
2263/// Retrieves memories with a full-text query (same BM25 path as
2264/// `memory.search`) and computes an aggregate over the results. Session
2265/// metrics derive from `session_<n>` tags (a common client convention),
2266/// letting callers answer cross-session synthesis questions like "how long
2267/// from X to Y" without scanning raw results themselves.
2268///
2269/// For span metrics, the anchor set is narrowed to results matching the
2270/// rarest query term (fewest matches, ties broken by query order) so that
2271/// unrelated-but-similar turns (e.g. the same question about a different
2272/// skill) do not distort the span.
2273pub struct MemoryAggregateTool {
2274    search: Option<Arc<SearchEngine>>,
2275    store: Arc<MemoryStore>,
2276    stats: ToolStats,
2277    effects: EffectRow,
2278}
2279
2280impl MemoryAggregateTool {
2281    pub fn new(search: Option<Arc<SearchEngine>>, store: Arc<MemoryStore>) -> Self {
2282        Self {
2283            search,
2284            store,
2285            stats: ToolStats::default(),
2286            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2287        }
2288    }
2289}
2290
2291/// Extract a session ordinal from `session_<n>` tags.
2292fn session_ordinal(tags: &[String]) -> Option<u64> {
2293    tags.iter().find_map(|tag| {
2294        let rest = tag.strip_prefix("session_")?;
2295        rest.parse::<u64>().ok()
2296    })
2297}
2298
2299/// Word-boundary match of a lowercase query term against content (with a
2300/// light suffix-stripped variant for morphological tolerance).
2301fn contains_term(content: &str, term: &str) -> bool {
2302    let lowered = content.to_ascii_lowercase();
2303    let variants = [term.to_string(), strip_suffix(term)];
2304    for variant in &variants {
2305        if variant.len() < 2 {
2306            continue;
2307        }
2308        let mut start = 0;
2309        while let Some(pos) = lowered[start..].find(variant.as_str()) {
2310            let before_ok = pos == 0
2311                || !lowered[start + pos - 1..start + pos]
2312                    .chars()
2313                    .next()
2314                    .is_some_and(char::is_alphanumeric);
2315            let end = start + pos + variant.len();
2316            let after_ok = end >= lowered.len()
2317                || !lowered[end..]
2318                    .chars()
2319                    .next()
2320                    .is_some_and(char::is_alphanumeric);
2321            if before_ok && after_ok {
2322                return true;
2323            }
2324            start += pos + variant.len();
2325        }
2326    }
2327    false
2328}
2329
2330/// Strip a common English suffix for tolerant matching (mirrors the
2331/// simple stemmer used by the search tokenizer).
2332fn strip_suffix(term: &str) -> String {
2333    for suffix in ["ing", "ed", "es", "s"] {
2334        if let Some(stem) = term.strip_suffix(suffix) {
2335            if stem.len() >= 2 {
2336                return stem.to_string();
2337            }
2338        }
2339    }
2340    term.to_string()
2341}
2342
2343#[async_trait]
2344impl Tool for MemoryAggregateTool {
2345    fn name(&self) -> &str {
2346        "memory.aggregate"
2347    }
2348    fn gana(&self) -> Gana {
2349        Gana::WinnowingBasket
2350    }
2351    fn effects(&self) -> &EffectRow {
2352        &self.effects
2353    }
2354    fn description(&self) -> &str {
2355        "Aggregate over memories matching a query: count, distinct session count, or session span (cross-session synthesis)"
2356    }
2357    fn input_schema(&self) -> Value {
2358        schema(
2359            &json!({
2360                "query": str_prop("Full-text query selecting the memories to aggregate over"),
2361                "metric": str_prop("Aggregate metric: count | session_count | session_span"),
2362                "limit": super::common::positive_int_prop("Maximum candidates considered (default 50; must be >= 1)"),
2363            }),
2364            &["query", "metric"],
2365        )
2366    }
2367    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2368        let query = args
2369            .get("query")
2370            .and_then(Value::as_str)
2371            .ok_or_else(|| wm_core::CoreError::InvalidArgs("query (string) required".into()))?;
2372        let metric = args
2373            .get("metric")
2374            .and_then(Value::as_str)
2375            .ok_or_else(|| wm_core::CoreError::InvalidArgs("metric (string) required".into()))?;
2376        let limit = super::common::positive_usize_arg(&args, "limit", 50)
2377            .map_err(wm_core::CoreError::InvalidArgs)?;
2378        let Some(search) = self.search.as_ref() else {
2379            return Err(wm_core::CoreError::Memory(
2380                "search engine unavailable for aggregation".into(),
2381            ));
2382        };
2383
2384        let results = search.search(query, limit)?;
2385        // Load full memories (for tags) and drop non-visible ones.
2386        let mut memories = Vec::new();
2387        for r in &results {
2388            let Some(galaxy) = wm_core::Galaxy::from_db_name(&r.galaxy) else {
2389                continue;
2390            };
2391            let Ok(id) = uuid::Uuid::parse_str(&r.memory_id) else {
2392                continue;
2393            };
2394            let Ok(Some(mem)) = self.store.get(galaxy, id) else {
2395                continue;
2396            };
2397            if super::common::mcp_visible(&mem) && super::common::validity_visible(&mem) {
2398                memories.push((r.score, mem));
2399            }
2400        }
2401
2402        let evidence: Vec<Value> = memories
2403            .iter()
2404            .map(|(score, mem)| {
2405                json!({
2406                    "memory_id": mem.metadata.id.to_string(),
2407                    "score": score,
2408                    "content": wm_memory::scrub_text(&mem.content),
2409                    "tags": mem.metadata.tags,
2410                })
2411            })
2412            .collect();
2413
2414        // Anchor narrowing for session metrics: keep only results matching
2415        // the rarest query term (fewest matches; ties by query order).
2416        // Honesty fallbacks (previously both yielded an empty anchor set,
2417        // reporting session_count 0 / span null DESPITE session-tagged
2418        // evidence): with fewer than 2 session-tagged hits there is nothing
2419        // to disambiguate, so the session-tagged set IS the anchor; when no
2420        // term matches (stopword-only query, vocab mismatch) the same
2421        // fallback applies. The `anchor` disclosure says which ran.
2422        let session_tagged: Vec<_> = memories
2423            .iter()
2424            .filter(|(_, mem)| session_ordinal(&mem.metadata.tags).is_some())
2425            .collect();
2426        let (anchored, anchor): (Vec<_>, &str) = if metric == "count" {
2427            (Vec::new(), "none")
2428        } else if session_tagged.len() < 2 {
2429            (session_tagged.clone(), "session_tagged_fallback")
2430        } else {
2431            let terms: Vec<String> = wm_memory::strip_stopwords(query)
2432                .split(|c: char| !c.is_alphanumeric())
2433                .filter(|t| t.len() > 1)
2434                .map(str::to_ascii_lowercase)
2435                .collect();
2436            let mut best: Option<(String, usize)> = None;
2437            for term in &terms {
2438                let count = session_tagged
2439                    .iter()
2440                    .filter(|(_, mem)| contains_term(&mem.content, term))
2441                    .count();
2442                if count == 0 {
2443                    continue;
2444                }
2445                let better = best
2446                    .as_ref()
2447                    .is_none_or(|(_, best_count)| count < *best_count);
2448                if better {
2449                    best = Some((term.clone(), count));
2450                }
2451            }
2452            match best {
2453                Some((term, _)) => (
2454                    session_tagged
2455                        .iter()
2456                        .filter(|(_, mem)| contains_term(&mem.content, &term))
2457                        .copied()
2458                        .collect(),
2459                    "rarest_term",
2460                ),
2461                None => (session_tagged.clone(), "session_tagged_fallback"),
2462            }
2463        };
2464
2465        let aggregate = match metric {
2466            "count" => json!({
2467                "metric": "count",
2468                "value": memories.len(),
2469                "content": format!("{} memories", memories.len()),
2470            }),
2471            "session_count" => {
2472                let sessions: std::collections::HashSet<u64> = anchored
2473                    .iter()
2474                    .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
2475                    .collect();
2476                json!({
2477                    "metric": "session_count",
2478                    "value": sessions.len(),
2479                    "content": format!("{} distinct sessions", sessions.len()),
2480                })
2481            }
2482            "session_span" => {
2483                let ordinals: Vec<u64> = anchored
2484                    .iter()
2485                    .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
2486                    .collect();
2487                if ordinals.is_empty() {
2488                    json!({
2489                        "metric": "session_span",
2490                        "value": null,
2491                        "content": "no session-tagged evidence found",
2492                    })
2493                } else {
2494                    let span = ordinals.iter().max().unwrap() - ordinals.iter().min().unwrap();
2495                    json!({
2496                        "metric": "session_span",
2497                        "value": span,
2498                        "unit": "sessions",
2499                        "content": format!("{span} sessions"),
2500                    })
2501                }
2502            }
2503            other => {
2504                return Err(wm_core::CoreError::InvalidArgs(format!(
2505                    "unknown metric '{other}' (count | session_count | session_span)"
2506                )));
2507            }
2508        };
2509
2510        Ok(json!({
2511            "status": "success",
2512            "query": query,
2513            "total": memories.len(),
2514            "session_tagged": session_tagged.len(),
2515            "anchor": anchor,
2516            "limit_hit": results.len() >= limit,
2517            "aggregate": aggregate,
2518            "results": evidence,
2519        }))
2520    }
2521    fn stats(&self) -> &ToolStats {
2522        &self.stats
2523    }
2524}
2525
2526/// `memory.sort` — sort memories by importance, recency, or access count.
2527pub struct MemorySortTool {
2528    store: Arc<MemoryStore>,
2529    stats: ToolStats,
2530    effects: EffectRow,
2531}
2532
2533impl MemorySortTool {
2534    pub fn new(store: Arc<MemoryStore>) -> Self {
2535        Self {
2536            store,
2537            stats: ToolStats::default(),
2538            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2539        }
2540    }
2541}
2542
2543#[async_trait]
2544impl Tool for MemorySortTool {
2545    fn input_schema(&self) -> Value {
2546        schema(
2547            &json!({
2548                "galaxy": super::common::str_prop("Galaxy to sort (optional; default codex)"),
2549                "sort_by": super::common::str_prop("Sort key: importance | created_at | accessed_at | access_count"),
2550                "order": super::common::str_prop("Order: asc | desc (default desc)"),
2551                "limit": super::common::int_prop("Maximum entries (default 50)"),
2552            }),
2553            &[],
2554        )
2555    }
2556    fn name(&self) -> &str {
2557        "memory.sort"
2558    }
2559    fn gana(&self) -> Gana {
2560        Gana::WinnowingBasket
2561    }
2562    fn effects(&self) -> &EffectRow {
2563        &self.effects
2564    }
2565    fn description(&self) -> &str {
2566        "Sort memories by importance, recency, or access count"
2567    }
2568    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2569        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2570        let sort_by = args
2571            .get("sort_by")
2572            .and_then(|v| v.as_str())
2573            .unwrap_or("importance");
2574        let order = args.get("order").and_then(|v| v.as_str()).unwrap_or("desc");
2575        let limit = args
2576            .get("limit")
2577            .and_then(serde_json::Value::as_u64)
2578            .unwrap_or(50) as usize;
2579
2580        let mut memories = self.store.scan(galaxy, 10_000)?;
2581        // Private memories never appear in MCP responses. Non-current
2582        // validity likewise hides while enforced (Slice B, off by default).
2583        memories.retain(|m| {
2584            crate::expansion::common::mcp_visible(m)
2585                && crate::expansion::common::validity_visible(m)
2586        });
2587
2588        match sort_by {
2589            "importance" => memories.sort_by(|a, b| {
2590                b.metadata
2591                    .importance
2592                    .partial_cmp(&a.metadata.importance)
2593                    .unwrap_or(std::cmp::Ordering::Equal)
2594            }),
2595            "recency" => memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.created_at)),
2596            "accessed" => {
2597                memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.accessed_at));
2598            }
2599            "access_count" => {
2600                memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.access_count));
2601            }
2602            _ => {
2603                return Err(wm_core::CoreError::InvalidArgs(format!(
2604                    "Unknown sort_by: '{sort_by}'. Use importance, recency, accessed, or access_count"
2605                )));
2606            }
2607        }
2608
2609        if order == "asc" {
2610            memories.reverse();
2611        }
2612
2613        let total = memories.len();
2614        memories.truncate(limit);
2615
2616        let results: Vec<Value> = memories
2617            .iter()
2618            .map(|m| {
2619                json!({
2620                    "id": m.metadata.id,
2621                    "content": &m.content,
2622                    "importance": m.metadata.importance,
2623                    "created_at": m.metadata.created_at.to_rfc3339(),
2624                    "accessed_at": m.metadata.accessed_at.to_rfc3339(),
2625                    "access_count": m.metadata.access_count,
2626                    "tags": &m.metadata.tags,
2627                })
2628            })
2629            .collect();
2630
2631        Ok(json!({
2632            "status": "success",
2633            "galaxy": galaxy_name(galaxy),
2634            "sort_by": sort_by,
2635            "order": order,
2636            "total": total,
2637            "returned": results.len(),
2638            "memories": results,
2639        }))
2640    }
2641    fn stats(&self) -> &ToolStats {
2642        &self.stats
2643    }
2644}
2645
2646/// `memory.filter` — filter memories by tags, date range, importance.
2647pub struct MemoryFilterTool {
2648    store: Arc<MemoryStore>,
2649    stats: ToolStats,
2650    effects: EffectRow,
2651}
2652
2653impl MemoryFilterTool {
2654    pub fn new(store: Arc<MemoryStore>) -> Self {
2655        Self {
2656            store,
2657            stats: ToolStats::default(),
2658            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2659        }
2660    }
2661}
2662
2663#[async_trait]
2664impl Tool for MemoryFilterTool {
2665    fn name(&self) -> &str {
2666        "memory.filter"
2667    }
2668    fn gana(&self) -> Gana {
2669        Gana::WinnowingBasket
2670    }
2671    fn effects(&self) -> &EffectRow {
2672        &self.effects
2673    }
2674    fn description(&self) -> &str {
2675        "Filter memories by tags, date range, importance thresholds, and a content query substring"
2676    }
2677    fn input_schema(&self) -> Value {
2678        super::common::schema(
2679            &json!({
2680                "galaxy": super::common::str_prop("Galaxy to filter (default codex)"),
2681                "tags": super::common::str_array_prop("Filter: memories with all of these tags"),
2682                "exclude_tags": super::common::str_array_prop("Filter: drop memories carrying any of these tags"),
2683                "min_importance": super::common::num_prop("Filter: minimum importance (0-1)"),
2684                "max_importance": super::common::num_prop("Filter: maximum importance (0-1)"),
2685                "query": super::common::str_prop("Filter: every whitespace-separated term must appear (case-insensitive) in the content or title"),
2686                "created_after": super::common::str_prop("Filter: only memories created at or after this RFC 3339 timestamp (e.g. 2026-08-01T00:00:00Z)"),
2687                "created_before": super::common::str_prop("Filter: only memories created at or before this RFC 3339 timestamp"),
2688                "limit": super::common::int_prop("Maximum entries (default 50)"),
2689                "offset": super::common::int_prop("Skip this many matching entries before returning (default 0)"),
2690            }),
2691            &[],
2692        )
2693    }
2694    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2695        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2696        let tags: Vec<String> = args
2697            .get("tags")
2698            .and_then(|v| v.as_array())
2699            .map(|arr| {
2700                arr.iter()
2701                    .filter_map(|t| t.as_str().map(String::from))
2702                    .collect()
2703            })
2704            .unwrap_or_default();
2705        let exclude_tags: Vec<String> = args
2706            .get("exclude_tags")
2707            .and_then(|v| v.as_array())
2708            .map(|arr| {
2709                arr.iter()
2710                    .filter_map(|t| t.as_str().map(String::from))
2711                    .collect()
2712            })
2713            .unwrap_or_default();
2714        let min_importance = args
2715            .get("min_importance")
2716            .and_then(serde_json::Value::as_f64)
2717            .unwrap_or(0.0) as f32;
2718        let max_importance = args
2719            .get("max_importance")
2720            .and_then(serde_json::Value::as_f64)
2721            .unwrap_or(1.0) as f32;
2722        let limit = args
2723            .get("limit")
2724            .and_then(serde_json::Value::as_u64)
2725            .unwrap_or(50) as usize;
2726        let offset = args
2727            .get("offset")
2728            .and_then(serde_json::Value::as_u64)
2729            .unwrap_or(0) as usize;
2730        // Date range — promised by the description, previously ignored.
2731        // Bounds are inclusive RFC 3339 timestamps (e.g. "2026-08-01T00:00:00Z").
2732        let parse_bound = |name: &str| -> wm_core::Result<Option<chrono::DateTime<chrono::Utc>>> {
2733            match args.get(name).and_then(|v| v.as_str()) {
2734                Some(s) if !s.trim().is_empty() => chrono::DateTime::parse_from_rfc3339(s.trim())
2735                    .map(|t| Some(t.with_timezone(&chrono::Utc)))
2736                    .map_err(|_| {
2737                        wm_core::CoreError::InvalidArgs(format!(
2738                            "{name} must be an RFC 3339 timestamp (e.g. \"2026-08-01T00:00:00Z\"), got: {s}"
2739                        ))
2740                    }),
2741                _ => Ok(None),
2742            }
2743        };
2744        let created_after = parse_bound("created_after")?;
2745        let created_before = parse_bound("created_before")?;
2746        // Content query — previously accepted-and-ignored (the arg was
2747        // silently dropped). Every term must appear case-insensitively in
2748        // the content or title; empty/absent means no content filtering.
2749        let query_terms: Vec<String> = args
2750            .get("query")
2751            .and_then(|v| v.as_str())
2752            .map(|q| q.split_whitespace().map(str::to_lowercase).collect())
2753            .unwrap_or_default();
2754
2755        let memories = self.store.scan(galaxy, 10_000)?;
2756
2757        let matched: Vec<&wm_memory::Memory> = memories
2758            .iter()
2759            .filter(|m| {
2760                // Private memories never appear in MCP responses.
2761                if !crate::expansion::common::mcp_visible(m) {
2762                    return false;
2763                }
2764                // Non-current validity hides while enforced (Slice B).
2765                if !crate::expansion::common::validity_visible(m) {
2766                    return false;
2767                }
2768                if m.metadata.importance < min_importance || m.metadata.importance > max_importance
2769                {
2770                    return false;
2771                }
2772                if !tags.is_empty() && !tags.iter().all(|t| m.metadata.tags.contains(t)) {
2773                    return false;
2774                }
2775                if exclude_tags
2776                    .iter()
2777                    .any(|t| m.metadata.tags.iter().any(|mt| mt == t))
2778                {
2779                    return false;
2780                }
2781                if let Some(after) = created_after {
2782                    if m.metadata.created_at < after {
2783                        return false;
2784                    }
2785                }
2786                if let Some(before) = created_before {
2787                    if m.metadata.created_at > before {
2788                        return false;
2789                    }
2790                }
2791                if !query_terms.is_empty() {
2792                    let haystack = match &m.metadata.title {
2793                        Some(title) => format!("{}\n{}", m.content, title).to_lowercase(),
2794                        None => m.content.to_lowercase(),
2795                    };
2796                    if !query_terms.iter().all(|t| haystack.contains(t)) {
2797                        return false;
2798                    }
2799                }
2800                true
2801            })
2802            .collect();
2803        // Page AFTER filtering: offset/limit address the visible surface.
2804        let filtered: Vec<&&wm_memory::Memory> = matched.iter().skip(offset).take(limit).collect();
2805
2806        let total_scanned = memories.len();
2807        let results: Vec<Value> = filtered
2808            .iter()
2809            .map(|m| {
2810                json!({
2811                    "id": m.metadata.id,
2812                    "content": &m.content,
2813                    "importance": m.metadata.importance,
2814                    "tags": &m.metadata.tags,
2815                    "created_at": m.metadata.created_at.to_rfc3339(),
2816                })
2817            })
2818            .collect();
2819
2820        Ok(json!({
2821            "status": "success",
2822            "galaxy": galaxy_name(galaxy),
2823            "scanned": total_scanned,
2824            "matched": matched.len(),
2825            "offset": offset,
2826            "returned": results.len(),
2827            "filters": {
2828                "tags": tags,
2829                "exclude_tags": exclude_tags,
2830                "min_importance": min_importance,
2831                "max_importance": max_importance,
2832                "query_terms": query_terms,
2833                "created_after": created_after.map(|t| t.to_rfc3339()),
2834                "created_before": created_before.map(|t| t.to_rfc3339()),
2835            },
2836            "memories": results,
2837        }))
2838    }
2839    fn stats(&self) -> &ToolStats {
2840        &self.stats
2841    }
2842}
2843
2844/// `memory.deduplicate` — find and merge duplicate memories by content similarity.
2845pub struct MemoryDeduplicateTool {
2846    store: Arc<MemoryStore>,
2847    search: Option<Arc<SearchEngine>>,
2848    stats: ToolStats,
2849    effects: EffectRow,
2850}
2851
2852impl MemoryDeduplicateTool {
2853    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
2854        Self {
2855            store,
2856            search,
2857            stats: ToolStats::default(),
2858            effects: EffectRow {
2859                writes: super::common::memory_galaxy_writes(),
2860                reads: super::common::memory_galaxy_reads(),
2861                destructive: true,
2862                ..Default::default()
2863            },
2864        }
2865    }
2866}
2867
2868#[async_trait]
2869impl Tool for MemoryDeduplicateTool {
2870    fn name(&self) -> &str {
2871        "memory.deduplicate"
2872    }
2873    fn gana(&self) -> Gana {
2874        Gana::WinnowingBasket
2875    }
2876    fn effects(&self) -> &EffectRow {
2877        &self.effects
2878    }
2879    fn description(&self) -> &str {
2880        "Find and merge duplicate memories by content hash or similarity"
2881    }
2882    fn input_schema(&self) -> Value {
2883        super::common::schema(
2884            &json!({
2885                "galaxy": super::common::str_prop("Galaxy to deduplicate"),
2886                "mode": super::common::str_prop("Strategy: hash | similarity (default: hash)"),
2887                "limit": super::common::int_prop("Maximum entries to scan"),
2888                "dry_run": super::common::bool_prop("Preview only (default: true)"),
2889            }),
2890            &["galaxy"],
2891        )
2892    }
2893    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2894        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2895        let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("hash");
2896        let dry_run = args
2897            .get("dry_run")
2898            .and_then(serde_json::Value::as_bool)
2899            .unwrap_or(true);
2900        let limit = args
2901            .get("limit")
2902            .and_then(serde_json::Value::as_u64)
2903            .unwrap_or(10_000) as usize;
2904
2905        let memories = self.store.scan(galaxy, limit)?;
2906
2907        match mode {
2908            "hash" => {
2909                let mut seen_hashes: HashMap<String, uuid::Uuid> = HashMap::new();
2910                let mut duplicates: Vec<Value> = Vec::new();
2911
2912                for mem in &memories {
2913                    let hash = &mem.metadata.content_hash;
2914                    if let Some(existing_id) = seen_hashes.get(hash) {
2915                        if *existing_id != mem.metadata.id {
2916                            duplicates.push(json!({
2917                                "id": mem.metadata.id,
2918                                "duplicate_of": existing_id,
2919                                "content_preview": mem.content.chars().take(100).collect::<String>(),
2920                                "importance": mem.metadata.importance,
2921                            }));
2922                            if !dry_run {
2923                                self.store.delete(galaxy, mem.metadata.id)?;
2924                                super::common::deindex(
2925                                    self.search.as_deref(),
2926                                    &mem.metadata.id.to_string(),
2927                                );
2928                            }
2929                        }
2930                    } else {
2931                        seen_hashes.insert(hash.clone(), mem.metadata.id);
2932                    }
2933                }
2934
2935                let removed = if dry_run { 0 } else { duplicates.len() };
2936
2937                Ok(json!({
2938                    "status": "success",
2939                    "galaxy": galaxy_name(galaxy),
2940                    "mode": mode,
2941                    "dry_run": dry_run,
2942                    "scanned": memories.len(),
2943                    "duplicates_found": duplicates.len(),
2944                    "removed": removed,
2945                    "duplicates": duplicates,
2946                }))
2947            }
2948            "content" => {
2949                let mut duplicates: Vec<Value> = Vec::new();
2950                let mut removed_count = 0u32;
2951
2952                for i in 0..memories.len() {
2953                    for j in (i + 1)..memories.len() {
2954                        if memories[i].content == memories[j].content {
2955                            duplicates.push(json!({
2956                                "id": memories[j].metadata.id,
2957                                "duplicate_of": memories[i].metadata.id,
2958                                "content_preview": memories[j].content.chars().take(100).collect::<String>(),
2959                            }));
2960                            if !dry_run {
2961                                self.store.delete(galaxy, memories[j].metadata.id)?;
2962                                super::common::deindex(
2963                                    self.search.as_deref(),
2964                                    &memories[j].metadata.id.to_string(),
2965                                );
2966                                removed_count += 1;
2967                            }
2968                            break;
2969                        }
2970                    }
2971                }
2972
2973                Ok(json!({
2974                    "status": "success",
2975                    "galaxy": galaxy_name(galaxy),
2976                    "mode": mode,
2977                    "dry_run": dry_run,
2978                    "scanned": memories.len(),
2979                    "duplicates_found": duplicates.len(),
2980                    "removed": removed_count,
2981                    "duplicates": duplicates,
2982                }))
2983            }
2984            _ => Err(wm_core::CoreError::InvalidArgs(format!(
2985                "Unknown mode: '{mode}'. Use 'hash' or 'content'"
2986            ))),
2987        }
2988    }
2989    fn stats(&self) -> &ToolStats {
2990        &self.stats
2991    }
2992}
2993
2994/// `memory.export` — export memories in JSON, CSV, or Markdown format.
2995pub struct MemoryExportTool {
2996    store: Arc<MemoryStore>,
2997    stats: ToolStats,
2998    effects: EffectRow,
2999}
3000
3001impl MemoryExportTool {
3002    pub fn new(store: Arc<MemoryStore>) -> Self {
3003        Self {
3004            store,
3005            stats: ToolStats::default(),
3006            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
3007        }
3008    }
3009}
3010
3011#[async_trait]
3012impl Tool for MemoryExportTool {
3013    fn input_schema(&self) -> Value {
3014        schema(
3015            &json!({
3016                "galaxy": super::common::str_prop("Galaxy to export (optional; default codex)"),
3017                "format": super::common::str_prop("Export format: json | jsonl | markdown"),
3018                "limit": super::common::int_prop("Maximum entries to export"),
3019            }),
3020            &[],
3021        )
3022    }
3023    fn name(&self) -> &str {
3024        "memory.export"
3025    }
3026    fn gana(&self) -> Gana {
3027        Gana::WinnowingBasket
3028    }
3029    fn effects(&self) -> &EffectRow {
3030        &self.effects
3031    }
3032    fn description(&self) -> &str {
3033        "Export memories in JSON, CSV, or Markdown format"
3034    }
3035    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
3036        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
3037        let format = args
3038            .get("format")
3039            .and_then(|v| v.as_str())
3040            .unwrap_or("json");
3041        let limit = args
3042            .get("limit")
3043            .and_then(serde_json::Value::as_u64)
3044            .unwrap_or(1000) as usize;
3045
3046        let memories = self.store.scan(galaxy, limit)?;
3047
3048        let exported = match format {
3049            "json" => {
3050                let entries: Vec<Value> = memories
3051                    .iter()
3052                    .map(|m| {
3053                        json!({
3054                            "id": m.metadata.id,
3055                            "content": &m.content,
3056                            "tags": &m.metadata.tags,
3057                            "importance": m.metadata.importance,
3058                            "created_at": m.metadata.created_at.to_rfc3339(),
3059                            "access_count": m.metadata.access_count,
3060                        })
3061                    })
3062                    .collect();
3063                serde_json::to_string_pretty(&entries).unwrap_or_default()
3064            }
3065            "csv" => {
3066                let mut csv = String::from("id,content,tags,importance,created_at,access_count\n");
3067                for m in &memories {
3068                    let tags = m.metadata.tags.join(";");
3069                    let content = m.content.replace('\n', " ").replace('"', "'");
3070                    let _ = writeln!(
3071                        csv,
3072                        "{},{},{},{:.3},{},{}",
3073                        m.metadata.id,
3074                        content,
3075                        tags,
3076                        m.metadata.importance,
3077                        m.metadata.created_at.to_rfc3339(),
3078                        m.metadata.access_count,
3079                    );
3080                }
3081                csv
3082            }
3083            "markdown" => {
3084                let mut md = format!("# Memory Export: {}\n\n", galaxy_name(galaxy));
3085                let _ = write!(md, "Total memories: {}\n\n", memories.len());
3086                for m in &memories {
3087                    let _ = write!(
3088                        md,
3089                        "## {}\n\n- **Importance**: {:.2}\n- **Tags**: {}\n- **Created**: {}\n- **Access Count**: {}\n\n{}\n\n---\n\n",
3090                        m.metadata.id,
3091                        m.metadata.importance,
3092                        m.metadata.tags.join(", "),
3093                        m.metadata.created_at.to_rfc3339(),
3094                        m.metadata.access_count,
3095                        m.content,
3096                    );
3097                }
3098                md
3099            }
3100            _ => {
3101                return Err(wm_core::CoreError::InvalidArgs(format!(
3102                    "Unknown format: '{format}'. Use json, csv, or markdown"
3103                )));
3104            }
3105        };
3106
3107        Ok(json!({
3108            "status": "success",
3109            "galaxy": galaxy_name(galaxy),
3110            "format": format,
3111            "count": memories.len(),
3112            "export": exported,
3113        }))
3114    }
3115    fn stats(&self) -> &ToolStats {
3116        &self.stats
3117    }
3118}
3119
3120#[cfg(test)]
3121mod tests {
3122    use super::*;
3123    use wm_core::{EpisodicKind, EpisodicRecord, Galaxy, Provenance, ProvenanceSource};
3124    use wm_memory::{Association, AssociationStore, LinkType, Memory, MemoryStore};
3125
3126    fn test_store() -> Arc<MemoryStore> {
3127        let dir = tempfile::tempdir().unwrap();
3128        Arc::new(MemoryStore::open_default(dir.path()).unwrap())
3129    }
3130
3131    fn populate_memories(store: &Arc<MemoryStore>, galaxy: Galaxy) {
3132        let mut m1 = Memory::new(galaxy, "First memory about rust".into());
3133        m1.metadata.importance = 0.9;
3134        m1.metadata.tags = vec!["rust".into(), "programming".into()];
3135        let _ = store.put(galaxy, &m1);
3136
3137        let mut m2 = Memory::new(galaxy, "Second memory about python".into());
3138        m2.metadata.importance = 0.5;
3139        m2.metadata.tags = vec!["python".into()];
3140        let _ = store.put(galaxy, &m2);
3141
3142        let mut m3 = Memory::new(galaxy, "Third memory about rust".into());
3143        m3.metadata.importance = 0.3;
3144        m3.metadata.tags = vec!["rust".into(), "tutorial".into()];
3145        let _ = store.put(galaxy, &m3);
3146    }
3147
3148    #[tokio::test]
3149    async fn episodic_search_filters_private_records() {
3150        let store = test_store();
3151        let public = EpisodicRecord::new(
3152            None,
3153            1,
3154            EpisodicKind::Observation,
3155            "public retrieval evidence",
3156            Provenance::new(ProvenanceSource::User),
3157        );
3158        let private = EpisodicRecord::new(
3159            None,
3160            2,
3161            EpisodicKind::Observation,
3162            "private retrieval evidence",
3163            Provenance::new(ProvenanceSource::User),
3164        )
3165        .with_visibility(true, false);
3166        store.episodic().append(&public).unwrap();
3167        store.episodic().append(&private).unwrap();
3168
3169        let tool = MemoryEpisodicSearchTool::new(store);
3170        let mut ctx = Context::default();
3171        let result = tool
3172            .call(
3173                &mut ctx,
3174                json!({"query": "retrieval evidence", "limit": 10}),
3175            )
3176            .await
3177            .unwrap();
3178        assert_eq!(result["count"], 1);
3179        assert_eq!(result["results"][0]["id"], json!(public.id));
3180    }
3181
3182    /// Mirror a v5 memory into the episodic lane exactly like the write
3183    /// path does (capture_explicit_memory): record id = memory id.
3184    fn mirror_memory(
3185        store: &Arc<MemoryStore>,
3186        mem: &Memory,
3187        session: Option<uuid::Uuid>,
3188        sequence: u64,
3189    ) {
3190        use wm_core::EpisodicCapturePolicy;
3191        let record = EpisodicRecord::new(
3192            session,
3193            sequence,
3194            EpisodicKind::Observation,
3195            mem.content.clone(),
3196            Provenance::new(ProvenanceSource::User),
3197        )
3198        .with_id(mem.metadata.id)
3199        .with_visibility(mem.metadata.is_private, mem.metadata.model_exclude);
3200        store
3201            .episodic()
3202            .append_explicit(&record, EpisodicCapturePolicy::explicit_only())
3203            .unwrap();
3204    }
3205
3206    fn default_search_tool(
3207        store: Arc<MemoryStore>,
3208        search: Option<Arc<SearchEngine>>,
3209    ) -> MemoryHybridRecallTool {
3210        MemoryHybridRecallTool::as_search(store, search, None)
3211    }
3212
3213    #[tokio::test]
3214    async fn default_route_prefers_episodic_and_discloses_mode() {
3215        // V8 ship list #1: with no real embedder, memory.search must route
3216        // through the episodic deterministic machinery by default and
3217        // disclose `recall_mode: episodic` + per-result `source`.
3218        let (_dir, store, search) = hybrid_fixture();
3219        let needle = Memory::new(
3220            Galaxy::Codex,
3221            "Kotlin coroutine budget meeting notes".into(),
3222        );
3223        let needle_id = needle.metadata.id;
3224        let other = Memory::new(Galaxy::Codex, "Grocery list eggs and flour".into());
3225        store.put(Galaxy::Codex, &needle).unwrap();
3226        store.put(Galaxy::Codex, &other).unwrap();
3227        mirror_memory(&store, &needle, None, 1);
3228        mirror_memory(&store, &other, None, 2);
3229
3230        let tool = default_search_tool(store.clone(), Some(search));
3231        let mut ctx = Context::default();
3232        let v = tool
3233            .call(
3234                &mut ctx,
3235                json!({"query": "kotlin coroutine budget", "limit": 10}),
3236            )
3237            .await
3238            .unwrap();
3239        assert_eq!(v["recall_mode"], "episodic");
3240        assert_eq!(v["results"][0]["source"], "episodic");
3241        assert_eq!(v["results"][0]["id"], json!(needle_id.to_string()));
3242        assert!(v["results"][0]["score"].as_f64().unwrap() > 0.0);
3243    }
3244
3245    #[test]
3246    fn fused_mode_label_follows_the_configured_vector_weight() {
3247        // F-T0-2: the label follows the weights, not the code path.
3248        assert_eq!(fused_mode_label(0.3), "hybrid");
3249        assert_eq!(fused_mode_label(1.0), "hybrid");
3250        assert_eq!(fused_mode_label(0.0), "bm25");
3251    }
3252
3253    #[tokio::test]
3254    async fn zero_vector_weight_discloses_bm25_not_hybrid() {
3255        // T0 finding F-T0-2: `bm25-baseline` ran the fusion path with the
3256        // vector/importance weights zeroed (ranking = BM25) yet disclosed
3257        // `recall_mode: hybrid` on every result. The disclosure must say
3258        // bm25 for that configuration.
3259        let (_dir, store, search) = hybrid_fixture();
3260        let needle = Memory::new(
3261            Galaxy::Codex,
3262            "Kotlin coroutine budget meeting notes".into(),
3263        );
3264        let needle_id = needle.metadata.id;
3265        store.put(Galaxy::Codex, &needle).unwrap();
3266        wm_memory::reindex::rebuild_index(&store, &search, &[]).unwrap();
3267
3268        // Any embed call would be a regression: the vector half is inert
3269        // under this config, so the tool path must answer from BM25 alone
3270        // (F-T0-2 follow-up — before the fast path this config still paid
3271        // the query embed).
3272        struct NoEmbedEmbedder;
3273        impl wm_memory::Embedder for NoEmbedEmbedder {
3274            fn embed_batch(&self, _texts: &[&str]) -> wm_core::Result<Vec<Vec<f32>>> {
3275                panic!("zeroed vector weight must not call the embedder");
3276            }
3277            fn dimension(&self) -> usize {
3278                16
3279            }
3280            fn is_available(&self) -> bool {
3281                true
3282            }
3283            fn backend_name(&self) -> &'static str {
3284                "no-embed-test"
3285            }
3286        }
3287
3288        let config = wm_memory::RecallConfig {
3289            bm25_weight: 1.0,
3290            vector_weight: 0.0,
3291            importance_weight: 0.0,
3292            ..wm_memory::RecallConfig::default()
3293        };
3294        let recall = Arc::new(
3295            RecallEngine::new(
3296                store.clone(),
3297                search.clone(),
3298                wm_memory::VectorStore::new(),
3299                Arc::new(NoEmbedEmbedder),
3300                config,
3301            )
3302            .unwrap(),
3303        );
3304        let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), Some(recall));
3305        let mut ctx = Context::default();
3306        let v = tool
3307            .call(
3308                &mut ctx,
3309                json!({"query": "kotlin coroutine budget", "limit": 10}),
3310            )
3311            .await
3312            .unwrap();
3313        assert_eq!(v["recall_mode"], "bm25", "{v}");
3314        assert_eq!(v["results"][0]["source"], "bm25", "{v}");
3315        assert_eq!(v["results"][0]["id"], json!(needle_id.to_string()));
3316    }
3317
3318    #[tokio::test]
3319    async fn search_rejects_non_positive_limit() {
3320        // 2026-09-19 review: limit 0 passed through to Tantivy and panicked
3321        // the server process (exit 101). The boundary must reject it —
3322        // zero, negative, and non-integer forms alike — and the boundary
3323        // value 1 must still work.
3324        let (_dir, store, search) = hybrid_fixture();
3325        index_memory(&store, &search, Galaxy::Codex, "kotlin coroutine budget");
3326        let tool = MemoryHybridRecallTool::as_search(store, Some(search), None);
3327        let mut ctx = Context::default();
3328        for bad in [json!(0), json!(-1), json!("0"), json!(0.5)] {
3329            let err = tool
3330                .call(&mut ctx, json!({"query": "kotlin", "limit": bad}))
3331                .await
3332                .unwrap_err();
3333            assert!(
3334                err.to_string().contains("limit"),
3335                "limit={bad} must be rejected, got: {err}"
3336            );
3337        }
3338        let v = tool
3339            .call(&mut ctx, json!({"query": "kotlin", "limit": 1}))
3340            .await
3341            .unwrap();
3342        assert_eq!(v["status"], "success", "{v}");
3343        assert_eq!(v["results"].as_array().map(Vec::len), Some(1), "{v}");
3344    }
3345
3346    #[tokio::test]
3347    async fn default_route_matches_the_episodic_machinery_ranking() {
3348        // The default route must BE the episodic machinery, not a lookalike:
3349        // same corpus, same query, first result identical to
3350        // memory.episodic_search's top hit.
3351        let (_dir, store, search) = hybrid_fixture();
3352        let contents = [
3353            "Deployed the telemetry agent on Tuesday",
3354            "Cancun trip booked for the twelfth",
3355            "Telemetry agent rollout postponed to Friday",
3356            "Deadline for the quarterly report moved",
3357        ];
3358        let mut memories: Vec<(uuid::Uuid, &str)> = Vec::new();
3359        for (i, content) in contents.iter().enumerate() {
3360            let mem = Memory::new(Galaxy::Codex, (*content).to_string());
3361            memories.push((mem.metadata.id, content));
3362            store.put(Galaxy::Codex, &mem).unwrap();
3363            mirror_memory(&store, &mem, None, i as u64 + 1);
3364        }
3365        let query = "when was the telemetry agent deployed";
3366
3367        let default_tool = default_search_tool(store.clone(), Some(search.clone()));
3368        let mut ctx = Context::default();
3369        let default_v = default_tool
3370            .call(&mut ctx, json!({"query": query, "limit": 10}))
3371            .await
3372            .unwrap();
3373        let episodic_tool = MemoryEpisodicSearchTool::new(store);
3374        let episodic_v = episodic_tool
3375            .call(&mut ctx, json!({"query": query, "limit": 10}))
3376            .await
3377            .unwrap();
3378        assert_eq!(
3379            default_v["results"][0]["id"], episodic_v["results"][0]["id"],
3380            "default route must rank exactly like the episodic machinery"
3381        );
3382        let top = memories
3383            .iter()
3384            .find(|(id, _)| id.to_string() == default_v["results"][0]["id"])
3385            .map(|(_, c)| *c)
3386            .unwrap();
3387        assert_eq!(top, "Deployed the telemetry agent on Tuesday");
3388    }
3389
3390    #[tokio::test]
3391    async fn default_route_falls_back_to_fts_when_episodic_yields_nothing() {
3392        // Legacy store shape: memories indexed but the episodic lane never
3393        // populated. The default route must disclose `fts` honestly.
3394        let (_dir, store, search) = hybrid_fixture();
3395        let mem = Memory::new(Galaxy::Codex, "Zebra quotas revised upward".into());
3396        let id = mem.metadata.id;
3397        store.put(Galaxy::Codex, &mem).unwrap();
3398        {
3399            let mut writer = search.writer().unwrap();
3400            search
3401                .add_document(
3402                    &mut writer,
3403                    &id.to_string(),
3404                    "codex",
3405                    "Zebra quotas revised upward",
3406                    &mem.metadata.tags,
3407                    mem.metadata.created_at.timestamp(),
3408                )
3409                .unwrap();
3410            search.commit(&mut writer).unwrap();
3411        }
3412
3413        let tool = default_search_tool(store, Some(search));
3414        let mut ctx = Context::default();
3415        let v = tool
3416            .call(&mut ctx, json!({"query": "zebra quotas", "limit": 10}))
3417            .await
3418            .unwrap();
3419        assert_eq!(v["recall_mode"], "fts");
3420        assert_eq!(v["results"][0]["source"], "fts");
3421        assert_eq!(v["results"][0]["id"], json!(id.to_string()));
3422    }
3423
3424    #[tokio::test]
3425    async fn episodic_default_route_respects_galaxy_filter() {
3426        let (_dir, store, search) = hybrid_fixture();
3427        let in_galaxy = Memory::new(Galaxy::Codex, "Marble fountain restoration plan".into());
3428        let other_galaxy =
3429            Memory::new(Galaxy::Sessions, "Marble fountain restoration notes".into());
3430        store.put(Galaxy::Codex, &in_galaxy).unwrap();
3431        store.put(Galaxy::Sessions, &other_galaxy).unwrap();
3432        mirror_memory(&store, &in_galaxy, None, 1);
3433        mirror_memory(&store, &other_galaxy, None, 2);
3434
3435        let tool = default_search_tool(store, Some(search));
3436        let mut ctx = Context::default();
3437        let v = tool
3438            .call(
3439                &mut ctx,
3440                json!({"query": "marble fountain", "galaxy": "sessions", "limit": 10}),
3441            )
3442            .await
3443            .unwrap();
3444        assert_eq!(v["recall_mode"], "episodic");
3445        for r in v["results"].as_array().unwrap() {
3446            assert_eq!(r["galaxy"], "sessions", "galaxy filter must hold");
3447        }
3448        assert_eq!(
3449            v["results"][0]["id"],
3450            json!(other_galaxy.metadata.id.to_string())
3451        );
3452    }
3453
3454    #[tokio::test]
3455    async fn episodic_default_route_filters_private_and_stale() {
3456        let (_dir, store, search) = hybrid_fixture();
3457        let public = Memory::new(
3458            Galaxy::Codex,
3459            "Lighthouse maintenance schedule confirmed".into(),
3460        );
3461        let mut private = Memory::new(Galaxy::Codex, "Lighthouse access code renewal".into());
3462        private.metadata.is_private = true;
3463        let stale = Memory::new(Galaxy::Codex, "Lighthouse inspection legacy draft".into());
3464        let stale_id = stale.metadata.id;
3465        store.put(Galaxy::Codex, &public).unwrap();
3466        store.put(Galaxy::Codex, &private).unwrap();
3467        store.put(Galaxy::Codex, &stale).unwrap();
3468        mirror_memory(&store, &public, None, 1);
3469        mirror_memory(&store, &private, None, 2);
3470        mirror_memory(&store, &stale, None, 3);
3471        // The stale record's v5 memory is gone — the mirror survived, the
3472        // source of truth did not.
3473        store.delete(Galaxy::Codex, stale_id).unwrap();
3474
3475        let tool = default_search_tool(store, Some(search));
3476        let mut ctx = Context::default();
3477        let v = tool
3478            .call(&mut ctx, json!({"query": "lighthouse", "limit": 10}))
3479            .await
3480            .unwrap();
3481        assert_eq!(v["recall_mode"], "episodic");
3482        let ids: Vec<&str> = v["results"]
3483            .as_array()
3484            .unwrap()
3485            .iter()
3486            .filter_map(|r| r["id"].as_str())
3487            .collect();
3488        assert!(
3489            !ids.contains(&private.metadata.id.to_string().as_str()),
3490            "private memories must never surface on the default route"
3491        );
3492        assert!(
3493            !ids.contains(&stale_id.to_string().as_str()),
3494            "episodic records without a live v5 memory must be skipped"
3495        );
3496        assert!(!ids.is_empty(), "the public hit must still surface");
3497    }
3498
3499    #[tokio::test]
3500    async fn memory_sort_by_importance_desc() {
3501        let store = test_store();
3502        populate_memories(&store, Galaxy::Codex);
3503        let tool = MemorySortTool::new(store);
3504        let mut ctx = Context::default();
3505        let v = tool
3506            .call(&mut ctx, json!({"sort_by": "importance", "order": "desc"}))
3507            .await
3508            .unwrap();
3509        assert_eq!(v["status"], "success");
3510        assert_eq!(v["returned"], 3);
3511        let mems = v["memories"].as_array().unwrap();
3512        assert!(mems[0]["importance"].as_f64().unwrap() >= mems[1]["importance"].as_f64().unwrap());
3513    }
3514
3515    #[tokio::test]
3516    async fn memory_update_cannot_mutate_tier() {
3517        // S5 phase 2: tier moves are dream-cycle-ONLY. The update tool
3518        // whitelists its fields — a `tier` argument in the payload must be
3519        // ignored, not applied.
3520        let store = test_store();
3521        let mem = Memory::new(Galaxy::Codex, "tier is not client-settable".into());
3522        let id = mem.metadata.id;
3523        store.put(Galaxy::Codex, &mem).unwrap();
3524
3525        let tool = MemoryUpdateTool::new(store.clone(), None);
3526        let mut ctx = Context::default();
3527        let v = tool
3528            .call(
3529                &mut ctx,
3530                json!({"galaxy": "codex", "id": id.to_string(), "tier": "archival", "tags": ["x"]}),
3531            )
3532            .await
3533            .unwrap();
3534        assert_eq!(v["status"], "success");
3535
3536        let after = store.get(Galaxy::Codex, id).unwrap().unwrap();
3537        assert_eq!(
3538            after.metadata.tier,
3539            wm_memory::Tier::Working,
3540            "memory.update must never move the tier"
3541        );
3542        assert_eq!(
3543            after.metadata.tags,
3544            vec!["x".to_string()],
3545            "whitelisted fields still apply"
3546        );
3547    }
3548
3549    #[tokio::test]
3550    async fn empty_search_hints_at_populated_galaxies() {
3551        // Content lives in `sessions`, not `codex` (the vault-store shape).
3552        // The galaxy-unfiltered default (no `galaxy` arg) must FIND it and
3553        // label the result with its galaxy — that is the post-cutover fix
3554        // (2026-08-29): hits used to be resolved against `codex` only.
3555        let (_dir, store, search) = hybrid_fixture();
3556        index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
3557        let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
3558        let mut ctx = Context::default();
3559        let v = tool
3560            .call(&mut ctx, json!({"query": "gate plan"}))
3561            .await
3562            .unwrap();
3563        assert_eq!(
3564            v["count"], 1,
3565            "unfiltered search must find cross-galaxy content: {v}"
3566        );
3567        assert_eq!(v["galaxy"], "all");
3568        assert_eq!(v["results"][0]["galaxy"], "sessions");
3569        assert!(v["hint"].is_null());
3570
3571        // An explicit galaxy still filters at the index and hints on a miss.
3572        let v2 = tool
3573            .call(
3574                &mut ctx,
3575                json!({"query": "zzz-no-match", "galaxy": "sessions"}),
3576            )
3577            .await
3578            .unwrap();
3579        assert_eq!(v2["count"], 0);
3580        let hint2 = v2["hint"].as_str().unwrap();
3581        assert!(hint2.contains("no matches for this query"), "{hint2}");
3582
3583        // A no-match query WITHOUT a galaxy filter searches everywhere and
3584        // reports the overall corpus shape instead of a per-galaxy view.
3585        let v3 = tool
3586            .call(&mut ctx, json!({"query": "zzz-no-match"}))
3587            .await
3588            .unwrap();
3589        assert_eq!(v3["count"], 0);
3590        let hint3 = v3["hint"].as_str().expect("hint present on empty result");
3591        assert!(hint3.contains("across all memory galaxies"), "{hint3}");
3592    }
3593
3594    #[tokio::test]
3595    async fn unfiltered_search_labels_hits_from_every_galaxy() {
3596        // Content spread across three galaxies; the default search (no
3597        // `galaxy` arg) must surface all of them, each labeled. This is the
3598        // federated-recall regression: every backing's rich content lived
3599        // outside `codex` and the old resolution path returned silent zeros.
3600        let (_dir, store, search) = hybrid_fixture();
3601        index_memory(
3602            &store,
3603            &search,
3604            Galaxy::Sessions,
3605            "lineage ledger phase four",
3606        );
3607        index_memory(
3608            &store,
3609            &search,
3610            Galaxy::Codex,
3611            "lineage ledger codex mirror note",
3612        );
3613        index_memory(&store, &search, Galaxy::Dreams, "lineage ledger dream echo");
3614        let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
3615        let mut ctx = Context::default();
3616        let v = tool
3617            .call(&mut ctx, json!({"query": "lineage ledger", "limit": 10}))
3618            .await
3619            .unwrap();
3620        assert_eq!(v["count"], 3, "got: {v}");
3621        let galaxies: Vec<&str> = v["results"]
3622            .as_array()
3623            .unwrap()
3624            .iter()
3625            .map(|r| r["galaxy"].as_str().unwrap())
3626            .collect();
3627        assert!(galaxies.contains(&"sessions"), "got: {galaxies:?}");
3628        assert!(galaxies.contains(&"codex"), "got: {galaxies:?}");
3629        assert!(galaxies.contains(&"dreams"), "got: {galaxies:?}");
3630
3631        // Explicit galaxy filters to exactly that galaxy.
3632        let v2 = tool
3633            .call(
3634                &mut ctx,
3635                json!({"query": "lineage ledger", "galaxy": "dreams"}),
3636            )
3637            .await
3638            .unwrap();
3639        assert_eq!(v2["count"], 1, "got: {v2}");
3640        assert_eq!(v2["results"][0]["galaxy"], "dreams");
3641    }
3642
3643    #[tokio::test]
3644    async fn galaxy_all_alias_matches_unfiltered_search() {
3645        // P0 round-trip fix (2026-09-14): unfiltered responses emit
3646        // `"galaxy": "all"`; echoing that value back must behave exactly
3647        // like omitting the argument instead of "Unknown galaxy: 'all'".
3648        let (_dir, store, search) = hybrid_fixture();
3649        index_memory(&store, &search, Galaxy::Sessions, "alias probe session");
3650        index_memory(&store, &search, Galaxy::Dreams, "alias probe dream");
3651        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3652        let mut ctx = Context::default();
3653        let v = tool
3654            .call(
3655                &mut ctx,
3656                json!({"query": "alias probe", "galaxy": "all", "limit": 10}),
3657            )
3658            .await
3659            .unwrap();
3660        assert_eq!(v["count"], 2, "got: {v}");
3661        assert_eq!(v["galaxy"], "all");
3662        let galaxies: Vec<&str> = v["results"]
3663            .as_array()
3664            .unwrap()
3665            .iter()
3666            .map(|r| r["galaxy"].as_str().unwrap())
3667            .collect();
3668        assert!(galaxies.contains(&"sessions"), "got: {galaxies:?}");
3669        assert!(galaxies.contains(&"dreams"), "got: {galaxies:?}");
3670
3671        // Mixed case normalizes the same way.
3672        let v2 = tool
3673            .call(
3674                &mut ctx,
3675                json!({"query": "alias probe", "galaxy": "ALL", "limit": 10}),
3676            )
3677            .await
3678            .unwrap();
3679        assert_eq!(v2["count"], 2, "got: {v2}");
3680    }
3681
3682    #[tokio::test]
3683    async fn unfiltered_search_excludes_telemetry_unless_explicit() {
3684        // P0 regression (v9.1.5): default `memory.search` returned RSI
3685        // friction records from the telemetry galaxy when the query text
3686        // happened to match. Telemetry is evidence, not cognition: only an
3687        // explicit galaxy filter may reach it.
3688        let (_dir, store, search) = hybrid_fixture();
3689        index_memory(
3690            &store,
3691            &search,
3692            Galaxy::Codex,
3693            "telemetry probe project note",
3694        );
3695        index_memory(
3696            &store,
3697            &search,
3698            Galaxy::Telemetry,
3699            "telemetry probe diagnostic record",
3700        );
3701        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3702        let mut ctx = Context::default();
3703        let v = tool
3704            .call(&mut ctx, json!({"query": "telemetry probe", "limit": 10}))
3705            .await
3706            .unwrap();
3707        assert_eq!(v["count"], 1, "unfiltered search must skip telemetry: {v}");
3708        assert_eq!(v["results"][0]["galaxy"], "codex");
3709
3710        let v2 = tool
3711            .call(
3712                &mut ctx,
3713                json!({"query": "telemetry probe", "galaxy": "telemetry", "limit": 10}),
3714            )
3715            .await
3716            .unwrap();
3717        assert_eq!(v2["count"], 1, "explicit telemetry must still work: {v2}");
3718        assert_eq!(v2["results"][0]["galaxy"], "telemetry");
3719    }
3720
3721    #[tokio::test]
3722    async fn successful_search_carries_no_hint() {
3723        let (_dir, store, search) = hybrid_fixture();
3724        index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
3725        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3726        let mut ctx = Context::default();
3727        let v = tool
3728            .call(
3729                &mut ctx,
3730                json!({"query": "gate plan", "galaxy": "sessions"}),
3731            )
3732            .await
3733            .unwrap();
3734        assert_eq!(v["count"], 1);
3735        assert!(v["hint"].is_null());
3736    }
3737
3738    #[tokio::test]
3739    async fn associative_expansion_surfaces_linked_memory() {
3740        // The core spreading-activation contract: a direct hit on memory A
3741        // activates its one-hop neighbor B even though B shares no query
3742        // terms, and B is marked source=association with its link metadata.
3743        let dir = tempfile::tempdir().unwrap();
3744        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3745        let tantivy_dir = dir.path().join("tantivy");
3746        std::fs::create_dir_all(&tantivy_dir).unwrap();
3747        let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
3748        index_memory(
3749            &store,
3750            &search,
3751            Galaxy::Codex,
3752            "gate plan for the v7 alpha release",
3753        );
3754        let mut linked = Memory::new(
3755            Galaxy::Codex,
3756            "backup automation runs nightly at 03:30".into(),
3757        );
3758        linked.metadata.importance = 0.7;
3759        let linked_id = linked.metadata.id;
3760        store.put(Galaxy::Codex, &linked).unwrap();
3761        search
3762            .writer()
3763            .and_then(|mut w| {
3764                search.add_document(
3765                    &mut w,
3766                    &linked_id.to_string(),
3767                    "codex",
3768                    &linked.content,
3769                    &linked.metadata.tags,
3770                    linked.metadata.created_at.timestamp(),
3771                )?;
3772                search.commit(&mut w)
3773            })
3774            .unwrap();
3775
3776        // The association must NOT share vocabulary with the query.
3777        let assoc = Association::new(
3778            find_id(&store, "gate plan"),
3779            linked_id,
3780            LinkType::Extends,
3781            0.8,
3782        );
3783        let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
3784        associations.put(store.env(), &assoc).unwrap();
3785
3786        let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
3787            .with_associations(Some(associations));
3788        let mut ctx = Context::default();
3789        let v = tool
3790            .call(&mut ctx, json!({"query": "gate plan alpha release"}))
3791            .await
3792            .unwrap();
3793        assert_eq!(v["count"], 2, "direct hit + associated memory: {v}");
3794        let assoc_hit = v["results"]
3795            .as_array()
3796            .unwrap()
3797            .iter()
3798            .find(|r| r["source"] == "association")
3799            .expect("association-sourced result present");
3800        assert_eq!(assoc_hit["id"], json!(linked_id.to_string()));
3801        assert_eq!(assoc_hit["link_type"], "extends");
3802        assert!(assoc_hit["via"].is_string());
3803        assert!(assoc_hit["weight"].as_f64().unwrap() > 0.7);
3804    }
3805
3806    fn find_id(store: &MemoryStore, needle: &str) -> uuid::Uuid {
3807        store
3808            .scan(Galaxy::Codex, 100)
3809            .unwrap()
3810            .into_iter()
3811            .find(|m| m.content.contains(needle))
3812            .map(|m| m.metadata.id)
3813            .unwrap()
3814    }
3815
3816    #[tokio::test]
3817    async fn associative_expansion_skips_private_and_dedupes() {
3818        let dir = tempfile::tempdir().unwrap();
3819        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3820        let mut a = Memory::new(Galaxy::Codex, "quarterly revenue planning notes".into());
3821        a.metadata.importance = 0.8;
3822        let a_id = a.metadata.id;
3823        store.put(Galaxy::Codex, &a).unwrap();
3824        // Private neighbor: must never surface through expansion.
3825        let mut private = Memory::new(Galaxy::Codex, "private salary bands".into());
3826        private.metadata.is_private = true;
3827        private.metadata.importance = 0.8;
3828        store.put(Galaxy::Codex, &private).unwrap();
3829        // Public neighbor linked twice (both directions) — must appear once.
3830        let mut b = Memory::new(Galaxy::Codex, "hiring plan for next quarter".into());
3831        b.metadata.importance = 0.7;
3832        let b_id = b.metadata.id;
3833        store.put(Galaxy::Codex, &b).unwrap();
3834
3835        let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
3836        associations
3837            .put(
3838                store.env(),
3839                &Association::new(a_id, private.metadata.id, LinkType::Related, 0.9),
3840            )
3841            .unwrap();
3842        associations
3843            .put(
3844                store.env(),
3845                &Association::new(a_id, b_id, LinkType::Related, 0.9),
3846            )
3847            .unwrap();
3848        associations
3849            .put(
3850                store.env(),
3851                &Association::new(b_id, a_id, LinkType::Related, 0.9),
3852            )
3853            .unwrap();
3854
3855        let tool = MemoryHybridRecallTool::as_search(store.clone(), None, None)
3856            .with_associations(Some(associations.clone()));
3857        let mut ctx = Context::default();
3858        // No SearchEngine: scan-free importance path still seeds anchors? No —
3859        // with no query there is no seed; use a query but no FTS: results come
3860        // from Phase 1 only when search is present. So attach a search engine.
3861        let _ = &tool;
3862        let tantivy_dir = dir.path().join("tantivy");
3863        std::fs::create_dir_all(&tantivy_dir).unwrap();
3864        let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
3865        for (content, id) in [
3866            ("quarterly revenue planning notes", a_id),
3867            ("private salary bands", private.metadata.id),
3868            ("hiring plan for next quarter", b_id),
3869        ] {
3870            search
3871                .writer()
3872                .and_then(|mut w| {
3873                    search.add_document(&mut w, &id.to_string(), "codex", content, &[], 0)?;
3874                    search.commit(&mut w)
3875                })
3876                .unwrap();
3877        }
3878        let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
3879            .with_associations(Some(associations));
3880        let v = tool
3881            .call(&mut ctx, json!({"query": "quarterly revenue planning"}))
3882            .await
3883            .unwrap();
3884        let ids: Vec<&str> = v["results"]
3885            .as_array()
3886            .unwrap()
3887            .iter()
3888            .filter_map(|r| r["id"].as_str())
3889            .collect();
3890        assert!(
3891            !ids.iter().any(|id| *id == private.metadata.id.to_string()),
3892            "private memory must not surface via association: {ids:?}"
3893        );
3894        assert_eq!(
3895            ids.iter().filter(|id| **id == b_id.to_string()).count(),
3896            1,
3897            "neighbor linked both directions appears exactly once: {ids:?}"
3898        );
3899    }
3900
3901    #[tokio::test]
3902    async fn memory_update_content_recomputes_hash() {
3903        let store = test_store();
3904        let mem = Memory::new(Galaxy::Codex, "original text".into());
3905        store.put(Galaxy::Codex, &mem).unwrap();
3906        let id = mem.metadata.id;
3907        let original_hash = mem.metadata.content_hash.clone();
3908
3909        let tool = MemoryUpdateTool::new(store.clone(), None);
3910        let v = tool
3911            .call(
3912                &mut Context::default(),
3913                json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3914            )
3915            .await
3916            .unwrap();
3917        assert_eq!(v["status"], "success");
3918
3919        // Regression: content updates used to keep the old content hash,
3920        // leaving dedup and hash lookups pointing at stale content.
3921        let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
3922        assert_eq!(stored.content, "changed text");
3923        assert_eq!(
3924            stored.metadata.content_hash,
3925            wm_memory::content_hash("changed text")
3926        );
3927        assert_ne!(stored.metadata.content_hash, original_hash);
3928    }
3929
3930    #[tokio::test]
3931    async fn memory_update_discloses_hash_timeline() {
3932        // V8 S11a: every update response carries the (new) content_hash so
3933        // the write-audit journal records a hash timeline per memory; a
3934        // content-changing update additionally carries prev_content_hash.
3935        let store = test_store();
3936        let mem = Memory::new(Galaxy::Codex, "original text".into());
3937        store.put(Galaxy::Codex, &mem).unwrap();
3938        let id = mem.metadata.id;
3939        let original_hash = mem.metadata.content_hash.clone();
3940        let tool = MemoryUpdateTool::new(store.clone(), None);
3941        let mut ctx = Context::default();
3942
3943        let v = tool
3944            .call(
3945                &mut ctx,
3946                json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3947            )
3948            .await
3949            .unwrap();
3950        assert_eq!(
3951            v["content_hash"],
3952            json!(wm_memory::content_hash("changed text"))
3953        );
3954        assert_eq!(v["prev_content_hash"], json!(original_hash));
3955
3956        // A metadata-only update discloses the current hash and no prev.
3957        let v = tool
3958            .call(
3959                &mut ctx,
3960                json!({"galaxy": "codex", "id": id.to_string(), "tags": ["amended"]}),
3961            )
3962            .await
3963            .unwrap();
3964        assert_eq!(
3965            v["content_hash"],
3966            json!(wm_memory::content_hash("changed text"))
3967        );
3968        assert!(v.get("prev_content_hash").is_none());
3969    }
3970
3971    #[tokio::test]
3972    async fn memory_update_appends_revision_chain() {
3973        // V8 S11c: content changes append hash-linked revision entries;
3974        // metadata-only edits do not; the actor rides in from the context.
3975        let store = test_store();
3976        let mem = Memory::new(Galaxy::Codex, "original text".into());
3977        store.put(Galaxy::Codex, &mem).unwrap();
3978        let id = mem.metadata.id;
3979        let tool = MemoryUpdateTool::new(store.clone(), None);
3980        let mut ctx = Context {
3981            user_id: Some("agent-b".to_string()),
3982            session_id: Some(uuid::Uuid::nil()),
3983            compartment: Some("production".to_string()),
3984            ..Default::default()
3985        };
3986        let v = tool
3987            .call(
3988                &mut ctx,
3989                json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
3990            )
3991            .await
3992            .unwrap();
3993        assert_eq!(v["revision"]["seq"], 0);
3994        assert_eq!(
3995            v["revision"]["old_hash"],
3996            json!(wm_memory::content_hash("original text"))
3997        );
3998        assert_eq!(
3999            v["revision"]["new_hash"],
4000            json!(wm_memory::content_hash("second text"))
4001        );
4002
4003        let v = tool
4004            .call(
4005                &mut ctx,
4006                json!({"galaxy": "codex", "id": id.to_string(), "content": "third text"}),
4007            )
4008            .await
4009            .unwrap();
4010        assert_eq!(v["revision"]["seq"], 1);
4011
4012        let revisions = store.revisions(Galaxy::Codex, id).unwrap();
4013        assert_eq!(revisions.len(), 2);
4014        assert_eq!(revisions[1].old_hash, revisions[0].new_hash, "chain links");
4015        assert_eq!(revisions[0].actor_user.as_deref(), Some("agent-b"));
4016        assert_eq!(
4017            revisions[0].actor_compartment.as_deref(),
4018            Some("production")
4019        );
4020        assert_eq!(
4021            revisions[0].actor_session.as_deref(),
4022            Some(uuid::Uuid::nil().to_string().as_str())
4023        );
4024
4025        let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
4026        assert_eq!(stored.metadata.revision_count, 2);
4027
4028        // The honest chain verifies clean against the live content hash.
4029        let report = store
4030            .verify_revision_chain(Galaxy::Codex, id, &stored.metadata.content_hash)
4031            .unwrap();
4032        assert!(report.valid, "{:?}", report.breaks);
4033        assert!(report.matches_head);
4034    }
4035
4036    #[tokio::test]
4037    async fn memory_update_out_of_band_edit_breaks_chain() {
4038        // Content changed WITHOUT the update tool (the write path the
4039        // journal sees but cannot describe) must break the head match.
4040        let store = test_store();
4041        let mem = Memory::new(Galaxy::Codex, "original text".into());
4042        store.put(Galaxy::Codex, &mem).unwrap();
4043        let id = mem.metadata.id;
4044        let tool = MemoryUpdateTool::new(store.clone(), None);
4045        tool.call(
4046            &mut Context::default(),
4047            json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
4048        )
4049        .await
4050        .unwrap();
4051
4052        // Out-of-band rewrite: hash moved, no revision appended.
4053        let mut row = store.get(Galaxy::Codex, id).unwrap().unwrap();
4054        row.content = "smuggled text".to_string();
4055        row.metadata.content_hash = wm_memory::content_hash("smuggled text");
4056        store.put(Galaxy::Codex, &row).unwrap();
4057
4058        let report = store
4059            .verify_revision_chain(Galaxy::Codex, id, &row.metadata.content_hash)
4060            .unwrap();
4061        assert!(!report.valid);
4062        assert!(!report.matches_head);
4063        assert!(report.breaks.iter().any(|b| b.contains("head mismatch")));
4064    }
4065
4066    #[tokio::test]
4067    async fn memory_revisions_tool_list_and_verify() {
4068        let store = test_store();
4069        let mem = Memory::new(Galaxy::Codex, "v1".into());
4070        store.put(Galaxy::Codex, &mem).unwrap();
4071        let id = mem.metadata.id;
4072        let update = MemoryUpdateTool::new(store.clone(), None);
4073        update
4074            .call(
4075                &mut Context::default(),
4076                json!({"galaxy": "codex", "id": id.to_string(), "content": "v2"}),
4077            )
4078            .await
4079            .unwrap();
4080
4081        let tool = MemoryRevisionsTool::new(store.clone());
4082        let v = tool
4083            .call(&mut Context::default(), json!({"id": id.to_string()}))
4084            .await
4085            .unwrap();
4086        assert_eq!(v["action"], "list");
4087        assert_eq!(v["count"], 1);
4088
4089        let v = tool
4090            .call(
4091                &mut Context::default(),
4092                json!({"id": id.to_string(), "action": "verify"}),
4093            )
4094            .await
4095            .unwrap();
4096        assert_eq!(v["valid"], true);
4097        assert_eq!(v["entries"], 1);
4098
4099        // An injected splice is detectable: entry 1 claims an old_hash the
4100        // chain never produced.
4101        store
4102            .record_revision(
4103                Galaxy::Codex,
4104                id,
4105                "forged_old_hash",
4106                &wm_memory::content_hash("v2"),
4107                wm_memory::RevisionActor::default(),
4108            )
4109            .unwrap();
4110        let v = tool
4111            .call(
4112                &mut Context::default(),
4113                json!({"id": id.to_string(), "action": "verify"}),
4114            )
4115            .await
4116            .unwrap();
4117        assert_eq!(v["valid"], false);
4118        let breaks: Vec<String> = v["breaks"]
4119            .as_array()
4120            .unwrap()
4121            .iter()
4122            .map(|b| b.as_str().unwrap().to_string())
4123            .collect();
4124        assert!(
4125            breaks.iter().any(|b| b.contains("hash-linkage")),
4126            "{breaks:?}"
4127        );
4128    }
4129
4130    #[tokio::test]
4131    async fn memory_update_applies_importance_verbatim() {
4132        // V8 S11d: class ceilings/floors live in the pipeline write gate,
4133        // the single seam every dispatch passes through (same contract as
4134        // the create path). The tool itself applies the arg verbatim, so a
4135        // direct call performs no policy — gate coverage is pinned in
4136        // `wm-dispatch/src/write_gate.rs` instead.
4137        let store = test_store();
4138
4139        let tel = Memory::new(
4140            Galaxy::Codex,
4141            "## Auto-logged Friction: dispatch error\n\nbody".into(),
4142        );
4143        store.put(Galaxy::Codex, &tel).unwrap();
4144
4145        let tool = MemoryUpdateTool::new(store.clone(), None);
4146        let mut ctx = Context::default();
4147
4148        let v = tool
4149            .call(
4150                &mut ctx,
4151                json!({"galaxy": "codex", "id": tel.metadata.id.to_string(), "importance": 0.9}),
4152            )
4153            .await
4154            .unwrap();
4155        assert!(v.get("class_policy").is_none());
4156        assert!(v.get("write_gate").is_none());
4157        let stored = store.get(Galaxy::Codex, tel.metadata.id).unwrap().unwrap();
4158        assert!((stored.metadata.importance - 0.9).abs() < 1e-5);
4159    }
4160
4161    #[tokio::test]
4162    async fn memory_search_min_trust_filter_drops_low_trust() {
4163        // V8 T-b: min_trust is a post-resolution FILTER on every route —
4164        // user-confirmed (1.0) survives a 0.9 floor, tool-ingested (0.7)
4165        // does not; the response discloses what it filtered.
4166        let (_dir, store, search) = hybrid_fixture();
4167        let mut confirmed = Memory::new(Galaxy::Codex, "Quantum foal registry minutes".into());
4168        confirmed.metadata.source_trust = 1.0;
4169        confirmed.metadata.source = "user".to_string();
4170        let mut ingested = Memory::new(Galaxy::Codex, "Quantum foal registry draft".into());
4171        ingested.metadata.source_trust = 0.7;
4172        ingested.metadata.source = "tool".to_string();
4173        store.put(Galaxy::Codex, &confirmed).unwrap();
4174        store.put(Galaxy::Codex, &ingested).unwrap();
4175        mirror_memory(&store, &confirmed, None, 1);
4176        mirror_memory(&store, &ingested, None, 2);
4177
4178        let tool = default_search_tool(store, Some(search));
4179        let mut ctx = Context::default();
4180
4181        let v = tool
4182            .call(
4183                &mut ctx,
4184                json!({"query": "quantum foal registry", "limit": 10}),
4185            )
4186            .await
4187            .unwrap();
4188        assert_eq!(v["count"], 2, "no floor: both results surface");
4189        assert!(v.get("min_trust").is_none());
4190
4191        let v = tool
4192            .call(
4193                &mut ctx,
4194                json!({"query": "quantum foal registry", "limit": 10, "min_trust": 0.9}),
4195            )
4196            .await
4197            .unwrap();
4198        assert_eq!(v["min_trust"], 0.9);
4199        assert_eq!(v["min_trust_filtered"], 1);
4200        let trusts: Vec<f64> = v["results"]
4201            .as_array()
4202            .unwrap()
4203            .iter()
4204            .map(|r| r["trust"].as_f64().unwrap())
4205            .collect();
4206        assert!(trusts.iter().all(|t| *t >= 0.9), "{trusts:?}");
4207    }
4208
4209    #[tokio::test]
4210    async fn memory_sort_by_importance_asc() {
4211        let store = test_store();
4212        populate_memories(&store, Galaxy::Codex);
4213        let tool = MemorySortTool::new(store);
4214        let mut ctx = Context::default();
4215        let v = tool
4216            .call(&mut ctx, json!({"sort_by": "importance", "order": "asc"}))
4217            .await
4218            .unwrap();
4219        let mems = v["memories"].as_array().unwrap();
4220        assert!(mems[0]["importance"].as_f64().unwrap() <= mems[1]["importance"].as_f64().unwrap());
4221    }
4222
4223    #[tokio::test]
4224    async fn memory_sort_by_recency() {
4225        let store = test_store();
4226        populate_memories(&store, Galaxy::Codex);
4227        let tool = MemorySortTool::new(store);
4228        let mut ctx = Context::default();
4229        let v = tool
4230            .call(&mut ctx, json!({"sort_by": "recency"}))
4231            .await
4232            .unwrap();
4233        assert_eq!(v["returned"], 3);
4234    }
4235
4236    #[tokio::test]
4237    async fn memory_sort_invalid_field() {
4238        let store = test_store();
4239        let tool = MemorySortTool::new(store);
4240        let mut ctx = Context::default();
4241        let result = tool.call(&mut ctx, json!({"sort_by": "invalid"})).await;
4242        assert!(result.is_err());
4243    }
4244
4245    #[tokio::test]
4246    async fn memory_sort_with_limit() {
4247        let store = test_store();
4248        populate_memories(&store, Galaxy::Codex);
4249        let tool = MemorySortTool::new(store);
4250        let mut ctx = Context::default();
4251        let v = tool.call(&mut ctx, json!({"limit": 2})).await.unwrap();
4252        assert_eq!(v["returned"], 2);
4253        assert_eq!(v["total"], 3);
4254    }
4255
4256    #[tokio::test]
4257    async fn memory_filter_by_tag() {
4258        let store = test_store();
4259        populate_memories(&store, Galaxy::Codex);
4260        let tool = MemoryFilterTool::new(store);
4261        let mut ctx = Context::default();
4262        let v = tool
4263            .call(&mut ctx, json!({"tags": ["rust"]}))
4264            .await
4265            .unwrap();
4266        assert_eq!(v["matched"], 2);
4267    }
4268
4269    #[tokio::test]
4270    async fn memory_filter_by_importance_range() {
4271        let store = test_store();
4272        populate_memories(&store, Galaxy::Codex);
4273        let tool = MemoryFilterTool::new(store);
4274        let mut ctx = Context::default();
4275        let v = tool
4276            .call(
4277                &mut ctx,
4278                json!({"min_importance": 0.4, "max_importance": 0.6}),
4279            )
4280            .await
4281            .unwrap();
4282        assert_eq!(v["matched"], 1);
4283    }
4284
4285    /// The `query` arg was silently dropped before this fix — every term
4286    /// must now match (case-insensitive) against content or title, and the
4287    /// terms are echoed in `filters` so callers can see what ran.
4288    #[tokio::test]
4289    async fn memory_filter_query_matches_content_and_title() {
4290        let store = test_store();
4291        let tool = MemoryFilterTool::new(store.clone());
4292        let mut ctx = Context::default();
4293
4294        let mut titled = Memory::new(Galaxy::Codex, "unrelated body text".into());
4295        titled.metadata.title = Some("Rust Borrow Checker".into());
4296        let plain = Memory::new(Galaxy::Codex, "rust ownership rules".into());
4297        let other = Memory::new(Galaxy::Codex, "gardening tips".into());
4298        for m in [&titled, &plain, &other] {
4299            store.put(Galaxy::Codex, m).unwrap();
4300        }
4301
4302        let v = tool.call(&mut ctx, json!({"query": "RUST"})).await.unwrap();
4303        assert_eq!(v["matched"], 2, "content hit + title hit: {v}");
4304        assert_eq!(v["filters"]["query_terms"], json!(["rust"]));
4305
4306        let v = tool
4307            .call(&mut ctx, json!({"query": "rust borrow"}))
4308            .await
4309            .unwrap();
4310        assert_eq!(v["matched"], 1, "all terms must match: {v}");
4311        assert_eq!(v["memories"][0]["content"], "unrelated body text");
4312    }
4313
4314    #[tokio::test]
4315    async fn memory_filter_no_matches() {
4316        let store = test_store();
4317        populate_memories(&store, Galaxy::Codex);
4318        let tool = MemoryFilterTool::new(store);
4319        let mut ctx = Context::default();
4320        let v = tool
4321            .call(&mut ctx, json!({"tags": ["nonexistent"]}))
4322            .await
4323            .unwrap();
4324        assert_eq!(v["matched"], 0);
4325    }
4326
4327    #[tokio::test]
4328    async fn memory_filter_combined_tags_and_importance() {
4329        let store = test_store();
4330        populate_memories(&store, Galaxy::Codex);
4331        let tool = MemoryFilterTool::new(store);
4332        let mut ctx = Context::default();
4333        let v = tool
4334            .call(&mut ctx, json!({"tags": ["rust"], "min_importance": 0.5}))
4335            .await
4336            .unwrap();
4337        assert_eq!(v["matched"], 1);
4338    }
4339
4340    /// API honesty (§8): `offset`, `exclude_tags`, and the date range the
4341    /// description always promised are real. Paging addresses the VISIBLE
4342    /// surface.
4343    #[tokio::test]
4344    async fn memory_filter_offset_exclude_tags_and_date_range() {
4345        let store = test_store();
4346        let tool = MemoryFilterTool::new(store.clone());
4347        let mut ctx = Context::default();
4348
4349        let mut recent_a = Memory::new(Galaxy::Codex, "recent a".into());
4350        recent_a.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(2);
4351        let mut recent_b = Memory::new(Galaxy::Codex, "recent b".into());
4352        recent_b.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(1);
4353        recent_b.metadata.tags = vec!["noise".into()];
4354        let mut recent_priv = Memory::new(Galaxy::Codex, "recent private".into());
4355        recent_priv.metadata.created_at = chrono::Utc::now() - chrono::Duration::minutes(90);
4356        recent_priv.metadata.is_private = true;
4357        let mut old = Memory::new(Galaxy::Codex, "old relic".into());
4358        old.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(60);
4359        for m in [&recent_a, &recent_b, &recent_priv, &old] {
4360            store.put(Galaxy::Codex, m).unwrap();
4361        }
4362
4363        let cutoff = (chrono::Utc::now() - chrono::Duration::days(1))
4364            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
4365
4366        // Date range + exclude_tags + privacy all compose.
4367        let v = tool
4368            .call(
4369                &mut ctx,
4370                json!({
4371                    "galaxy": "codex",
4372                    "created_after": cutoff,
4373                    "exclude_tags": ["noise"],
4374                }),
4375            )
4376            .await
4377            .unwrap();
4378        assert_eq!(v["matched"], 1, "only recent-a is visible in range: {v}");
4379        assert_eq!(v["returned"], 1);
4380        assert_eq!(v["memories"][0]["content"], "recent a");
4381        assert_eq!(v["filters"]["exclude_tags"], json!(["noise"]));
4382        assert!(v["filters"]["created_after"].is_string());
4383
4384        // Offset pages the matched surface (recent-a, recent-b, old —
4385        // the private memory is invisible and never counted).
4386        let page2 = tool
4387            .call(
4388                &mut ctx,
4389                json!({"galaxy": "codex", "offset": 3, "limit": 2}),
4390            )
4391            .await
4392            .unwrap();
4393        assert_eq!(
4394            page2["matched"], 3,
4395            "private memory must not count: {page2}"
4396        );
4397        assert_eq!(
4398            page2["returned"], 0,
4399            "offset past the match set is an honest empty page"
4400        );
4401        assert_eq!(page2["offset"], 3);
4402
4403        // Malformed date bounds are a loud InvalidArgs.
4404        let bad = tool
4405            .call(
4406                &mut ctx,
4407                json!({"galaxy": "codex", "created_before": "yesterday"}),
4408            )
4409            .await;
4410        assert!(bad.is_err(), "non-RFC-3339 bound must be refused");
4411    }
4412
4413    #[tokio::test]
4414    async fn memory_deduplicate_hash_dry_run() {
4415        let store = test_store();
4416        let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
4417        let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
4418        let _ = store.put(Galaxy::Codex, &m1);
4419        let _ = store.put(Galaxy::Codex, &m2);
4420        let _ = store.put(
4421            Galaxy::Codex,
4422            &Memory::new(Galaxy::Codex, "unique content".into()),
4423        );
4424
4425        let tool = MemoryDeduplicateTool::new(store.clone(), None);
4426        let mut ctx = Context::default();
4427        let v = tool
4428            .call(&mut ctx, json!({"mode": "hash", "dry_run": true}))
4429            .await
4430            .unwrap();
4431        assert_eq!(v["duplicates_found"], 1);
4432        assert_eq!(v["removed"], 0);
4433
4434        let memories = store.scan(Galaxy::Codex, 100).unwrap();
4435        assert_eq!(memories.len(), 3);
4436    }
4437
4438    #[tokio::test]
4439    async fn memory_deduplicate_hash_execute() {
4440        let store = test_store();
4441        let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
4442        let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
4443        let _ = store.put(Galaxy::Codex, &m1);
4444        let _ = store.put(Galaxy::Codex, &m2);
4445        let _ = store.put(
4446            Galaxy::Codex,
4447            &Memory::new(Galaxy::Codex, "unique content".into()),
4448        );
4449
4450        let tool = MemoryDeduplicateTool::new(store.clone(), None);
4451        let mut ctx = Context::default();
4452        let v = tool
4453            .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
4454            .await
4455            .unwrap();
4456        assert_eq!(v["duplicates_found"], 1);
4457        assert_eq!(v["removed"], 1);
4458
4459        let memories = store.scan(Galaxy::Codex, 100).unwrap();
4460        assert_eq!(memories.len(), 2);
4461    }
4462
4463    #[tokio::test]
4464    async fn memory_deduplicate_deindexes_removed_memories() {
4465        // Regression: deduplicate used to delete from LMDB without de-indexing,
4466        // so full-text search kept returning the removed duplicate.
4467        let (_dir, store, search) = hybrid_fixture();
4468
4469        let m1 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
4470        let m2 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
4471        let id1 = m1.metadata.id;
4472        let id2 = m2.metadata.id;
4473        let _ = store.put(Galaxy::Codex, &m1);
4474        let _ = store.put(Galaxy::Codex, &m2);
4475        for mem in [&m1, &m2] {
4476            let mut writer = search.writer().unwrap();
4477            search
4478                .add_document(
4479                    &mut writer,
4480                    &mem.metadata.id.to_string(),
4481                    mem.metadata.galaxy.db_name(),
4482                    &mem.content,
4483                    &mem.metadata.tags,
4484                    mem.metadata.created_at.timestamp(),
4485                )
4486                .unwrap();
4487            search.commit(&mut writer).unwrap();
4488        }
4489
4490        // Precondition: both duplicates are searchable.
4491        let before = search.search_ids("index drift", 100).unwrap();
4492        assert_eq!(before.len(), 2);
4493
4494        let tool = MemoryDeduplicateTool::new(store.clone(), Some(search.clone()));
4495        let mut ctx = Context::default();
4496        let v = tool
4497            .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
4498            .await
4499            .unwrap();
4500        assert_eq!(v["removed"], 1);
4501
4502        // The surviving memory is still searchable; the removed one is gone.
4503        // (LMDB scan order is by UUID, so either duplicate may be the keeper.)
4504        let after = search.search_ids("index drift", 100).unwrap();
4505        assert_eq!(after.len(), 1, "search index should only contain survivors");
4506        assert!(
4507            after.contains(&id1) || after.contains(&id2),
4508            "survivor should be one of the original memories"
4509        );
4510    }
4511
4512    #[tokio::test]
4513    async fn memory_deduplicate_content_mode() {
4514        let store = test_store();
4515        let m1 = Memory::new(Galaxy::Codex, "same text".into());
4516        let m2 = Memory::new(Galaxy::Codex, "same text".into());
4517        let _ = store.put(Galaxy::Codex, &m1);
4518        let _ = store.put(Galaxy::Codex, &m2);
4519
4520        let tool = MemoryDeduplicateTool::new(store, None);
4521        let mut ctx = Context::default();
4522        let v = tool
4523            .call(&mut ctx, json!({"mode": "content", "dry_run": true}))
4524            .await
4525            .unwrap();
4526        assert_eq!(v["duplicates_found"], 1);
4527    }
4528
4529    #[tokio::test]
4530    async fn memory_deduplicate_no_duplicates() {
4531        let store = test_store();
4532        let _ = store.put(
4533            Galaxy::Codex,
4534            &Memory::new(Galaxy::Codex, "content a".into()),
4535        );
4536        let _ = store.put(
4537            Galaxy::Codex,
4538            &Memory::new(Galaxy::Codex, "content b".into()),
4539        );
4540
4541        let tool = MemoryDeduplicateTool::new(store, None);
4542        let mut ctx = Context::default();
4543        let v = tool.call(&mut ctx, json!({})).await.unwrap();
4544        assert_eq!(v["duplicates_found"], 0);
4545    }
4546
4547    #[tokio::test]
4548    async fn memory_deduplicate_invalid_mode() {
4549        let store = test_store();
4550        let tool = MemoryDeduplicateTool::new(store, None);
4551        let mut ctx = Context::default();
4552        let result = tool.call(&mut ctx, json!({"mode": "invalid"})).await;
4553        assert!(result.is_err());
4554    }
4555
4556    #[tokio::test]
4557    async fn memory_export_json() {
4558        let store = test_store();
4559        populate_memories(&store, Galaxy::Codex);
4560        let tool = MemoryExportTool::new(store);
4561        let mut ctx = Context::default();
4562        let v = tool
4563            .call(&mut ctx, json!({"format": "json"}))
4564            .await
4565            .unwrap();
4566        assert_eq!(v["format"], "json");
4567        assert_eq!(v["count"], 3);
4568        assert!(v["export"].as_str().unwrap().contains("First memory"));
4569    }
4570
4571    #[tokio::test]
4572    async fn memory_export_csv() {
4573        let store = test_store();
4574        populate_memories(&store, Galaxy::Codex);
4575        let tool = MemoryExportTool::new(store);
4576        let mut ctx = Context::default();
4577        let v = tool.call(&mut ctx, json!({"format": "csv"})).await.unwrap();
4578        let csv = v["export"].as_str().unwrap();
4579        assert!(csv.contains("id,content,tags"));
4580        assert!(csv.contains("First memory"));
4581    }
4582
4583    #[tokio::test]
4584    async fn memory_export_markdown() {
4585        let store = test_store();
4586        populate_memories(&store, Galaxy::Codex);
4587        let tool = MemoryExportTool::new(store);
4588        let mut ctx = Context::default();
4589        let v = tool
4590            .call(&mut ctx, json!({"format": "markdown"}))
4591            .await
4592            .unwrap();
4593        let md = v["export"].as_str().unwrap();
4594        assert!(md.contains("# Memory Export"));
4595        assert!(md.contains("First memory"));
4596    }
4597
4598    #[tokio::test]
4599    async fn memory_export_invalid_format() {
4600        let store = test_store();
4601        let tool = MemoryExportTool::new(store);
4602        let mut ctx = Context::default();
4603        let result = tool.call(&mut ctx, json!({"format": "xml"})).await;
4604        assert!(result.is_err());
4605    }
4606
4607    #[tokio::test]
4608    async fn memory_export_empty_galaxy() {
4609        let store = test_store();
4610        let tool = MemoryExportTool::new(store);
4611        let mut ctx = Context::default();
4612        let v = tool
4613            .call(&mut ctx, json!({"format": "json"}))
4614            .await
4615            .unwrap();
4616        assert_eq!(v["count"], 0);
4617    }
4618
4619    #[tokio::test]
4620    async fn memory_sort_and_filter_are_winnowing_basket_gana() {
4621        let store = test_store();
4622        assert_eq!(
4623            MemorySortTool::new(store.clone()).gana(),
4624            Gana::WinnowingBasket
4625        );
4626        assert_eq!(
4627            MemoryFilterTool::new(store.clone()).gana(),
4628            Gana::WinnowingBasket
4629        );
4630        assert_eq!(
4631            MemoryDeduplicateTool::new(store.clone(), None).gana(),
4632            Gana::WinnowingBasket
4633        );
4634        assert_eq!(MemoryExportTool::new(store).gana(), Gana::WinnowingBasket);
4635    }
4636
4637    // ── hybrid_recall incident regression tests ─────────────────────────
4638    //
4639    // Mirrors the 2026-08-11 incident: `memory.hybrid_recall` with query
4640    // "smoke test from wmClient" and limit 20 returned 20 unrelated memories
4641    // at BM25 scores 0.5–1.0 with zero query-token overlap. The fix uses
4642    // OR semantics with a token-coverage floor and score floors, replacing
4643    // the old `scan(galaxy, 100)` lottery.
4644
4645    #[test]
4646    fn hybrid_recall_routes_expose_query_schema() {
4647        let dir = tempfile::tempdir().unwrap();
4648        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
4649        for tool in [
4650            MemoryHybridRecallTool::new(store.clone(), None, None),
4651            MemoryHybridRecallTool::as_search(store, None, None),
4652        ] {
4653            let schema = tool.input_schema();
4654            assert_eq!(schema["type"], "object");
4655            assert!(schema["properties"].get("query").is_some());
4656            assert_eq!(schema["required"], json!(["query"]));
4657        }
4658    }
4659
4660    /// Build a store + tantivy index pair where the memory and its index
4661    /// document are kept in sync (as the write path does).
4662    fn hybrid_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
4663        let dir = tempfile::tempdir().unwrap();
4664        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
4665        let tantivy_dir = dir.path().join("tantivy");
4666        std::fs::create_dir_all(&tantivy_dir).unwrap();
4667        let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
4668        (dir, store, search)
4669    }
4670
4671    fn index_memory(
4672        store: &Arc<MemoryStore>,
4673        search: &Arc<SearchEngine>,
4674        galaxy: Galaxy,
4675        content: &str,
4676    ) {
4677        let mem = Memory::new(galaxy, content.to_string());
4678        let id = mem.metadata.id;
4679        store.put(galaxy, &mem).unwrap();
4680        let mut writer = search.writer().unwrap();
4681        search
4682            .add_document(
4683                &mut writer,
4684                &id.to_string(),
4685                galaxy.db_name(),
4686                content,
4687                &mem.metadata.tags,
4688                mem.metadata.created_at.timestamp(),
4689            )
4690            .unwrap();
4691        search.commit(&mut writer).unwrap();
4692    }
4693
4694    #[tokio::test]
4695    async fn hybrid_recall_excludes_private_memories() {
4696        let (_dir, store, search) = hybrid_fixture();
4697
4698        // Private memory, indexed exactly like the write path.
4699        let mut priv_mem = Memory::new(Galaxy::Codex, "private secret plan alpha".to_string());
4700        priv_mem.metadata.is_private = true;
4701        let id = priv_mem.metadata.id;
4702        store.put(Galaxy::Codex, &priv_mem).unwrap();
4703        {
4704            let mut writer = search.writer().unwrap();
4705            search
4706                .add_document(
4707                    &mut writer,
4708                    &id.to_string(),
4709                    "codex",
4710                    "private secret plan alpha",
4711                    &[],
4712                    priv_mem.metadata.created_at.timestamp(),
4713                )
4714                .unwrap();
4715            search.commit(&mut writer).unwrap();
4716        }
4717
4718        // Public memory with overlapping terms.
4719        index_memory(
4720            &store,
4721            &search,
4722            Galaxy::Codex,
4723            "public plan alpha documentation",
4724        );
4725
4726        let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
4727        let v = tool
4728            .call(
4729                &mut Context::default(),
4730                json!({"query": "plan alpha", "galaxy": "codex"}),
4731            )
4732            .await
4733            .unwrap();
4734        let results = v["results"].as_array().unwrap();
4735        let contents: Vec<&str> = results
4736            .iter()
4737            .filter_map(|r| r["content"].as_str())
4738            .collect();
4739        assert!(
4740            !contents.iter().any(|c| c.contains("private")),
4741            "private memory leaked through hybrid recall: {results:?}"
4742        );
4743        assert!(
4744            contents.iter().any(|c| c.contains("public")),
4745            "public memory missing from hybrid recall: {results:?}"
4746        );
4747    }
4748
4749    #[tokio::test]
4750    async fn batch_read_treats_private_as_miss() {
4751        let store = test_store();
4752        let mut priv_mem = Memory::new(Galaxy::Codex, "private batch note".into());
4753        priv_mem.metadata.is_private = true;
4754        let priv_id = priv_mem.metadata.id;
4755        store.put(Galaxy::Codex, &priv_mem).unwrap();
4756        let pub_mem = Memory::new(Galaxy::Codex, "public batch note".into());
4757        let pub_id = pub_mem.metadata.id;
4758        store.put(Galaxy::Codex, &pub_mem).unwrap();
4759
4760        let tool = MemoryBatchReadTool::new(store);
4761        let v = tool
4762            .call(
4763                &mut Context::default(),
4764                json!({"galaxy": "codex", "ids": [priv_id.to_string(), pub_id.to_string()]}),
4765            )
4766            .await
4767            .unwrap();
4768        assert_eq!(v["found"], 1);
4769        assert_eq!(v["misses"], 1);
4770        assert!(
4771            !v["memories"]
4772                .as_array()
4773                .unwrap()
4774                .iter()
4775                .any(|m| m["content"].as_str().unwrap_or("").contains("private")),
4776            "private memory leaked through batch_read: {v}"
4777        );
4778    }
4779
4780    #[tokio::test]
4781    async fn hybrid_recall_incident_query_returns_only_relevant() {
4782        let (_dir, store, search) = hybrid_fixture();
4783        index_memory(
4784            &store,
4785            &search,
4786            Galaxy::Codex,
4787            "smoke test from wmClient: verify recall",
4788        );
4789        index_memory(
4790            &store,
4791            &search,
4792            Galaxy::Codex,
4793            "NES Evolution and Impact: a history of the console wars",
4794        );
4795        index_memory(
4796            &store,
4797            &search,
4798            Galaxy::Codex,
4799            "Insights on The Gateless Gate: koans and zen practice",
4800        );
4801        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4802        let mut ctx = Context::default();
4803        let v = tool
4804            .call(
4805                &mut ctx,
4806                json!({"query": "smoke test", "galaxy": "codex", "limit": 5}),
4807            )
4808            .await
4809            .unwrap();
4810        let results = v["results"].as_array().unwrap();
4811        assert_eq!(
4812            results.len(),
4813            1,
4814            "incident query must not return unrelated memories: {results:?}"
4815        );
4816        let hit = &results[0];
4817        assert_eq!(hit["source"], "fts");
4818        assert!(
4819            hit["content"]
4820                .as_str()
4821                .unwrap()
4822                .contains("smoke test from wmClient")
4823        );
4824        assert!(hit["normalized_score"].as_f64().unwrap() > 0.0);
4825        assert_eq!(v["count"], 1);
4826    }
4827
4828    #[tokio::test]
4829    async fn hybrid_recall_filters_stale_index_entries() {
4830        // A document indexed in tantivy but absent from LMDB must not be
4831        // returned (the old code wasted top-K slots on these).
4832        let (_dir, store, search) = hybrid_fixture();
4833        index_memory(
4834            &store,
4835            &search,
4836            Galaxy::Codex,
4837            "rust memory about ownership",
4838        );
4839        {
4840            let mut writer = search.writer().unwrap();
4841            search
4842                .add_document(
4843                    &mut writer,
4844                    "99999999-9999-9999-9999-999999999999",
4845                    "codex",
4846                    "rust ghost memory",
4847                    &[],
4848                    1700000000,
4849                )
4850                .unwrap();
4851            search.commit(&mut writer).unwrap();
4852        }
4853
4854        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4855        let mut ctx = Context::default();
4856        let v = tool
4857            .call(&mut ctx, json!({"query": "rust", "limit": 10}))
4858            .await
4859            .unwrap();
4860        let results = v["results"].as_array().unwrap();
4861        assert_eq!(results.len(), 1);
4862        assert_ne!(
4863            results[0]["id"].as_str().unwrap(),
4864            "99999999-9999-9999-9999-999999999999"
4865        );
4866    }
4867
4868    #[tokio::test]
4869    async fn hybrid_recall_respects_min_score_arg() {
4870        let (_dir, store, search) = hybrid_fixture();
4871        index_memory(&store, &search, Galaxy::Codex, "alpha");
4872        let filler = format!("alpha {}", "zzz ".repeat(400));
4873        index_memory(&store, &search, Galaxy::Codex, &filler);
4874
4875        // No threshold: both match.
4876        let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
4877        let mut ctx = Context::default();
4878        let v = tool
4879            .call(&mut ctx, json!({"query": "alpha", "limit": 10}))
4880            .await
4881            .unwrap();
4882        assert_eq!(v["count"], 2);
4883
4884        // min_score between the two scores: only the strong match remains.
4885        let scores: Vec<f64> = v["results"]
4886            .as_array()
4887            .unwrap()
4888            .iter()
4889            .map(|r| r["score"].as_f64().unwrap())
4890            .collect();
4891        let lo = scores.iter().copied().fold(f64::MAX, f64::min);
4892        let hi = scores.iter().copied().fold(0.0, f64::max);
4893        let mid = f64::midpoint(hi, lo);
4894
4895        let v = tool
4896            .call(
4897                &mut ctx,
4898                json!({"query": "alpha", "limit": 10, "min_score": mid}),
4899            )
4900            .await
4901            .unwrap();
4902        assert_eq!(v["count"], 1);
4903        assert!((v["results"][0]["score"].as_f64().unwrap() - hi).abs() < 1e-3);
4904    }
4905
4906    #[tokio::test]
4907    async fn hybrid_recall_or_coverage_finds_partial_matches() {
4908        // OR + token-coverage finds partial matches without a separate
4909        // fallback phase.  The doc covering 3/4 query terms survives the
4910        // 2/4 coverage floor; the 1/4 doc is filtered out.
4911        let (_dir, store, search) = hybrid_fixture();
4912        index_memory(&store, &search, Galaxy::Codex, "alpha only here");
4913        index_memory(&store, &search, Galaxy::Codex, "alpha beta gamma delta");
4914
4915        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4916        let mut ctx = Context::default();
4917        let v = tool
4918            .call(&mut ctx, json!({"query": "alpha beta gamma", "limit": 10}))
4919            .await
4920            .unwrap();
4921        let results = v["results"].as_array().unwrap();
4922        // 3-term query: 2/3 coverage floor.  "alpha beta gamma delta" covers
4923        // 3/3, "alpha only here" covers 1/3 → filtered.
4924        assert_eq!(results.len(), 1);
4925        assert!(
4926            results[0]["content"]
4927                .as_str()
4928                .unwrap()
4929                .contains("alpha beta gamma")
4930        );
4931
4932        // 4-term query: 2/4 coverage floor.  "alpha beta gamma delta" covers
4933        // 3/4, "alpha only here" covers 1/4 → filtered.
4934        let v = tool
4935            .call(
4936                &mut ctx,
4937                json!({"query": "alpha beta gamma zeta", "limit": 10}),
4938            )
4939            .await
4940            .unwrap();
4941        let results = v["results"].as_array().unwrap();
4942        assert_eq!(
4943            results.len(),
4944            1,
4945            "OR + coverage must require 2/4 token coverage: {results:?}"
4946        );
4947        assert!(
4948            results[0]["content"]
4949                .as_str()
4950                .unwrap()
4951                .contains("alpha beta gamma")
4952        );
4953        for r in results {
4954            assert!(
4955                matches!(r["source"].as_str(), Some("fts")),
4956                "results should be tagged fts"
4957            );
4958        }
4959    }
4960
4961    fn index_tagged_memory(
4962        store: &Arc<MemoryStore>,
4963        search: &Arc<SearchEngine>,
4964        galaxy: Galaxy,
4965        content: &str,
4966        tags: &[&str],
4967    ) {
4968        let mut mem = Memory::new(galaxy, content.to_string());
4969        mem.metadata.tags = tags.iter().map(ToString::to_string).collect();
4970        let id = mem.metadata.id;
4971        store.put(galaxy, &mem).unwrap();
4972        let mut writer = search.writer().unwrap();
4973        search
4974            .add_document(
4975                &mut writer,
4976                &id.to_string(),
4977                galaxy.db_name(),
4978                content,
4979                &mem.metadata.tags,
4980                mem.metadata.created_at.timestamp(),
4981            )
4982            .unwrap();
4983        search.commit(&mut writer).unwrap();
4984    }
4985
4986    fn aggregate_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
4987        let (dir, store, search) = hybrid_fixture();
4988        // A Rust learning journey across sessions 2, 7, 12 …
4989        index_tagged_memory(
4990            &store,
4991            &search,
4992            Galaxy::Codex,
4993            "I started learning Rust.",
4994            &["user", "session_002"],
4995        );
4996        index_tagged_memory(
4997            &store,
4998            &search,
4999            Galaxy::Codex,
5000            "I finished my first Rust project, a CLI tool.",
5001            &["user", "session_007"],
5002        );
5003        index_tagged_memory(
5004            &store,
5005            &search,
5006            Galaxy::Codex,
5007            "I got a job as a systems engineer using Rust.",
5008            &["user", "session_012"],
5009        );
5010        // …and a Go journey that must not distort the Rust span (all its
5011        // turns also match the generic terms "started"/"job").
5012        index_tagged_memory(
5013            &store,
5014            &search,
5015            Galaxy::Codex,
5016            "I started learning Go.",
5017            &["user", "session_003"],
5018        );
5019        index_tagged_memory(
5020            &store,
5021            &search,
5022            Galaxy::Codex,
5023            "I got a job as a backend engineer using Go.",
5024            &["user", "session_015"],
5025        );
5026        (dir, store, search)
5027    }
5028
5029    #[tokio::test]
5030    async fn aggregate_session_span_isolated_by_rarest_term() {
5031        let (_dir, store, search) = aggregate_fixture();
5032        let tool = MemoryAggregateTool::new(Some(search), store);
5033        let mut ctx = Context::default();
5034        let v = tool
5035            .call(
5036                &mut ctx,
5037                json!({
5038                    "query": "How long did it take from starting Rust to getting a job using it?",
5039                    "metric": "session_span",
5040                }),
5041            )
5042            .await
5043            .unwrap();
5044        assert_eq!(v["aggregate"]["value"], 10, "session_012 - session_002");
5045        assert_eq!(v["aggregate"]["unit"], "sessions");
5046        assert_eq!(v["aggregate"]["content"], "10 sessions");
5047    }
5048
5049    #[tokio::test]
5050    async fn aggregate_session_count() {
5051        let (_dir, store, search) = aggregate_fixture();
5052        let tool = MemoryAggregateTool::new(Some(search), store);
5053        let mut ctx = Context::default();
5054        let v = tool
5055            .call(
5056                &mut ctx,
5057                json!({
5058                    "query": "How long did it take from starting Rust to getting a job using it?",
5059                    "metric": "session_count",
5060                }),
5061            )
5062            .await
5063            .unwrap();
5064        // The anchor cluster is the Rust turns; the middle turn ("finished
5065        // my first Rust project") matches only one query term and is held
5066        // back by the search engine's token-coverage floor, so the distinct
5067        // session count is 2 (start and end sessions) — span is unaffected
5068        // because min/max need only the endpoints.
5069        assert_eq!(v["aggregate"]["value"], 2);
5070    }
5071
5072    #[tokio::test]
5073    async fn aggregate_count_needs_no_session_tags() {
5074        let (_dir, store, search) = aggregate_fixture();
5075        let tool = MemoryAggregateTool::new(Some(search), store);
5076        let mut ctx = Context::default();
5077        let v = tool
5078            .call(
5079                &mut ctx,
5080                json!({"query": "Rust project", "metric": "count"}),
5081            )
5082            .await
5083            .unwrap();
5084        // OR semantics: all three Rust turns match "rust".
5085        assert_eq!(v["aggregate"]["value"], 3);
5086    }
5087
5088    /// Previously a single session-tagged hit (or a stopword-only query
5089    /// with no anchor term) reported session_count 0 / span null DESPITE
5090    /// evidence. The fallback anchors on the session-tagged set instead.
5091    #[tokio::test]
5092    async fn aggregate_single_session_falls_back_honestly() {
5093        let (_dir, store, search) = aggregate_fixture();
5094        let tool = MemoryAggregateTool::new(Some(search), store);
5095        let mut ctx = Context::default();
5096        // "CLI tool" narrows to the session_007 turn alone.
5097        let v = tool
5098            .call(
5099                &mut ctx,
5100                json!({"query": "CLI tool", "metric": "session_count"}),
5101            )
5102            .await
5103            .unwrap();
5104        assert_eq!(v["aggregate"]["value"], 1, "one session in evidence: {v}");
5105        assert_eq!(v["anchor"], "session_tagged_fallback");
5106        let v = tool
5107            .call(
5108                &mut ctx,
5109                json!({"query": "CLI tool", "metric": "session_span"}),
5110            )
5111            .await
5112            .unwrap();
5113        assert_eq!(v["aggregate"]["value"], 0, "single point spans 0: {v}");
5114    }
5115
5116    #[tokio::test]
5117    async fn aggregate_rejects_unknown_metric() {
5118        let (_dir, store, search) = aggregate_fixture();
5119        let tool = MemoryAggregateTool::new(Some(search), store);
5120        let mut ctx = Context::default();
5121        let err = tool
5122            .call(&mut ctx, json!({"query": "x", "metric": "median"}))
5123            .await
5124            .unwrap_err();
5125        assert!(err.to_string().contains("unknown metric"));
5126    }
5127
5128    /// 2026-09-15 audit: bounded floors must reject out-of-range values
5129    /// instead of silently dropping the filter (watching `min_trust: 2.0`
5130    /// quietly disable the trust floor is the failure this pins).
5131    #[tokio::test]
5132    async fn hybrid_recall_rejects_out_of_range_floors() {
5133        let (_dir, store, search) = hybrid_fixture();
5134        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
5135        let mut ctx = Context::default();
5136
5137        for (key, value) in [
5138            ("min_trust", json!(2.0)),
5139            ("min_trust", json!(-0.1)),
5140            ("min_importance", json!(1.5)),
5141            ("min_importance", json!(-0.5)),
5142            ("min_score_ratio", json!(1.5)),
5143            ("min_score_ratio", json!(-1.0)),
5144            ("min_score", json!(-3.0)),
5145            ("min_trust", json!("2.0")),
5146        ] {
5147            let err = tool
5148                .call(&mut ctx, json!({"query": "x", key: value}))
5149                .await
5150                .unwrap_err();
5151            assert!(
5152                err.to_string().contains(key),
5153                "{key}={value} must be rejected by name, got: {err}"
5154            );
5155        }
5156
5157        // Boundary values at the edge of the valid interval are accepted.
5158        let v = tool
5159            .call(
5160                &mut ctx,
5161                json!({
5162                    "query": "x",
5163                    "min_trust": 1.0,
5164                    "min_importance": 0.0,
5165                    "min_score_ratio": 0.0,
5166                    "min_score": 0.0,
5167                }),
5168            )
5169            .await
5170            .unwrap();
5171        assert_eq!(v["min_trust"], 1.0, "valid boundary floor disclosed: {v}");
5172    }
5173
5174    /// 2026-09-15 audit: importance is defined on 0.0-1.0; out-of-range
5175    /// updates are caller errors, not values stored verbatim.
5176    #[tokio::test]
5177    async fn memory_update_rejects_out_of_range_importance() {
5178        let store = test_store();
5179        let mut mem = Memory::new(Galaxy::Codex, "audit validation target".into());
5180        mem.metadata.importance = 0.5;
5181        store.put(Galaxy::Codex, &mem).unwrap();
5182        let id = mem.metadata.id;
5183
5184        let tool = MemoryUpdateTool::new(store.clone(), None);
5185        let mut ctx = Context::default();
5186        for bad in [json!(2.0), json!(999), json!(-0.25), json!("1.5")] {
5187            let err = tool
5188                .call(&mut ctx, json!({"id": id, "importance": bad}))
5189                .await
5190                .unwrap_err();
5191            assert!(
5192                err.to_string().contains("importance"),
5193                "importance={bad} must be rejected, got: {err}"
5194            );
5195        }
5196        let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
5197        assert!(
5198            (stored.metadata.importance - 0.5).abs() < f32::EPSILON,
5199            "rejected updates must not mutate the record: {}",
5200            stored.metadata.importance
5201        );
5202
5203        // A valid update still lands.
5204        let v = tool
5205            .call(&mut ctx, json!({"id": id, "importance": 0.75}))
5206            .await
5207            .unwrap();
5208        assert_eq!(v["status"], "success", "{v}");
5209        let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
5210        assert!((stored.metadata.importance - 0.75).abs() < f32::EPSILON);
5211    }
5212
5213    // ── Absolute-evidence abstention (2026-09-22) ──────────────────────
5214
5215    #[test]
5216    fn weak_evidence_abstention_floor_and_coverage() {
5217        // Knobs off: never abstains.
5218        let fts = vec![json!({"source": "fts", "raw_score": 0.5})];
5219        assert!(weak_evidence_abstention_with(&fts, "alpha", 0.0, 0.0).is_none());
5220
5221        // FTS/hybrid top below the absolute BM25 floor.
5222        let weak =
5223            weak_evidence_abstention_with(&fts, "alpha", 2.0, 0.0).expect("below floor abstains");
5224        assert_eq!(weak["reason"], "top_below_floor");
5225        assert_eq!(weak["signal"], "bm25");
5226        assert_eq!(weak["scope"], "retrieval");
5227        assert_eq!(weak["status"], "insufficient_evidence");
5228
5229        // Above the floor: no abstention.
5230        let strong = vec![json!({"source": "hybrid", "raw_score": 4.0})];
5231        assert!(weak_evidence_abstention_with(&strong, "alpha", 2.0, 0.0).is_none());
5232
5233        // Vector-only evidence (raw 0.0) is not judged by the BM25 floor.
5234        let vector_only = vec![json!({"source": "hybrid", "raw_score": 0.0, "vector_score": 0.9})];
5235        assert!(weak_evidence_abstention_with(&vector_only, "alpha", 2.0, 0.0).is_none());
5236
5237        // Episodic coverage: 1 of 4 stopword-stripped terms matched.
5238        let episodic = vec![json!({"source": "episodic", "matched_terms": 1})];
5239        let low = weak_evidence_abstention_with(&episodic, "alpha beta gamma delta", 0.0, 0.5)
5240            .expect("low coverage abstains");
5241        assert_eq!(low["reason"], "coverage_below_floor");
5242        assert_eq!(low["signal"], "coverage");
5243        assert_eq!(low["matched_terms"], 1);
5244
5245        // Full coverage passes.
5246        let full = vec![json!({"source": "episodic", "matched_terms": 4})];
5247        assert!(weak_evidence_abstention_with(&full, "alpha beta gamma delta", 0.0, 0.5).is_none());
5248
5249        // Non-retrieval sources are not judged.
5250        let association = vec![json!({"source": "association", "score": 0.1})];
5251        assert!(weak_evidence_abstention_with(&association, "alpha", 2.0, 0.5).is_none());
5252    }
5253}