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