1use ahash::AHashMap;
28use std::sync::{Arc, RwLock};
29use wm_memory::Embedder;
30
31use crate::nlu::{PHRASE_ROUTES, PREFIX_ROUTES, TOOL_PROFILES, ToolProfile};
32
33const OATS_ALPHA: f32 = 0.15;
35
36const OATS_MIN_OBSERVATIONS: usize = 10;
38
39const MIN_THRESHOLD: f64 = 0.10;
41
42pub const MIN_MARGIN: f64 = 0.02;
50
51#[derive(Debug, Clone)]
53pub struct OutcomeStats {
54 success_centroid: Vec<f32>,
56 #[allow(dead_code)]
58 failure_centroid: Vec<f32>,
59 success_count: usize,
61 failure_count: usize,
63}
64
65impl OutcomeStats {
66 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 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 const fn is_ready(&self) -> bool {
99 self.success_count >= OATS_MIN_OBSERVATIONS
100 }
101}
102
103fn 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
115pub struct EmbeddingRouter {
122 tool_embeddings: AHashMap<String, Vec<f32>>,
124 embedder: Box<dyn Embedder>,
126 outcome_stats: RwLock<AHashMap<String, OutcomeStats>>,
128 dim: usize,
130 apply_prefix_bonus: bool,
138}
139
140impl EmbeddingRouter {
141 #[must_use]
149 pub fn new(embedder: Box<dyn Embedder>) -> Option<Self> {
150 Self::new_with_descriptions(embedder, tool_descriptions(), true)
151 }
152
153 #[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 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 #[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 #[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 #[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 let prefix_bonus: Option<(&str, f64)> = if self.apply_prefix_bonus {
270 intent_bonus(&lower)
271 } else {
272 None
273 };
274
275 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 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 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 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 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 #[must_use]
391 pub fn tool_count(&self) -> usize {
392 self.tool_embeddings.len()
393 }
394
395 #[must_use]
397 pub const fn dimension(&self) -> usize {
398 self.dim
399 }
400
401 #[must_use]
403 pub fn backend_name(&self) -> &str {
404 self.embedder.backend_name()
405 }
406
407 #[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 #[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 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
463const MAX_SAMPLES: usize = 50;
467
468#[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#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
483pub struct ShadowModeStats {
484 pub total_queries: u64,
486 pub total_disagreements: u64,
488 pub disagreement_pairs: std::collections::HashMap<String, u64>,
490 pub samples: Vec<DisagreementSample>,
492}
493
494impl ShadowModeStats {
495 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 #[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 #[must_use]
535 pub fn promotion_ready(&self) -> bool {
536 self.total_queries >= 100 && self.disagreement_rate() < 0.20
537 }
538
539 #[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
570fn 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
592fn 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#[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
614fn 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
620fn 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
640static INTENT_ANCHORS: &[(&str, &[&str])] = &[
648 (
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 (
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.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 (
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 (
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 (
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.begin", &["begin a transaction"]),
843 ("transaction.commit", &["commit the transaction"]),
844 ("transaction.rollback", &["rollback the transaction"]),
845 (
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 (
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 (
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 (
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#[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
1006fn 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#[cfg(test)]
1027mod tests {
1028 use super::*;
1029
1030 #[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 #[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 for _ in 0..3 {
1141 stats.record(&[1.0, 0.0], true);
1142 }
1143 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 #[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 #[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 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 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 for _ in 0..15 {
1486 router.record_outcome("memory.create", "save this important fact", true);
1487 }
1488
1489 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 #[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 #[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 for _ in 0..99 {
1627 stats.record("agree", "memory.create", 0.9, "memory.create", 0.8);
1628 }
1629 assert!(!stats.promotion_ready());
1630
1631 stats.record("agree", "memory.create", 0.9, "memory.create", 0.8);
1633 assert!(stats.promotion_ready());
1634
1635 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); }
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 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 let saved = router.save_oats().expect("should serialize");
1698
1699 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}