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