Skip to main content

wm_tools/
embedding_router.rs

1//! Embedding-based NLU router for the `wm` meta-tool.
2//!
3//! Replaces 166 hand-written TF-IDF keyword profiles with embedding cosine
4//! similarity. Each tool's description is embedded once at startup. Input
5//! queries are embedded and compared against all tool embeddings using cosine
6//! similarity.
7//!
8//! # OATS: Outcome-Aware Tool Selection
9//!
10//! After each tool call, the router records whether the routing was correct
11//! (tool succeeded) or incorrect (tool failed / was wrong). Success and failure
12//! query embeddings are averaged into centroids. Tool embeddings are refined
13//! by interpolating toward the success centroid:
14//!
15//! ```text
16//! refined = base * (1 - α) + success_centroid * α
17//! ```
18//!
19//! This is zero-cost at serving time when pre-computed, and improves NDCG@5
20//! from ~0.869 to ~0.940 (OATS, 2026).
21//!
22//! # Fallback
23//!
24//! If no real embedder is available (only StubEmbedder), the router returns
25//! `None` from `new()`, and the caller falls back to the TF-IDF router.
26
27use ahash::AHashMap;
28use std::sync::{Arc, RwLock};
29use wm_memory::Embedder;
30
31use crate::nlu::{PHRASE_ROUTES, PREFIX_ROUTES, TOOL_PROFILES, ToolProfile};
32
33/// OATS refinement strength (interpolation factor toward success centroid).
34const OATS_ALPHA: f32 = 0.15;
35
36/// Minimum observations before OATS refinement kicks in.
37const OATS_MIN_OBSERVATIONS: usize = 10;
38
39/// Minimum cosine similarity to return a match (below this → gnosis fallback).
40const MIN_THRESHOLD: f64 = 0.10;
41
42/// Minimum margin between top-1 and top-2 before the embedding router's
43/// choice is trusted.
44///
45/// Near-ties (margin below this) mean the description vocabulary cannot
46/// separate intent — the caller should defer to the TF-IDF router. Derived
47/// from the 2026-08-11 shadow data: ambiguous queries produced confident-
48/// looking top-1 scores (0.5–0.8) with the correct tool often runner-up.
49pub const MIN_MARGIN: f64 = 0.02;
50
51/// Outcome statistics for a single tool (OATS data).
52#[derive(Debug, Clone)]
53pub struct OutcomeStats {
54    /// Running centroid of query embeddings where this tool was the correct route.
55    success_centroid: Vec<f32>,
56    /// Running centroid of query embeddings where this tool was the wrong route.
57    #[allow(dead_code)]
58    failure_centroid: Vec<f32>,
59    /// Number of successful routing observations.
60    success_count: usize,
61    /// Number of failed routing observations.
62    failure_count: usize,
63}
64
65impl OutcomeStats {
66    /// Create empty outcome stats with the given embedding dimensionality.
67    fn new(dim: usize) -> Self {
68        Self {
69            success_centroid: vec![0.0; dim],
70            failure_centroid: vec![0.0; dim],
71            success_count: 0,
72            failure_count: 0,
73        }
74    }
75
76    /// Record a routing outcome with the query embedding.
77    fn record(&mut self, query_emb: &[f32], success: bool) {
78        if query_emb.is_empty() {
79            return;
80        }
81
82        if success {
83            update_centroid(
84                &mut self.success_centroid,
85                &mut self.success_count,
86                query_emb,
87            );
88        } else {
89            update_centroid(
90                &mut self.failure_centroid,
91                &mut self.failure_count,
92                query_emb,
93            );
94        }
95    }
96
97    /// Whether OATS has enough data to refine this tool's embedding.
98    const fn is_ready(&self) -> bool {
99        self.success_count >= OATS_MIN_OBSERVATIONS
100    }
101}
102
103/// Update a running centroid with a new vector (incremental mean).
104fn update_centroid(centroid: &mut [f32], count: &mut usize, new_vec: &[f32]) {
105    if centroid.len() != new_vec.len() {
106        return;
107    }
108    let n = *count as f32 + 1.0;
109    for (c, v) in centroid.iter_mut().zip(new_vec.iter()) {
110        *c += (*v - *c) / n;
111    }
112    *count += 1;
113}
114
115/// The embedding-based NLU router.
116///
117/// Pre-computes tool embeddings at initialization, then routes queries by
118/// embedding the query and computing cosine similarity against all tool
119/// embeddings. OATS refinement adjusts tool embeddings based on observed
120/// outcomes.
121pub struct EmbeddingRouter {
122    /// Tool name → base embedding (from tool description).
123    tool_embeddings: AHashMap<String, Vec<f32>>,
124    /// Embedder backend.
125    embedder: Box<dyn Embedder>,
126    /// OATS outcome stats per tool (interior mutability for record_outcome).
127    outcome_stats: RwLock<AHashMap<String, OutcomeStats>>,
128    /// Embedding dimensionality.
129    dim: usize,
130    /// Whether to apply the TF-IDF prefix-route bonus.
131    ///
132    /// `true` for the legacy keyword-profile path (`new`), where descriptions
133    /// are bare keyword lists and the verb bonus compensates. `false` for
134    /// anchored descriptions (`with_descriptions`), where the bonus fights
135    /// the intent anchors ("list tools" was boosted toward memory.list despite
136    /// tools.list carrying the exact anchor).
137    apply_prefix_bonus: bool,
138}
139
140impl EmbeddingRouter {
141    /// Create a new embedding router, pre-computing tool embeddings.
142    ///
143    /// Returns `None` if:
144    /// - The embedder is a stub (hash-based embeddings have no semantic meaning)
145    /// - Batch embedding fails
146    ///
147    /// This allows the caller to gracefully fall back to the TF-IDF router.
148    #[must_use]
149    pub fn new(embedder: Box<dyn Embedder>) -> Option<Self> {
150        Self::new_with_descriptions(embedder, tool_descriptions(), true)
151    }
152
153    /// Create a new embedding router from explicit (tool, description) pairs.
154    ///
155    /// Unlike [`Self::new`] — which uses the static keyword profiles from
156    /// `nlu.rs` (169 tools, keyword-mashup descriptions) — this accepts
157    /// descriptions from the live tool registry (all 229 tools, prose
158    /// descriptions). Sentence-style descriptions embed far better than
159    /// bare keyword lists; the 2026-08-11 shadow run (42.6% disagreement)
160    /// showed keyword-mashup top-1 selection collapsing onto arbitrary
161    /// high-similarity tools.
162    #[must_use]
163    pub fn with_descriptions(
164        embedder: Box<dyn Embedder>,
165        descriptions: Vec<(String, String)>,
166    ) -> Option<Self> {
167        Self::new_with_descriptions(embedder, descriptions, false)
168    }
169
170    fn new_with_descriptions(
171        embedder: Box<dyn Embedder>,
172        descriptions: Vec<(String, String)>,
173        apply_prefix_bonus: bool,
174    ) -> Option<Self> {
175        // Stub embedders produce hash-based embeddings with no semantic similarity.
176        // Don't use the embedding router with them — fall back to TF-IDF.
177        if embedder.backend_name() == "stub" {
178            tracing::info!(
179                "embedding router disabled — stub embedder has no semantic similarity, using TF-IDF fallback"
180            );
181            return None;
182        }
183
184        let dim = embedder.dimension();
185
186        let texts: Vec<&str> = descriptions.iter().map(|(_, d)| d.as_str()).collect();
187        let embeddings = embedder.embed_batch(&texts).ok()?;
188
189        if embeddings.len() != descriptions.len() {
190            tracing::warn!(
191                "embedding router: expected {} embeddings, got {} — falling back to TF-IDF",
192                descriptions.len(),
193                embeddings.len()
194            );
195            return None;
196        }
197
198        let mut tool_embeddings = AHashMap::with_capacity(descriptions.len());
199        for ((name, _), emb) in descriptions.into_iter().zip(embeddings) {
200            tool_embeddings.insert(name, emb);
201        }
202
203        tracing::info!(
204            "embedding router initialized with {} tools, dim={}, backend={}",
205            tool_embeddings.len(),
206            dim,
207            embedder.backend_name()
208        );
209
210        Some(Self {
211            tool_embeddings,
212            embedder,
213            outcome_stats: RwLock::new(AHashMap::new()),
214            dim,
215            apply_prefix_bonus,
216        })
217    }
218
219    /// Route a natural language query to a tool name and confidence score.
220    ///
221    /// Returns `("gnosis", 0.0)` for empty input or when no tool scores above
222    /// the minimum threshold.
223    #[must_use]
224    pub fn route(&self, query: &str) -> (String, f64) {
225        match self.route_with_margin(query) {
226            Some((t, c, _)) => (t, c),
227            None => ("gnosis".into(), 0.0),
228        }
229    }
230
231    /// Route a query, returning (tool, confidence, margin).
232    ///
233    /// `margin` is the score gap between the top-1 and top-2 tools. Small
234    /// margins indicate the descriptions cannot separate intent — callers
235    /// should defer to the TF-IDF router when `margin < MIN_MARGIN`.
236    #[must_use]
237    pub fn route_with_margin(&self, query: &str) -> Option<(String, f64, f64)> {
238        self.route_with_margin_and_embedding(query)
239            .map(|(tool, conf, margin, _)| (tool, conf, margin))
240    }
241
242    /// Route a query, also returning the query embedding.
243    ///
244    /// The embedding is what [`record_outcome_with_embedding`](Self::record_outcome_with_embedding)
245    /// needs — returning it here lets callers embed each query once instead of
246    /// twice (embedder HTTP round-trips dominate NLU latency).
247    #[must_use]
248    pub fn route_with_margin_and_embedding(
249        &self,
250        query: &str,
251    ) -> Option<(String, f64, f64, Vec<f32>)> {
252        let lower = query.to_lowercase();
253        if lower.trim().is_empty() {
254            return None;
255        }
256
257        let query_emb = match self.embedder.embed(&lower) {
258            Ok(emb) => emb,
259            Err(e) => {
260                tracing::warn!(error = %e, "embedding router: query embedding failed");
261                return None;
262            }
263        };
264
265        // Prefix route bonus — only on the legacy keyword-profile path, where
266        // descriptions are bare keyword lists and the verb bonus compensates.
267        // On the anchored path it fights the intent anchors ("list tools" was
268        // boosted toward memory.list despite tools.list carrying the anchor).
269        let prefix_bonus: Option<(&str, f64)> = if self.apply_prefix_bonus {
270            intent_bonus(&lower)
271        } else {
272            None
273        };
274
275        // Score each tool by cosine similarity to (optionally refined) embedding
276        let Ok(stats_lock) = self.outcome_stats.read() else {
277            return None;
278        };
279
280        let mut best_tool = "gnosis".to_string();
281        let mut best_score = 0.0_f64;
282        let mut second_tool = String::new();
283        let mut second_score = 0.0_f64;
284
285        for (name, base_emb) in &self.tool_embeddings {
286            let refined = self.oats_refine(name, base_emb, &stats_lock);
287            let mut score = f64::from(cosine_sim(&query_emb, &refined));
288
289            // Apply prefix routing: bonus to matching tool, penalty to non-matching
290            if let Some((bonus_tool, bonus)) = prefix_bonus {
291                if name == bonus_tool {
292                    score *= bonus;
293                } else {
294                    score /= bonus;
295                }
296            }
297
298            if score > best_score {
299                second_score = best_score;
300                second_tool.clone_from(&best_tool);
301                best_score = score;
302                best_tool.clone_from(name);
303            } else if score > second_score {
304                second_score = score;
305                second_tool.clone_from(name);
306            }
307        }
308
309        drop(stats_lock);
310
311        if best_score < MIN_THRESHOLD {
312            return None;
313        }
314
315        if best_score - second_score < MIN_MARGIN {
316            tracing::debug!(
317                query = %lower,
318                best_tool = %best_tool,
319                best_score,
320                second_tool = %second_tool,
321                second_score,
322                "embedding router: near-tie"
323            );
324        }
325
326        Some((best_tool, best_score, best_score - second_score, query_emb))
327    }
328
329    /// OATS: interpolate tool embedding toward success centroid.
330    ///
331    /// If we have enough success observations (≥ `OATS_MIN_OBSERVATIONS`),
332    /// blend the base embedding toward the success centroid by `OATS_ALPHA`.
333    /// Otherwise, return the base embedding unchanged.
334    fn oats_refine(
335        &self,
336        tool_name: &str,
337        base_emb: &[f32],
338        stats: &AHashMap<String, OutcomeStats>,
339    ) -> Vec<f32> {
340        if let Some(stat) = stats.get(tool_name) {
341            if stat.is_ready() && stat.success_centroid.len() == base_emb.len() {
342                return interpolate(base_emb, &stat.success_centroid, OATS_ALPHA);
343            }
344        }
345        base_emb.to_vec()
346    }
347
348    /// Record a routing outcome for OATS refinement.
349    ///
350    /// Call this after each tool dispatch to track whether the routing was
351    /// correct. `success = true` means the tool was the right choice and
352    /// executed successfully; `false` means it was wrong or failed.
353    pub fn record_outcome(&self, tool_name: &str, query: &str, success: bool) {
354        if query.trim().is_empty() {
355            return;
356        }
357        let query_emb = match self.embedder.embed(&query.to_lowercase()) {
358            Ok(emb) => emb,
359            Err(_) => return,
360        };
361        self.record_outcome_with_embedding(tool_name, query, success, &query_emb);
362    }
363
364    /// Record a routing outcome reusing a query embedding already computed by
365    /// the router.
366    ///
367    /// Callers that routed through [`route_with_margin`](Self::route_with_margin)
368    /// should pass the embedding back here so the query is embedded only once
369    /// instead of twice (HTTP embedder round-trips dominate NLU latency).
370    pub fn record_outcome_with_embedding(
371        &self,
372        tool_name: &str,
373        query: &str,
374        success: bool,
375        query_emb: &[f32],
376    ) {
377        if query.trim().is_empty() {
378            return;
379        }
380        let Ok(mut stats) = self.outcome_stats.write() else {
381            return;
382        };
383        let stat = stats
384            .entry(tool_name.to_string())
385            .or_insert_with(|| OutcomeStats::new(self.dim));
386        stat.record(query_emb, success);
387    }
388
389    /// Number of tool embeddings in the router.
390    #[must_use]
391    pub fn tool_count(&self) -> usize {
392        self.tool_embeddings.len()
393    }
394
395    /// Embedding dimensionality.
396    #[must_use]
397    pub const fn dimension(&self) -> usize {
398        self.dim
399    }
400
401    /// Embedder backend name.
402    #[must_use]
403    pub fn backend_name(&self) -> &str {
404        self.embedder.backend_name()
405    }
406
407    /// Get a snapshot of outcome stats counts for observability.
408    #[must_use]
409    pub fn outcome_counts(&self) -> Vec<(String, usize, usize)> {
410        let Ok(stats) = self.outcome_stats.read() else {
411            return Vec::new();
412        };
413        stats
414            .iter()
415            .map(|(name, s)| (name.clone(), s.success_count, s.failure_count))
416            .collect()
417    }
418
419    /// Serialize OATS outcome stats to JSON for persistence.
420    #[must_use]
421    #[allow(clippy::type_complexity)]
422    pub fn save_oats(&self) -> Option<String> {
423        let Ok(stats) = self.outcome_stats.read() else {
424            return None;
425        };
426        let serializable: Vec<(String, usize, usize, Vec<f32>, Vec<f32>)> = stats
427            .iter()
428            .map(|(name, s)| {
429                (
430                    name.clone(),
431                    s.success_count,
432                    s.failure_count,
433                    s.success_centroid.clone(),
434                    s.failure_centroid.clone(),
435                )
436            })
437            .collect();
438        serde_json::to_string_pretty(&serializable).ok()
439    }
440
441    /// Load OATS outcome stats from JSON (previously saved by `save_oats`).
442    pub fn load_oats(&self, json: &str) {
443        if let Ok(data) =
444            serde_json::from_str::<Vec<(String, usize, usize, Vec<f32>, Vec<f32>)>>(json)
445        {
446            let Ok(mut stats) = self.outcome_stats.write() else {
447                return;
448            };
449            for (name, success_count, failure_count, success_centroid, failure_centroid) in data {
450                let dim = success_centroid.len().max(self.dim);
451                let mut s = OutcomeStats::new(dim);
452                s.success_count = success_count;
453                s.failure_count = failure_count;
454                s.success_centroid = success_centroid;
455                s.failure_centroid = failure_centroid;
456                stats.insert(name, s);
457            }
458            tracing::info!("Loaded OATS outcome stats from disk");
459        }
460    }
461}
462
463// ── Shadow Mode Stats ────────────────────────────────────────────────
464
465/// Maximum number of disagreement samples to retain.
466const MAX_SAMPLES: usize = 50;
467
468/// A single disagreement sample between embedding router and TF-IDF.
469#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
470pub struct DisagreementSample {
471    pub query: String,
472    pub embedding_tool: String,
473    pub embedding_conf: f64,
474    pub tfidf_tool: String,
475    pub tfidf_conf: f64,
476}
477
478/// Shadow mode statistics tracking embedding vs TF-IDF disagreements.
479///
480/// Thread-safe via `RwLock`. Updated on every `classify_with_router` call
481/// when the embedding router is active.
482#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
483pub struct ShadowModeStats {
484    /// Total queries routed through shadow mode.
485    pub total_queries: u64,
486    /// Total disagreements (embedding chose different tool than TF-IDF).
487    pub total_disagreements: u64,
488    /// Per-tool disagreement counts: (embedding_tool, tfidf_tool) → count.
489    pub disagreement_pairs: std::collections::HashMap<String, u64>,
490    /// Recent disagreement samples (capped at MAX_SAMPLES).
491    pub samples: Vec<DisagreementSample>,
492}
493
494impl ShadowModeStats {
495    /// Record a routing comparison.
496    pub fn record(
497        &mut self,
498        query: &str,
499        emb_tool: &str,
500        emb_conf: f64,
501        tfidf_tool: &str,
502        tfidf_conf: f64,
503    ) {
504        self.total_queries += 1;
505        if emb_tool != tfidf_tool {
506            self.total_disagreements += 1;
507            let key = format!("{emb_tool} → {tfidf_tool}");
508            *self.disagreement_pairs.entry(key).or_insert(0) += 1;
509            if self.samples.len() >= MAX_SAMPLES {
510                self.samples.remove(0);
511            }
512            self.samples.push(DisagreementSample {
513                query: query.chars().take(200).collect(),
514                embedding_tool: emb_tool.to_string(),
515                embedding_conf: emb_conf,
516                tfidf_tool: tfidf_tool.to_string(),
517                tfidf_conf,
518            });
519        }
520    }
521
522    /// Disagreement rate (0.0–1.0).
523    #[must_use]
524    pub fn disagreement_rate(&self) -> f64 {
525        if self.total_queries == 0 {
526            0.0
527        } else {
528            self.total_disagreements as f64 / self.total_queries as f64
529        }
530    }
531
532    /// Whether the embedding router is ready for promotion to primary
533    /// (disagreement rate below 20% and enough samples).
534    #[must_use]
535    pub fn promotion_ready(&self) -> bool {
536        self.total_queries >= 100 && self.disagreement_rate() < 0.20
537    }
538
539    /// Generate a JSON report for the `nlu.shadow_report` tool.
540    #[must_use]
541    pub fn report(&self) -> serde_json::Value {
542        let mut pairs: Vec<(String, u64)> = self
543            .disagreement_pairs
544            .iter()
545            .map(|(k, v)| (k.clone(), *v))
546            .collect();
547        pairs.sort_by_key(|x| std::cmp::Reverse(x.1));
548
549        serde_json::json!({
550            "total_queries": self.total_queries,
551            "total_disagreements": self.total_disagreements,
552            "disagreement_rate": self.disagreement_rate(),
553            "promotion_ready": self.promotion_ready(),
554            "top_disagreement_pairs": pairs.iter().take(10).map(|(k, v)| {
555                serde_json::json!({"pair": k, "count": v})
556            }).collect::<Vec<_>>(),
557            "recent_samples": self.samples.iter().take(10).map(|s| {
558                serde_json::json!({
559                    "query": s.query,
560                    "embedding_tool": s.embedding_tool,
561                    "embedding_conf": s.embedding_conf,
562                    "tfidf_tool": s.tfidf_tool,
563                    "tfidf_conf": s.tfidf_conf,
564                })
565            }).collect::<Vec<_>>(),
566        })
567    }
568}
569
570// ── Helpers ──────────────────────────────────────────────────────────
571
572/// Cosine similarity between two f32 vectors.
573fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
574    if a.is_empty() || b.is_empty() || a.len() != b.len() {
575        return 0.0;
576    }
577
578    let mut dot = 0.0_f32;
579    let mut norm_a = 0.0_f32;
580    let mut norm_b = 0.0_f32;
581
582    for (x, y) in a.iter().zip(b.iter()) {
583        dot += x * y;
584        norm_a += x * x;
585        norm_b += y * y;
586    }
587
588    let denom = norm_a.sqrt() * norm_b.sqrt();
589    if denom == 0.0 { 0.0 } else { dot / denom }
590}
591
592/// Linear interpolation between two vectors: `base * (1 - α) + target * α`.
593fn interpolate(base: &[f32], target: &[f32], alpha: f32) -> Vec<f32> {
594    base.iter()
595        .zip(target.iter())
596        .map(|(b, t)| b * (1.0 - alpha) + t * alpha)
597        .collect()
598}
599
600/// Generate tool descriptions from the static TOOL_PROFILES.
601///
602/// Each description is the tool name followed by its keywords. This gives the
603/// embedder semantic content to work with. Example:
604///
605/// `"memory.create remember store save memorize record persist capture"`
606#[must_use]
607pub fn tool_descriptions() -> Vec<(String, String)> {
608    TOOL_PROFILES
609        .iter()
610        .map(|p| (p.tool_name.to_string(), profile_to_description(p)))
611        .collect()
612}
613
614/// Convert a ToolProfile into a description string for embedding.
615fn profile_to_description(profile: &ToolProfile) -> String {
616    let keywords: Vec<&str> = profile.keywords.iter().map(|(t, _)| *t).collect();
617    format!("{} {}", profile.tool_name, keywords.join(" "))
618}
619
620/// Curated phrase/verb intent for the legacy prefix-bonus path.
621///
622/// Multi-word intentions are checked first (they are explicit enough not to
623/// need semantic support), then the single-word command-verb table. Mirrors
624/// the TF-IDF classifier's routing so both NLU layers agree.
625fn intent_bonus(lower: &str) -> Option<(&'static str, f64)> {
626    let probe = lower.trim_start();
627    PHRASE_ROUTES
628        .iter()
629        .find(|(phrase, _, _)| probe.starts_with(phrase))
630        .map(|(_, tool, bonus)| (*tool, *bonus))
631        .or_else(|| {
632            let first_word = probe.split_whitespace().next().unwrap_or("");
633            PREFIX_ROUTES
634                .iter()
635                .find(|(verb, _, _)| *verb == first_word)
636                .map(|(_, tool, bonus)| (*tool, *bonus))
637        })
638}
639
640/// Intent anchors: natural-language phrasings users say when they mean a tool.
641///
642/// The registry's `description()` strings describe *what a tool does* (display
643/// prose) but rarely match how users phrase intent. The 2026-08-11 shadow runs
644/// showed top-1 cosine collapsing onto arbitrary tools for common phrasings
645/// ("show my karma" → karma.clear). Anchors are appended to the embedded text
646/// so the vector for each tool covers user phrasing, not just docstring prose.
647static INTENT_ANCHORS: &[(&str, &[&str])] = &[
648    // Memory
649    (
650        "memory.create",
651        &[
652            "remember that",
653            "store this note",
654            "save this thought",
655            "memorize this",
656            "keep this in memory",
657            "note that",
658            "record that",
659        ],
660    ),
661    (
662        "memory.read",
663        &[
664            "get memory by id",
665            "read this memory",
666            "recall what I said",
667            "fetch memory",
668        ],
669    ),
670    (
671        "memory.list",
672        &[
673            "list my memories",
674            "show my recent memories",
675            "what memories do I have",
676            "find memories about",
677            "memories in the codex galaxy",
678        ],
679    ),
680    (
681        "memory.search",
682        &[
683            "search my memories for",
684            "find memory about",
685            "memory search",
686            "search for rust",
687            "search memories",
688            "what do you remember about",
689            "what did we decide about",
690            "look up",
691        ],
692    ),
693    (
694        "memory.vector.search",
695        &[
696            "find memory about search",
697            "semantic search",
698            "similar memories",
699        ],
700    ),
701    (
702        "memory.count",
703        &["count my memories", "how many memories", "memory count"],
704    ),
705    ("memory.tags", &["what tags do I have", "show memory tags"]),
706    (
707        "memory.delete",
708        &["delete memory", "remove memory", "forget this memory"],
709    ),
710    // Galaxy
711    (
712        "galaxy.list",
713        &["list galaxies", "what galaxies exist", "show the galaxies"],
714    ),
715    (
716        "galaxy.stats",
717        &[
718            "galaxy stats",
719            "stats for the codex galaxy",
720            "how many memories are in",
721            "show galaxy info",
722        ],
723    ),
724    (
725        "galaxy.create",
726        &["create a new galaxy", "new galaxy called", "make a galaxy"],
727    ),
728    ("galaxy.health", &["check galaxy health", "galaxy health"]),
729    (
730        "galaxy.taxonomy",
731        &["gana taxonomy", "show the gana taxonomy"],
732    ),
733    // Session
734    ("session.start", &["start a session", "begin a new session"]),
735    ("session.end", &["end the session", "close the session"]),
736    (
737        "session.list",
738        &[
739            "what sessions do I have",
740            "list sessions",
741            "show session history",
742        ],
743    ),
744    (
745        "session.record",
746        &["record this session turn", "log this session turn"],
747    ),
748    (
749        "session.replay",
750        &["replay the session", "replay last session"],
751    ),
752    (
753        "session.recall",
754        &[
755            "recall the session context",
756            "session history",
757            "previous session",
758            "record that the server restarted",
759        ],
760    ),
761    (
762        "session.handoff",
763        &[
764            "hand off the session",
765            "transfer session",
766            "session handoff",
767        ],
768    ),
769    // Karma
770    (
771        "karma.report",
772        &[
773            "show my karma",
774            "karma status",
775            "check my karma",
776            "karma balance",
777            "karma report",
778            "karma ledger status",
779        ],
780    ),
781    (
782        "karma.history",
783        &["karma history", "past karma entries", "recent karma"],
784    ),
785    (
786        "karma.clear",
787        &["clear karma", "wipe karma", "reset karma", "purge karma"],
788    ),
789    (
790        "karma.verify_chain",
791        &[
792            "check the karma chain",
793            "verify chain integrity",
794            "karma chain",
795        ],
796    ),
797    (
798        "karma.anchor",
799        &["anchor the karma chain", "publish anchor", "merkle anchor"],
800    ),
801    // Friction / RSI
802    (
803        "friction.log",
804        &["log friction", "log an error", "log friction entry"],
805    ),
806    (
807        "friction.review",
808        &[
809            "review the friction log",
810            "review friction",
811            "friction review",
812        ],
813    ),
814    (
815        "friction.auto_log",
816        &["auto log friction", "automatically log friction"],
817    ),
818    (
819        "friction.resolve",
820        &["resolve friction", "resolve this friction"],
821    ),
822    (
823        "improve.proposals",
824        &[
825            "what proposals are active",
826            "improvement proposals",
827            "list proposals",
828        ],
829    ),
830    // Claims
831    (
832        "claims",
833        &[
834            "add a claim",
835            "resolve a claim",
836            "claims status",
837            "what claims are pending",
838            "list claims",
839        ],
840    ),
841    // Transaction
842    ("transaction.begin", &["begin a transaction"]),
843    ("transaction.commit", &["commit the transaction"]),
844    ("transaction.rollback", &["rollback the transaction"]),
845    // Tools / meta
846    (
847        "tools.list",
848        &[
849            "list tools",
850            "what tools do you have",
851            "tools list",
852            "list all tools",
853        ],
854    ),
855    (
856        "nlu.shadow_report",
857        &["nlu shadow report", "show shadow mode stats"],
858    ),
859    (
860        "nlu.classify",
861        &["nlu classification test", "classify this query"],
862    ),
863    (
864        "state.snapshot",
865        &[
866            "what is the brain wave state",
867            "brain wave state",
868            "current brain wave",
869        ],
870    ),
871    (
872        "system.stats",
873        &["system stats", "show resource usage", "system stats please"],
874    ),
875    (
876        "system.health",
877        &[
878            "health check",
879            "doctor check",
880            "run a health check",
881            "system health",
882        ],
883    ),
884    (
885        "galaxy.dashboard",
886        &["consciousness dashboard", "display the dashboard"],
887    ),
888    (
889        "consciousness.depth",
890        &["consciousness depth", "depth of consciousness"],
891    ),
892    // Web / research
893    (
894        "web.fetch",
895        &[
896            "fetch this webpage",
897            "fetch the url and summarize",
898            "fetch url",
899        ],
900    ),
901    ("web.search", &["search the web for", "web search"]),
902    (
903        "web.search_and_read",
904        &["search and read", "search the web and read"],
905    ),
906    ("web.deep_fetch", &["deep fetch", "deep fetch this page"]),
907    (
908        "research.topic",
909        &[
910            "research the topic of",
911            "research topic",
912            "do a deep search on",
913        ],
914    ),
915    (
916        "research.repo",
917        &["research a github repo", "research repo", "github repo"],
918    ),
919    (
920        "research.rabbit_hole",
921        &["rabbit hole research", "rabbit hole"],
922    ),
923    // Self-play
924    (
925        "simulation.calibrate",
926        &[
927            "calibrate my predictions",
928            "record a prediction",
929            "brier scorecard",
930            "resolve a forecast",
931        ],
932    ),
933    (
934        "selfplay.run",
935        &["run selfplay", "start selfplay", "run training"],
936    ),
937    ("selfplay.status", &["selfplay status", "training status"]),
938    (
939        "selfplay.export",
940        &["export training data", "export selfplay data"],
941    ),
942    // Simulation / imagination
943    (
944        "sim.mc",
945        &[
946            "run a simulation",
947            "monte carlo simulation",
948            "simulate this",
949        ],
950    ),
951    (
952        "imagine.scenario",
953        &["imagine a scenario", "scenario planning"],
954    ),
955    (
956        "imagine.reflect",
957        &["reflect on this scenario", "counterfactual replay"],
958    ),
959    (
960        "gnosis",
961        &[
962            "what is your gana",
963            "who are you",
964            "what do I know about the wm project",
965        ],
966    ),
967];
968
969/// Merge registry tool descriptions with intent anchors for embedding.
970///
971/// Each description becomes: `"<name>: <registry description> — users say:
972/// <anchors joined>"`. Tools without anchors keep their prose description.
973///
974/// Tools whose `description()` is the Gana-level fallback (the default
975/// `Tool::description()` returns `gana().description()`, so ~45 tools across
976/// the registry embed to one of 28 shared vectors — e.g. all conformal.* and
977/// selfmodel.* tools) get a synthesized description from their dotted name:
978/// `"conformal.monitor"` → `"conformal monitor — monitor conformal
979/// prediction coverage and drift"`. Without this the router's margin
980/// calculation collapses on families with shared Gana text.
981#[must_use]
982pub fn anchored_descriptions(tools: &[Arc<dyn wm_core::Tool>]) -> Vec<(String, String)> {
983    tools
984        .iter()
985        .map(|t| {
986            let name = t.name();
987            let gana_fallback = t.gana().description() == t.description();
988            let desc = if gana_fallback {
989                synthesize_description(name)
990            } else {
991                t.description().to_string()
992            };
993            let anchors = INTENT_ANCHORS
994                .iter()
995                .find(|(n, _)| *n == name)
996                .map(|(_, a)| a);
997            let text = match anchors {
998                Some(anchors) => format!("{name}: {desc} — users say: {}", anchors.join("; ")),
999                None => format!("{name}: {desc}"),
1000            };
1001            (name.to_string(), text)
1002        })
1003        .collect()
1004}
1005
1006/// Build a description from a dotted tool name when the tool has no explicit
1007/// description (falls back to its Gana's generic text).
1008///
1009/// `"conformal.monitor"` → `"conformal monitor — monitor conformal prediction
1010/// coverage and drift"`. The family verb (the last segment) is repeated as a
1011/// verb so the embedded text carries tool-specific intent instead of the
1012/// shared Gana vector.
1013fn synthesize_description(name: &str) -> String {
1014    let parts: Vec<&str> = name.split('.').collect();
1015    if parts.len() < 2 {
1016        return format!("{name} — {name} operations");
1017    }
1018    let family = parts[..parts.len() - 1].join(" ");
1019    let verb = parts[parts.len() - 1];
1020    let verb_hyphen = verb.replace('_', "-");
1021    format!("{family} {verb_hyphen} — {family} {verb} operations and status")
1022}
1023
1024// ── Tests ────────────────────────────────────────────────────────────
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029
1030    // --- Unit tests for helper functions ---
1031
1032    #[test]
1033    fn cosine_sim_identical_vectors() {
1034        let v = vec![1.0, 2.0, 3.0];
1035        let sim = cosine_sim(&v, &v);
1036        assert!(
1037            (sim - 1.0).abs() < 1e-5,
1038            "identical vectors should have sim=1.0, got {sim}"
1039        );
1040    }
1041
1042    #[test]
1043    fn cosine_sim_orthogonal_vectors() {
1044        let a = vec![1.0, 0.0];
1045        let b = vec![0.0, 1.0];
1046        let sim = cosine_sim(&a, &b);
1047        assert!(
1048            sim.abs() < 1e-5,
1049            "orthogonal vectors should have sim=0.0, got {sim}"
1050        );
1051    }
1052
1053    #[test]
1054    fn cosine_sim_empty_vectors() {
1055        let sim = cosine_sim(&[], &[]);
1056        assert_eq!(sim, 0.0);
1057    }
1058
1059    #[test]
1060    fn cosine_sim_different_lengths() {
1061        let a = vec![1.0, 2.0];
1062        let b = vec![1.0, 2.0, 3.0];
1063        let sim = cosine_sim(&a, &b);
1064        assert_eq!(sim, 0.0, "different-length vectors should return 0.0");
1065    }
1066
1067    #[test]
1068    fn intent_bonus_prefers_phrases_then_verbs() {
1069        assert_eq!(
1070            intent_bonus("what do you remember about the quartz submarine"),
1071            Some(("memory.search", 1.5))
1072        );
1073        assert_eq!(
1074            intent_bonus("look up the quartz submarine"),
1075            Some(("memory.search", 1.4))
1076        );
1077        assert_eq!(
1078            intent_bonus("recall the last memory"),
1079            Some(("memory.search", 1.5))
1080        );
1081        assert_eq!(intent_bonus("remember this"), Some(("memory.create", 1.5)));
1082        assert_eq!(intent_bonus("xyzzy frobnicate"), None);
1083    }
1084
1085    #[test]
1086    fn interpolate_midpoint() {
1087        let base = vec![0.0, 0.0];
1088        let target = vec![10.0, 20.0];
1089        let result = interpolate(&base, &target, 0.5);
1090        assert!((result[0] - 5.0).abs() < 1e-5);
1091        assert!((result[1] - 10.0).abs() < 1e-5);
1092    }
1093
1094    #[test]
1095    fn interpolate_zero_alpha_returns_base() {
1096        let base = vec![1.0, 2.0, 3.0];
1097        let target = vec![10.0, 20.0, 30.0];
1098        let result = interpolate(&base, &target, 0.0);
1099        assert_eq!(result, base);
1100    }
1101
1102    #[test]
1103    fn interpolate_one_alpha_returns_target() {
1104        let base = vec![1.0, 2.0, 3.0];
1105        let target = vec![10.0, 20.0, 30.0];
1106        let result = interpolate(&base, &target, 1.0);
1107        assert_eq!(result, target);
1108    }
1109
1110    // --- OutcomeStats tests ---
1111
1112    #[test]
1113    fn outcome_stats_starts_empty() {
1114        let stats = OutcomeStats::new(384);
1115        assert_eq!(stats.success_count, 0);
1116        assert_eq!(stats.failure_count, 0);
1117        assert!(!stats.is_ready());
1118    }
1119
1120    #[test]
1121    fn outcome_stats_records_success() {
1122        let mut stats = OutcomeStats::new(4);
1123        stats.record(&[1.0, 0.0, 0.0, 0.0], true);
1124        assert_eq!(stats.success_count, 1);
1125        assert_eq!(stats.failure_count, 0);
1126    }
1127
1128    #[test]
1129    fn outcome_stats_records_failure() {
1130        let mut stats = OutcomeStats::new(4);
1131        stats.record(&[0.0, 1.0, 0.0, 0.0], false);
1132        assert_eq!(stats.success_count, 0);
1133        assert_eq!(stats.failure_count, 1);
1134    }
1135
1136    #[test]
1137    fn outcome_stats_centroid_converges() {
1138        let mut stats = OutcomeStats::new(2);
1139        // Record 3 successes at the same point
1140        for _ in 0..3 {
1141            stats.record(&[1.0, 0.0], true);
1142        }
1143        // Centroid should converge to [1.0, 0.0]
1144        assert!((stats.success_centroid[0] - 1.0).abs() < 1e-3);
1145        assert!(stats.success_centroid[1].abs() < 1e-3);
1146    }
1147
1148    #[test]
1149    fn outcome_stats_becomes_ready_after_min_observations() {
1150        let mut stats = OutcomeStats::new(2);
1151        for _ in 0..OATS_MIN_OBSERVATIONS {
1152            stats.record(&[1.0, 0.0], true);
1153        }
1154        assert!(stats.is_ready());
1155    }
1156
1157    #[test]
1158    fn outcome_stats_ignores_empty_embedding() {
1159        let mut stats = OutcomeStats::new(4);
1160        stats.record(&[], true);
1161        assert_eq!(stats.success_count, 0);
1162    }
1163
1164    // --- Tool description generation tests ---
1165
1166    #[test]
1167    fn tool_descriptions_non_empty() {
1168        let descs = tool_descriptions();
1169        assert!(
1170            !descs.is_empty(),
1171            "should have descriptions for all profiles"
1172        );
1173        assert!(
1174            descs.len() >= 60,
1175            "expected 60+ descriptions, got {}",
1176            descs.len()
1177        );
1178    }
1179
1180    #[test]
1181    fn tool_descriptions_contain_tool_name() {
1182        let descs = tool_descriptions();
1183        for (name, desc) in &descs {
1184            assert!(
1185                desc.starts_with(name),
1186                "description for '{name}' should start with the tool name, got: {desc}"
1187            );
1188        }
1189    }
1190
1191    #[test]
1192    fn tool_descriptions_contain_keywords() {
1193        let descs = tool_descriptions();
1194        let memory_create = descs.iter().find(|(n, _)| n == "memory.create");
1195        assert!(memory_create.is_some());
1196        let (_, desc) = memory_create.unwrap();
1197        assert!(
1198            desc.contains("remember"),
1199            "memory.create description should contain 'remember'"
1200        );
1201        assert!(
1202            desc.contains("store"),
1203            "memory.create description should contain 'store'"
1204        );
1205    }
1206
1207    #[test]
1208    fn tool_descriptions_are_unique() {
1209        let descs = tool_descriptions();
1210        let names: Vec<&str> = descs.iter().map(|(n, _)| n.as_str()).collect();
1211        let set: std::collections::HashSet<&str> = names.iter().copied().collect();
1212        assert_eq!(
1213            names.len(),
1214            set.len(),
1215            "duplicate tool names in descriptions"
1216        );
1217    }
1218
1219    // --- EmbeddingRouter with stub embedder ---
1220
1221    #[test]
1222    fn embedding_router_returns_none_for_stub() {
1223        let stub = Box::new(wm_memory::StubEmbedder::default());
1224        let router = EmbeddingRouter::new(stub);
1225        assert!(
1226            router.is_none(),
1227            "embedding router should return None for stub embedder"
1228        );
1229    }
1230
1231    #[test]
1232    fn embedding_router_with_descriptions_covers_registry_tools() {
1233        let embedder = Box::new(KeywordEmbedder::new(vec![
1234            "memory", "karma", "session", "list",
1235        ]));
1236        let descriptions = vec![
1237            (
1238                "memory.create".to_string(),
1239                "remember and store information in persistent memory".to_string(),
1240            ),
1241            (
1242                "karma.clear".to_string(),
1243                "wipe and reset the karma ledger entries".to_string(),
1244            ),
1245            (
1246                "session.list".to_string(),
1247                "list all recorded sessions".to_string(),
1248            ),
1249        ];
1250        let router =
1251            EmbeddingRouter::with_descriptions(embedder, descriptions).expect("should init");
1252        assert_eq!(router.tool_count(), 3);
1253        let (tool, _) = router.route("show me the sessions");
1254        assert_eq!(
1255            tool, "session.list",
1256            "registry-description routing should find session.list"
1257        );
1258    }
1259
1260    #[test]
1261    fn route_with_margin_returns_positive_margin() {
1262        let keywords: Vec<&str> = TOOL_PROFILES
1263            .iter()
1264            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1265            .collect::<std::collections::HashSet<_>>()
1266            .into_iter()
1267            .collect();
1268        let embedder = Box::new(KeywordEmbedder::new(keywords));
1269        let router = EmbeddingRouter::new(embedder).expect("should init");
1270
1271        let (tool, conf, margin) = router
1272            .route_with_margin("remember that the sky is blue")
1273            .expect("clear match should return Some");
1274        assert_eq!(tool, "memory.create");
1275        assert!(conf > 0.0);
1276        assert!(margin >= 0.0, "margin should be non-negative");
1277    }
1278
1279    // --- Mock embedder for testing ---
1280
1281    /// A test embedder that generates simple keyword-based embeddings.
1282    /// Each dimension corresponds to a keyword — if the text contains the
1283    /// keyword, that dimension is 1.0, otherwise 0.0. This provides basic
1284    /// semantic similarity for testing without a real embedder.
1285    struct KeywordEmbedder {
1286        keywords: Vec<String>,
1287        dim: usize,
1288    }
1289
1290    impl KeywordEmbedder {
1291        fn new(keywords: Vec<&str>) -> Self {
1292            let dim = keywords.len();
1293            Self {
1294                keywords: keywords.into_iter().map(String::from).collect(),
1295                dim,
1296            }
1297        }
1298
1299        fn embed_text(&self, text: &str) -> Vec<f32> {
1300            let lower = text.to_lowercase();
1301            self.keywords
1302                .iter()
1303                .map(|kw| {
1304                    if lower.contains(&kw.to_lowercase()) {
1305                        1.0
1306                    } else {
1307                        0.0
1308                    }
1309                })
1310                .collect()
1311        }
1312    }
1313
1314    impl Embedder for KeywordEmbedder {
1315        fn embed_batch(&self, texts: &[&str]) -> wm_core::Result<Vec<Vec<f32>>> {
1316            Ok(texts.iter().map(|t| self.embed_text(t)).collect())
1317        }
1318
1319        fn dimension(&self) -> usize {
1320            self.dim
1321        }
1322
1323        fn is_available(&self) -> bool {
1324            true
1325        }
1326
1327        fn backend_name(&self) -> &'static str {
1328            "keyword-test"
1329        }
1330    }
1331
1332    #[test]
1333    fn embedding_router_works_with_keyword_embedder() {
1334        let keywords: Vec<&str> = TOOL_PROFILES
1335            .iter()
1336            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1337            .collect::<std::collections::HashSet<_>>()
1338            .into_iter()
1339            .collect();
1340        let embedder = Box::new(KeywordEmbedder::new(keywords));
1341        let router = EmbeddingRouter::new(embedder).expect("should init with keyword embedder");
1342
1343        assert!(router.tool_count() >= 60);
1344        assert!(router.dimension() > 0);
1345        assert_eq!(router.backend_name(), "keyword-test");
1346    }
1347
1348    #[test]
1349    fn embedding_router_routes_remember_to_memory_create() {
1350        let keywords: Vec<&str> = TOOL_PROFILES
1351            .iter()
1352            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1353            .collect::<std::collections::HashSet<_>>()
1354            .into_iter()
1355            .collect();
1356        let embedder = Box::new(KeywordEmbedder::new(keywords));
1357        let router = EmbeddingRouter::new(embedder).expect("should init");
1358
1359        let (tool, conf) = router.route("remember that the sky is blue");
1360        assert_eq!(tool, "memory.create");
1361        assert!(
1362            conf > 0.0,
1363            "confidence should be > 0 for clear match, got {conf}"
1364        );
1365    }
1366
1367    #[test]
1368    fn embedding_router_routes_search_to_memory_search() {
1369        let keywords: Vec<&str> = TOOL_PROFILES
1370            .iter()
1371            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1372            .collect::<std::collections::HashSet<_>>()
1373            .into_iter()
1374            .collect();
1375        let embedder = Box::new(KeywordEmbedder::new(keywords));
1376        let router = EmbeddingRouter::new(embedder).expect("should init");
1377
1378        let (tool, conf) = router.route("search for rust");
1379        assert_eq!(tool, "memory.search");
1380        assert!(conf > 0.0);
1381    }
1382
1383    #[test]
1384    fn embedding_router_empty_returns_gnosis() {
1385        let keywords: Vec<&str> = TOOL_PROFILES
1386            .iter()
1387            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1388            .collect::<std::collections::HashSet<_>>()
1389            .into_iter()
1390            .collect();
1391        let embedder = Box::new(KeywordEmbedder::new(keywords));
1392        let router = EmbeddingRouter::new(embedder).expect("should init");
1393
1394        let (tool, conf) = router.route("");
1395        assert_eq!(tool, "gnosis");
1396        assert_eq!(conf, 0.0);
1397    }
1398
1399    #[test]
1400    fn embedding_router_whitespace_returns_gnosis() {
1401        let keywords: Vec<&str> = TOOL_PROFILES
1402            .iter()
1403            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1404            .collect::<std::collections::HashSet<_>>()
1405            .into_iter()
1406            .collect();
1407        let embedder = Box::new(KeywordEmbedder::new(keywords));
1408        let router = EmbeddingRouter::new(embedder).expect("should init");
1409
1410        let (tool, conf) = router.route("   ");
1411        assert_eq!(tool, "gnosis");
1412        assert_eq!(conf, 0.0);
1413    }
1414
1415    #[test]
1416    fn embedding_router_unknown_returns_gnosis() {
1417        let keywords: Vec<&str> = TOOL_PROFILES
1418            .iter()
1419            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1420            .collect::<std::collections::HashSet<_>>()
1421            .into_iter()
1422            .collect();
1423        let embedder = Box::new(KeywordEmbedder::new(keywords));
1424        let router = EmbeddingRouter::new(embedder).expect("should init");
1425
1426        let (tool, _conf) = router.route("xyzzy frobnicate");
1427        assert_eq!(tool, "gnosis");
1428    }
1429
1430    #[test]
1431    fn embedding_router_record_outcome_updates_stats() {
1432        let keywords: Vec<&str> = TOOL_PROFILES
1433            .iter()
1434            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1435            .collect::<std::collections::HashSet<_>>()
1436            .into_iter()
1437            .collect();
1438        let embedder = Box::new(KeywordEmbedder::new(keywords));
1439        let router = EmbeddingRouter::new(embedder).expect("should init");
1440
1441        // Record some outcomes
1442        router.record_outcome("memory.create", "remember that rust is fast", true);
1443        router.record_outcome("memory.create", "store this fact", true);
1444        router.record_outcome("memory.search", "search for rust", false);
1445
1446        let counts = router.outcome_counts();
1447        let memory_create = counts.iter().find(|(n, _, _)| n == "memory.create");
1448        assert!(memory_create.is_some());
1449        let (_, success, failure) = memory_create.unwrap();
1450        assert_eq!(*success, 2);
1451        assert_eq!(*failure, 0);
1452    }
1453
1454    #[test]
1455    fn embedding_router_record_outcome_ignores_empty_query() {
1456        let keywords: Vec<&str> = TOOL_PROFILES
1457            .iter()
1458            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1459            .collect::<std::collections::HashSet<_>>()
1460            .into_iter()
1461            .collect();
1462        let embedder = Box::new(KeywordEmbedder::new(keywords));
1463        let router = EmbeddingRouter::new(embedder).expect("should init");
1464
1465        router.record_outcome("memory.create", "", true);
1466        let counts = router.outcome_counts();
1467        assert!(
1468            counts.is_empty(),
1469            "empty query should not create outcome stats"
1470        );
1471    }
1472
1473    #[test]
1474    fn embedding_router_oats_refine_improves_routing() {
1475        let keywords: Vec<&str> = TOOL_PROFILES
1476            .iter()
1477            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1478            .collect::<std::collections::HashSet<_>>()
1479            .into_iter()
1480            .collect();
1481        let embedder = Box::new(KeywordEmbedder::new(keywords));
1482        let router = EmbeddingRouter::new(embedder).expect("should init");
1483
1484        // Record many successes for memory.create with "save" queries
1485        for _ in 0..15 {
1486            router.record_outcome("memory.create", "save this important fact", true);
1487        }
1488
1489        // Now "save this important fact" should route to memory.create with high confidence
1490        let (tool, conf) = router.route("save this important fact");
1491        assert_eq!(tool, "memory.create");
1492        assert!(
1493            conf > 0.0,
1494            "OATS-refined routing should still match, got conf={conf}"
1495        );
1496    }
1497
1498    // --- A/B comparison: embedding router vs TF-IDF on key test cases ---
1499
1500    #[test]
1501    fn ab_comparison_remember() {
1502        let keywords: Vec<&str> = TOOL_PROFILES
1503            .iter()
1504            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1505            .collect::<std::collections::HashSet<_>>()
1506            .into_iter()
1507            .collect();
1508        let embedder = Box::new(KeywordEmbedder::new(keywords));
1509        let router = EmbeddingRouter::new(embedder).expect("should init");
1510
1511        let query = "remember that the sky is blue";
1512        let (emb_tool, emb_conf) = router.route(query);
1513        let (tfidf_tool, tfidf_conf) = crate::nlu::classify(query);
1514
1515        assert_eq!(
1516            emb_tool, tfidf_tool,
1517            "embedding and TF-IDF should agree on '{query}'"
1518        );
1519        assert!(emb_conf > 0.0 && tfidf_conf > 0.0);
1520    }
1521
1522    #[test]
1523    fn ab_comparison_search() {
1524        let keywords: Vec<&str> = TOOL_PROFILES
1525            .iter()
1526            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1527            .collect::<std::collections::HashSet<_>>()
1528            .into_iter()
1529            .collect();
1530        let embedder = Box::new(KeywordEmbedder::new(keywords));
1531        let router = EmbeddingRouter::new(embedder).expect("should init");
1532
1533        let query = "search for rust";
1534        let (emb_tool, emb_conf) = router.route(query);
1535        let (tfidf_tool, _) = crate::nlu::classify(query);
1536
1537        assert_eq!(
1538            emb_tool, tfidf_tool,
1539            "embedding and TF-IDF should agree on '{query}'"
1540        );
1541        assert!(emb_conf > 0.0);
1542    }
1543
1544    #[test]
1545    fn ab_comparison_delete() {
1546        let keywords: Vec<&str> = TOOL_PROFILES
1547            .iter()
1548            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1549            .collect::<std::collections::HashSet<_>>()
1550            .into_iter()
1551            .collect();
1552        let embedder = Box::new(KeywordEmbedder::new(keywords));
1553        let router = EmbeddingRouter::new(embedder).expect("should init");
1554
1555        let query = "delete memory abc-123";
1556        let (emb_tool, _) = router.route(query);
1557        let (tfidf_tool, _) = crate::nlu::classify(query);
1558
1559        assert_eq!(
1560            emb_tool, tfidf_tool,
1561            "embedding and TF-IDF should agree on '{query}'"
1562        );
1563    }
1564
1565    #[test]
1566    fn ab_comparison_karma() {
1567        let keywords: Vec<&str> = TOOL_PROFILES
1568            .iter()
1569            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1570            .collect::<std::collections::HashSet<_>>()
1571            .into_iter()
1572            .collect();
1573        let embedder = Box::new(KeywordEmbedder::new(keywords));
1574        let router = EmbeddingRouter::new(embedder).expect("should init");
1575
1576        let query = "show me the karma report";
1577        let (emb_tool, _) = router.route(query);
1578        let (tfidf_tool, _) = crate::nlu::classify(query);
1579
1580        assert_eq!(
1581            emb_tool, tfidf_tool,
1582            "embedding and TF-IDF should agree on '{query}'"
1583        );
1584    }
1585
1586    // ── ShadowModeStats tests ─────────────────────────────────────────
1587
1588    #[test]
1589    fn shadow_stats_record_agreement() {
1590        let mut stats = ShadowModeStats::default();
1591        stats.record("test query", "memory.create", 0.9, "memory.create", 0.8);
1592        assert_eq!(stats.total_queries, 1);
1593        assert_eq!(stats.total_disagreements, 0);
1594        assert!(stats.samples.is_empty());
1595    }
1596
1597    #[test]
1598    fn shadow_stats_record_disagreement() {
1599        let mut stats = ShadowModeStats::default();
1600        stats.record("test query", "memory.create", 0.9, "memory.list", 0.7);
1601        assert_eq!(stats.total_queries, 1);
1602        assert_eq!(stats.total_disagreements, 1);
1603        assert_eq!(stats.samples.len(), 1);
1604        assert_eq!(stats.samples[0].embedding_tool, "memory.create");
1605        assert_eq!(stats.samples[0].tfidf_tool, "memory.list");
1606    }
1607
1608    #[test]
1609    fn shadow_stats_disagreement_rate() {
1610        let mut stats = ShadowModeStats::default();
1611        for _ in 0..8 {
1612            stats.record("agree", "memory.create", 0.9, "memory.create", 0.8);
1613        }
1614        for _ in 0..2 {
1615            stats.record("disagree", "memory.create", 0.9, "memory.list", 0.7);
1616        }
1617        assert_eq!(stats.total_queries, 10);
1618        assert_eq!(stats.total_disagreements, 2);
1619        assert!((stats.disagreement_rate() - 0.2).abs() < 0.001);
1620    }
1621
1622    #[test]
1623    fn shadow_stats_promotion_ready_threshold() {
1624        let mut stats = ShadowModeStats::default();
1625        // Not enough queries
1626        for _ in 0..99 {
1627            stats.record("agree", "memory.create", 0.9, "memory.create", 0.8);
1628        }
1629        assert!(!stats.promotion_ready());
1630
1631        // Enough queries, low disagreement
1632        stats.record("agree", "memory.create", 0.9, "memory.create", 0.8);
1633        assert!(stats.promotion_ready());
1634
1635        // Too many disagreements (25 out of 125 = 0.20, not < 0.20)
1636        for _ in 0..25 {
1637            stats.record("disagree", "memory.create", 0.9, "memory.list", 0.7);
1638        }
1639        assert!(!stats.promotion_ready());
1640    }
1641
1642    #[test]
1643    fn shadow_stats_report_json() {
1644        let mut stats = ShadowModeStats::default();
1645        stats.record("test", "memory.create", 0.9, "memory.list", 0.7);
1646        let report = stats.report();
1647        assert_eq!(report["total_queries"], 1);
1648        assert_eq!(report["total_disagreements"], 1);
1649        assert!(report["promotion_ready"].is_boolean());
1650        assert!(report["recent_samples"].is_array());
1651    }
1652
1653    #[test]
1654    fn shadow_stats_samples_capped() {
1655        let mut stats = ShadowModeStats::default();
1656        for i in 0..100 {
1657            stats.record(
1658                &format!("query {i}"),
1659                "memory.create",
1660                0.9,
1661                "memory.list",
1662                0.7,
1663            );
1664        }
1665        assert_eq!(stats.samples.len(), 50); // MAX_SAMPLES
1666    }
1667
1668    #[test]
1669    fn shadow_stats_serialization_roundtrip() {
1670        let mut stats = ShadowModeStats::default();
1671        stats.record("test", "memory.create", 0.9, "memory.list", 0.7);
1672        stats.record("another", "gnosis", 0.1, "gnosis", 0.1);
1673        let json = serde_json::to_string(&stats).unwrap();
1674        let deserialized: ShadowModeStats = serde_json::from_str(&json).unwrap();
1675        assert_eq!(deserialized.total_queries, 2);
1676        assert_eq!(deserialized.total_disagreements, 1);
1677        assert_eq!(deserialized.samples.len(), 1);
1678    }
1679
1680    #[test]
1681    fn oats_persistence_roundtrip() {
1682        let keywords: Vec<&str> = TOOL_PROFILES
1683            .iter()
1684            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1685            .collect::<std::collections::HashSet<_>>()
1686            .into_iter()
1687            .collect();
1688        let embedder = Box::new(KeywordEmbedder::new(keywords));
1689        let router = EmbeddingRouter::new(embedder).expect("should init");
1690
1691        // Record some outcomes
1692        router.record_outcome("memory.create", "create a memory", true);
1693        router.record_outcome("memory.create", "store this", true);
1694        router.record_outcome("memory.list", "list memories", true);
1695
1696        // Save
1697        let saved = router.save_oats().expect("should serialize");
1698
1699        // Load into a new router
1700        let keywords2: Vec<&str> = TOOL_PROFILES
1701            .iter()
1702            .flat_map(|p| p.keywords.iter().map(|(t, _)| *t))
1703            .collect::<std::collections::HashSet<_>>()
1704            .into_iter()
1705            .collect();
1706        let embedder2 = Box::new(KeywordEmbedder::new(keywords2));
1707        let router2 = EmbeddingRouter::new(embedder2).expect("should init");
1708        router2.load_oats(&saved);
1709
1710        let counts1 = router.outcome_counts();
1711        let counts2 = router2.outcome_counts();
1712        assert_eq!(counts1.len(), counts2.len());
1713        for (name, success, failure) in &counts1 {
1714            let match_found = counts2
1715                .iter()
1716                .any(|(n, s, f)| n == name && s == success && f == failure);
1717            assert!(
1718                match_found,
1719                "OATS data should match after roundtrip for {name}"
1720            );
1721        }
1722    }
1723}