Skip to main content

ratel_ai_core/
fact_registry.rs

1use std::sync::Arc;
2use std::time::Instant;
3
4use indexmap::IndexMap;
5
6use crate::dense_cache::{DenseCache, Embeddable};
7use crate::embedding::EmbedderError;
8use crate::embedding_config::EmbeddingModel;
9use crate::fact::{Fact, PinMode};
10use crate::fact_indexing::searchable_text;
11use crate::fusion::{RETRIEVE_DEPTH, RRF_K, rrf_fuse_weighted, sort_and_truncate};
12use crate::method::SearchMethod;
13use crate::search::Bm25Cache;
14use crate::trace::{ChurnKind, FactHitTrace, NoopSink, Origin, SearchStage, TraceEvent, TraceSink};
15
16/// One ranked match from a [`FactRegistry`] search, best-first in the returned
17/// `Vec` — the fact-side twin of [`crate::SkillHit`].
18pub struct FactHit {
19    /// Id of the matching fact ([`Fact::id`]).
20    pub fact_id: String,
21    /// Relevance score — higher is better; the scale depends on the
22    /// [`SearchMethod`] exactly as documented on [`crate::SearchHit::score`].
23    /// Ties break by `fact_id` ascending.
24    pub score: f32,
25}
26
27impl Embeddable for Fact {
28    fn embed_id(&self) -> &str {
29        &self.id
30    }
31    fn embed_text(&self) -> String {
32        searchable_text(self)
33    }
34}
35
36/// Retrieval index over [`Fact`]s — the push-path analog of
37/// [`crate::SkillRegistry`]. Same selectable BM25/semantic/hybrid engines; a
38/// parallel type keeps the skill path untouched and lets fact telemetry
39/// (`fact_search` / `fact_churn` / `fact_inject`) stand on its own.
40pub struct FactRegistry {
41    /// Corpus keyed by fact id, in insertion order — the fact-side twin of
42    /// [`crate::SkillRegistry`]'s field. `register` replaces an existing id in
43    /// place, never duplicating it. Insertion order is also the order
44    /// [`Self::pinned`] injects always-on facts in.
45    facts: IndexMap<String, Fact>,
46    sink: Arc<dyn TraceSink>,
47    experimental_catalog_definitions: bool,
48    /// Prebuilt BM25 index over `facts`, cached across searches and invalidated
49    /// on any mutation of the indexed text (mirrors the skill/tool registries).
50    bm25: Bm25Cache,
51    /// Dense embeddings for `facts`, keyed by id and built on demand.
52    dense: DenseCache,
53}
54
55impl Default for FactRegistry {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl FactRegistry {
62    /// An empty registry with tracing off ([`NoopSink`]) — see
63    /// [`crate::SkillRegistry::new`].
64    pub fn new() -> Self {
65        Self {
66            facts: IndexMap::new(),
67            sink: Arc::new(NoopSink),
68            experimental_catalog_definitions: false,
69            bm25: Bm25Cache::new(),
70            dense: DenseCache::new(),
71        }
72    }
73
74    /// An empty registry recording trace events to `sink` from the start.
75    pub fn with_trace_sink(sink: Arc<dyn TraceSink>) -> Self {
76        Self {
77            facts: IndexMap::new(),
78            sink,
79            experimental_catalog_definitions: false,
80            bm25: Bm25Cache::new(),
81            dense: DenseCache::new(),
82        }
83    }
84
85    /// A registry whose semantic/hybrid engines use an explicit embedding model.
86    /// BM25 is unaffected. See [`crate::SkillRegistry::with_embedding`].
87    pub fn with_embedding(model: EmbeddingModel) -> Self {
88        Self {
89            facts: IndexMap::new(),
90            sink: Arc::new(NoopSink),
91            experimental_catalog_definitions: false,
92            bm25: Bm25Cache::new(),
93            dense: DenseCache::with_model(model),
94        }
95    }
96
97    /// Replace the trace sink; subsequent events go to `sink`.
98    pub fn set_trace_sink(&mut self, sink: Arc<dyn TraceSink>) {
99        self.sink = sink;
100    }
101
102    /// Enable experimental complete catalog-definition events for later registrations.
103    pub fn experimental_enable_catalog_definitions(&mut self) {
104        self.experimental_catalog_definitions = true;
105    }
106
107    /// Record an arbitrary [`TraceEvent`] on the registry's sink. The SDK fact
108    /// grounding path emits its `fact_inject` / `fact_inject_skip` events
109    /// through this.
110    pub fn record_event(&self, event: TraceEvent) {
111        self.sink.record(event);
112    }
113
114    /// Register a fact, or replace one in place if its id is already present.
115    /// Replacing invalidates the old id's cached embedding; the corpus never
116    /// holds a duplicate.
117    pub fn register(&mut self, fact: Fact) {
118        let fact_id = fact.id.clone();
119        let definition = self
120            .experimental_catalog_definitions
121            .then(|| TraceEvent::catalog_definition_for_fact(&fact))
122            .flatten();
123        let definition_changed = definition.as_ref().is_some_and(|definition| {
124            self.facts.get(&fact_id).is_none_or(|existing| {
125                let existing_definition = TraceEvent::catalog_definition_for_fact(existing);
126                existing_definition
127                    .as_ref()
128                    .and_then(TraceEvent::catalog_definition_hash)
129                    != definition.catalog_definition_hash()
130            })
131        });
132        if self.facts.insert(fact_id.clone(), fact).is_some() {
133            // Replaced an existing id: drop its stale embedding.
134            self.dense.invalidate(&fact_id);
135        }
136        // The corpus changed: the cached BM25 index is stale.
137        self.bm25.invalidate();
138        self.sink.record(TraceEvent::FactChurn {
139            kind: ChurnKind::Add,
140            fact_id,
141        });
142        if definition_changed && let Some(definition) = definition {
143            self.sink.record(definition);
144        }
145    }
146
147    /// Number of registered facts (distinct ids).
148    pub fn len(&self) -> usize {
149        self.facts.len()
150    }
151
152    /// Whether no facts are registered.
153    pub fn is_empty(&self) -> bool {
154        self.facts.is_empty()
155    }
156
157    /// The always-on facts ([`PinMode::Always`]), in registration order — the
158    /// push tier the grounding layer injects every applicable turn, bypassing
159    /// ranking entirely. Retrieval-gated facts are excluded; reach them via
160    /// [`Self::search`].
161    pub fn pinned(&self) -> Vec<&Fact> {
162        self.facts
163            .values()
164            .filter(|f| f.pin == PinMode::Always)
165            .collect()
166    }
167
168    /// Look up a fact by id (including its `body`), or `None` for an unknown id.
169    pub fn get(&self, fact_id: &str) -> Option<&Fact> {
170        self.facts.get(fact_id)
171    }
172
173    /// Lexical BM25 retrieval — the fact-side twin of
174    /// [`crate::SkillRegistry::search`]: no model, never fails. Returns at most
175    /// `top_k` hits, best-first. Ranks both tiers (a pinned fact can still be a
176    /// query hit). Traced as [`Origin::Direct`].
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use ratel_ai_core::{Fact, FactRegistry, PinMode};
182    ///
183    /// let mut registry = FactRegistry::new();
184    /// registry.register(Fact {
185    ///     id: "cancellation".into(),
186    ///     name: "cancellation-policy".into(),
187    ///     description: "How to cancel or reschedule a booking".into(),
188    ///     experimental_searchable_description: None,
189    ///     tags: vec!["booking".into()],
190    ///     metadata: std::collections::HashMap::new(),
191    ///     body: "Cancel at least 24h ahead for a full refund.".into(),
192    ///     pin: PinMode::Retrieved,
193    /// });
194    ///
195    /// let hits = registry.search("how do I reschedule my appointment", 5);
196    /// assert_eq!(hits[0].fact_id, "cancellation");
197    /// ```
198    pub fn search(&self, query: &str, top_k: usize) -> Vec<FactHit> {
199        self.search_with_origin(query, top_k, Origin::Direct)
200    }
201
202    /// [`Self::search`] with an explicit trace [`Origin`].
203    pub fn search_with_origin(&self, query: &str, top_k: usize, origin: Origin) -> Vec<FactHit> {
204        self.bm25_search_traced(query, top_k, origin)
205    }
206
207    /// Retrieve with an explicit [`SearchMethod`]. See
208    /// [`crate::SkillRegistry::search_with_method`].
209    ///
210    /// # Errors
211    ///
212    /// Never errors for [`SearchMethod::Bm25`]; for `Semantic`/`Hybrid`, the
213    /// same [`EmbedderError`] cases as the skill path.
214    pub fn search_with_method(
215        &self,
216        query: &str,
217        top_k: usize,
218        origin: Origin,
219        method: SearchMethod,
220    ) -> Result<Vec<FactHit>, EmbedderError> {
221        match method {
222            SearchMethod::Bm25 => Ok(self.bm25_search_traced(query, top_k, origin)),
223            SearchMethod::Semantic => self.semantic_search_traced(query, top_k, origin),
224            SearchMethod::Hybrid => self.hybrid_search_traced(query, top_k, origin),
225        }
226    }
227
228    /// Pre-compute embeddings for not-yet-embedded facts.
229    ///
230    /// # Errors
231    ///
232    /// The same [`EmbedderError`] cases as [`crate::SkillRegistry::build_embeddings`].
233    pub fn build_embeddings(&self) -> Result<(), EmbedderError> {
234        self.dense.extend(self.facts.values(), self.sink.as_ref())
235    }
236
237    /// Recompute embeddings for the full fact corpus and atomically replace the
238    /// dense cache.
239    ///
240    /// # Errors
241    ///
242    /// Any [`EmbedderError`] from loading or embedding the complete corpus.
243    pub fn rebuild_embeddings(&self) -> Result<(), EmbedderError> {
244        self.dense.rebuild(self.facts.values(), self.sink.as_ref())
245    }
246
247    /// The corpus as `(id, searchable_text)` pairs for BM25.
248    fn bm25_docs(&self) -> impl Iterator<Item = (String, String)> + '_ {
249        self.facts
250            .values()
251            .map(|f| (f.id.clone(), searchable_text(f)))
252    }
253
254    /// The prebuilt BM25 index for the current corpus — cached across searches,
255    /// rebuilt on the first search after a mutation.
256    fn bm25_index(&self) -> Arc<crate::search::Bm25Index> {
257        self.bm25.get_or_build(|| self.bm25_docs())
258    }
259
260    // ---- engines -----------------------------------------------------------
261
262    fn bm25_search_traced(&self, query: &str, top_k: usize, origin: Origin) -> Vec<FactHit> {
263        let started = Instant::now();
264        let hits: Vec<FactHit> = self
265            .bm25_index()
266            .search(query, top_k)
267            .into_iter()
268            .map(|(fact_id, score)| FactHit { fact_id, score })
269            .collect();
270        let took_ms = started.elapsed().as_millis() as u64;
271        let top_score = hits.first().map(|h| h.score as f64);
272        self.record_search(
273            query,
274            origin,
275            top_k,
276            &hits,
277            vec![SearchStage {
278                name: "bm25".into(),
279                took_ms,
280                top_score,
281            }],
282            took_ms,
283        );
284        hits
285    }
286
287    fn semantic_search_traced(
288        &self,
289        query: &str,
290        top_k: usize,
291        origin: Origin,
292    ) -> Result<Vec<FactHit>, EmbedderError> {
293        let started = Instant::now();
294        if self.facts.is_empty() || top_k == 0 {
295            self.record_search(query, origin, top_k, &[], Vec::new(), 0);
296            return Ok(Vec::new());
297        }
298        let t = Instant::now();
299        // Facts have no usage arm, so retrieval depth stays `top_k` and the
300        // query vector the dense arm embeds is not reused.
301        let (ranked, _query_vec) = self.dense.search_returning_query_vec(
302            self.facts.values(),
303            query,
304            top_k,
305            self.sink.as_ref(),
306        )?;
307        let stage_ms = t.elapsed().as_millis() as u64;
308        let hits: Vec<FactHit> = ranked
309            .into_iter()
310            .map(|(fact_id, score)| FactHit { fact_id, score })
311            .collect();
312        let took_ms = started.elapsed().as_millis() as u64;
313        let top_score = hits.first().map(|h| h.score as f64);
314        self.record_search(
315            query,
316            origin,
317            top_k,
318            &hits,
319            vec![SearchStage {
320                name: "dense".into(),
321                took_ms: stage_ms,
322                top_score,
323            }],
324            took_ms,
325        );
326        Ok(hits)
327    }
328
329    fn hybrid_search_traced(
330        &self,
331        query: &str,
332        top_k: usize,
333        origin: Origin,
334    ) -> Result<Vec<FactHit>, EmbedderError> {
335        let started = Instant::now();
336        if self.facts.is_empty() || top_k == 0 {
337            self.record_search(query, origin, top_k, &[], Vec::new(), 0);
338            return Ok(Vec::new());
339        }
340        let depth = RETRIEVE_DEPTH.max(top_k);
341
342        let t = Instant::now();
343        let bm25_ranked = self.bm25_index().search(query, depth);
344        let bm25_stage = SearchStage {
345            name: "bm25".into(),
346            took_ms: t.elapsed().as_millis() as u64,
347            top_score: bm25_ranked.first().map(|(_, s)| *s as f64),
348        };
349
350        let t = Instant::now();
351        let (dense_ranked, _query_vec) = self.dense.search_returning_query_vec(
352            self.facts.values(),
353            query,
354            depth,
355            self.sink.as_ref(),
356        )?;
357        let dense_stage = SearchStage {
358            name: "dense".into(),
359            took_ms: t.elapsed().as_millis() as u64,
360            top_score: dense_ranked.first().map(|(_, s)| *s as f64),
361        };
362
363        let t = Instant::now();
364        let bm25_ids: Vec<String> = bm25_ranked.into_iter().map(|(id, _)| id).collect();
365        let dense_ids: Vec<String> = dense_ranked.into_iter().map(|(id, _)| id).collect();
366        let mut fused = rrf_fuse_weighted(&[(&bm25_ids, 1.0), (&dense_ids, 1.0)], RRF_K);
367        sort_and_truncate(&mut fused, top_k);
368        let rrf_stage = SearchStage {
369            name: "rrf".into(),
370            took_ms: t.elapsed().as_millis() as u64,
371            top_score: fused.first().map(|(_, s)| *s as f64),
372        };
373
374        let hits: Vec<FactHit> = fused
375            .into_iter()
376            .map(|(fact_id, score)| FactHit { fact_id, score })
377            .collect();
378        let took_ms = started.elapsed().as_millis() as u64;
379        self.record_search(
380            query,
381            origin,
382            top_k,
383            &hits,
384            vec![bm25_stage, dense_stage, rrf_stage],
385            took_ms,
386        );
387        Ok(hits)
388    }
389
390    #[allow(clippy::too_many_arguments)]
391    fn record_search(
392        &self,
393        query: &str,
394        origin: Origin,
395        top_k: usize,
396        hits: &[FactHit],
397        stages: Vec<SearchStage>,
398        took_ms: u64,
399    ) {
400        self.sink.record(TraceEvent::FactSearch {
401            query: query.to_string(),
402            origin,
403            top_k: top_k as u32,
404            hits: hits
405                .iter()
406                .map(|h| FactHitTrace {
407                    fact_id: h.fact_id.clone(),
408                    score: h.score as f64,
409                })
410                .collect(),
411            stages,
412            took_ms,
413        });
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::embedding::{Embedder, EmbedderError};
421    use crate::trace::MemorySink;
422
423    struct StubEmbedder;
424    impl StubEmbedder {
425        fn vec_for(text: &str) -> Vec<f32> {
426            let t = text.to_lowercase();
427            if t.contains("address") || t.contains("location") {
428                vec![1.0, 0.0, 0.0]
429            } else if t.contains("cancel") || t.contains("refund") {
430                vec![0.0, 1.0, 0.0]
431            } else {
432                vec![0.0, 0.0, 1.0]
433            }
434        }
435    }
436    impl Embedder for StubEmbedder {
437        fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
438            Ok(StubEmbedder::vec_for(text))
439        }
440        fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
441            Ok(StubEmbedder::vec_for(text))
442        }
443    }
444
445    fn with_embedder(embedder: Arc<dyn Embedder>) -> FactRegistry {
446        FactRegistry {
447            facts: IndexMap::new(),
448            sink: Arc::new(NoopSink),
449            experimental_catalog_definitions: false,
450            bm25: Bm25Cache::new(),
451            dense: DenseCache::with_embedder(embedder),
452        }
453    }
454
455    fn fact(id: &str, name: &str, description: &str, tags: &[&str], pin: PinMode) -> Fact {
456        Fact {
457            id: id.into(),
458            name: name.into(),
459            description: description.into(),
460            experimental_searchable_description: None,
461            tags: tags.iter().map(|t| (*t).into()).collect(),
462            metadata: std::collections::HashMap::new(),
463            body: format!("{name} body"),
464            pin,
465        }
466    }
467
468    fn catalog() -> FactRegistry {
469        let mut reg = FactRegistry::new();
470        reg.register(fact(
471            "shop-address",
472            "shop-address",
473            "Where the barbershop is located and its opening hours",
474            &["location"],
475            PinMode::Always,
476        ));
477        reg.register(fact(
478            "cancellation",
479            "cancellation-policy",
480            "How to cancel or reschedule a booking and get a refund",
481            &["booking"],
482            PinMode::Retrieved,
483        ));
484        reg
485    }
486
487    /// A builder that must never run — proof the search path already populated
488    /// the cache (the skill-side twin explains why the public seam can't pin this).
489    fn no_build() -> Vec<(String, String)> {
490        unreachable!("cache should already be populated by the search path")
491    }
492
493    #[test]
494    fn bm25_cache_is_warmed_by_search_and_dropped_by_register() {
495        // Lifecycle pin: a public search populates the cache, and `register` —
496        // the registry's only mutator — drops it. Results are byte-identical
497        // either way, so only this test fails if `register` stops invalidating;
498        // a stale index means `ground()` ranks against the old corpus and an
499        // edited fact never surfaces in the retrieved tier. Twin of
500        // `SkillRegistry::bm25_cache_is_warmed_by_search_and_dropped_by_every_mutator`.
501        let mut reg = catalog();
502        let _ = reg.search("how do I cancel", 5);
503        let warmed = reg.bm25.get_or_build(no_build);
504        let _ = reg.search("where is the shop", 5);
505        let reused = reg.bm25.get_or_build(no_build);
506        assert!(
507            Arc::ptr_eq(&warmed, &reused),
508            "searches between mutations must reuse one index"
509        );
510
511        reg.register(fact(
512            "extra",
513            "extra-fact",
514            "an extra fact",
515            &[],
516            PinMode::Retrieved,
517        ));
518        let builds = std::cell::Cell::new(0);
519        let after_register = reg.bm25.get_or_build(|| {
520            builds.set(builds.get() + 1);
521            reg.bm25_docs()
522        });
523        assert_eq!(builds.get(), 1, "register must drop the cached index");
524        assert!(!Arc::ptr_eq(&warmed, &after_register));
525    }
526
527    #[test]
528    fn a_replaced_fact_is_searchable_under_its_new_text() {
529        // The behavioral consequence of the invalidation above, through the
530        // public seam only: re-registering an id must re-index it, so the new
531        // description ranks and the old one no longer does.
532        let mut reg = catalog();
533        assert_eq!(
534            reg.search("cancel or reschedule a booking", 5)
535                .first()
536                .map(|h| h.fact_id.as_str()),
537            Some("cancellation")
538        );
539        reg.register(fact(
540            "cancellation",
541            "cancellation-policy",
542            "Gift cards, vouchers and store credit",
543            &["payment"],
544            PinMode::Retrieved,
545        ));
546        let hits = reg.search("gift cards and vouchers", 5);
547        assert_eq!(
548            hits.first().map(|h| h.fact_id.as_str()),
549            Some("cancellation"),
550            "a replaced fact must be searchable under its new text"
551        );
552    }
553
554    #[test]
555    fn search_ranks_the_relevant_fact_first() {
556        let reg = catalog();
557        let hits = reg.search("how do I cancel and get my money back", 5);
558        assert_eq!(
559            hits.first().map(|h| h.fact_id.as_str()),
560            Some("cancellation")
561        );
562    }
563
564    #[test]
565    fn experimental_searchable_description_replaces_fact_description_but_keeps_name_and_tags() {
566        let mut reg = FactRegistry::new();
567        let mut overridden = fact(
568            "billing",
569            "billing_policy",
570            "orchestrate zeppelin manifests",
571            &["finance_ops"],
572            PinMode::Retrieved,
573        );
574        overridden.experimental_searchable_description = Some("reconcile overdue invoices".into());
575        reg.register(overridden);
576
577        assert_eq!(reg.search("overdue invoices", 5)[0].fact_id, "billing");
578        assert!(reg.search("zeppelin manifests", 5).is_empty());
579        assert_eq!(reg.search("billing", 5)[0].fact_id, "billing");
580        assert_eq!(reg.search("finance ops", 5)[0].fact_id, "billing");
581    }
582
583    #[test]
584    fn search_on_empty_registry_returns_no_hits() {
585        let reg = FactRegistry::new();
586        assert!(reg.search("anything", 5).is_empty());
587    }
588
589    #[test]
590    fn pinned_returns_only_always_facts_in_registration_order() {
591        let mut reg = FactRegistry::new();
592        reg.register(fact("a", "a", "always one", &[], PinMode::Always));
593        reg.register(fact("r", "r", "retrieved one", &[], PinMode::Retrieved));
594        reg.register(fact("b", "b", "always two", &[], PinMode::Always));
595        let pinned: Vec<&str> = reg.pinned().iter().map(|f| f.id.as_str()).collect();
596        assert_eq!(
597            pinned,
598            vec!["a", "b"],
599            "only Always facts, in insertion order"
600        );
601    }
602
603    #[test]
604    fn pinned_facts_are_still_query_rankable() {
605        // A pinned fact is always-on *and* discoverable — search ranks both tiers.
606        let reg = catalog();
607        let hits = reg.search("barbershop location and address", 5);
608        assert_eq!(
609            hits.first().map(|h| h.fact_id.as_str()),
610            Some("shop-address")
611        );
612    }
613
614    #[test]
615    fn re_register_replaces_not_appends() {
616        let mut reg = FactRegistry::new();
617        reg.register(fact("s", "s", "barbershop address", &[], PinMode::Always));
618        reg.register(fact(
619            "s",
620            "s",
621            "cancellation and refund",
622            &[],
623            PinMode::Retrieved,
624        ));
625        assert_eq!(reg.len(), 1, "re-register replaces, not appends");
626        // The pin flag was updated too — no longer pinned.
627        assert!(reg.pinned().is_empty(), "replaced fact adopts the new pin");
628        let hits = reg.search("cancellation refund", 5);
629        assert_eq!(hits.first().map(|h| h.fact_id.as_str()), Some("s"));
630        assert_eq!(hits.len(), 1, "one id in the corpus yields at most one hit");
631    }
632
633    #[test]
634    fn get_returns_the_body() {
635        let reg = catalog();
636        assert_eq!(
637            reg.get("cancellation").map(|f| f.body.as_str()),
638            Some("cancellation-policy body")
639        );
640        assert!(reg.get("nope").is_none());
641    }
642
643    #[test]
644    fn semantic_ranks_via_injected_embedder() {
645        let mut reg = with_embedder(Arc::new(StubEmbedder));
646        reg.register(fact(
647            "shop-address",
648            "shop-address",
649            "shop location",
650            &["location"],
651            PinMode::Always,
652        ));
653        reg.register(fact(
654            "cancellation",
655            "cancellation",
656            "cancel and refund",
657            &["booking"],
658            PinMode::Retrieved,
659        ));
660        reg.build_embeddings().unwrap();
661        let hits = reg
662            .search_with_method(
663                "the shop location and address",
664                5,
665                Origin::Direct,
666                SearchMethod::Semantic,
667            )
668            .unwrap();
669        assert_eq!(
670            hits.first().map(|h| h.fact_id.as_str()),
671            Some("shop-address")
672        );
673    }
674
675    #[test]
676    fn semantic_uses_experimental_searchable_description_and_keeps_name_and_tags() {
677        let mut overridden_reg = with_embedder(Arc::new(StubEmbedder));
678        let mut overridden = fact(
679            "target",
680            "catalog",
681            "shop address",
682            &["general"],
683            PinMode::Retrieved,
684        );
685        overridden.experimental_searchable_description = Some("cancel refund policy".into());
686        overridden_reg.register(overridden);
687        overridden_reg.register(fact(
688            "decoy",
689            "decoy",
690            "shop address",
691            &["general"],
692            PinMode::Retrieved,
693        ));
694        overridden_reg.build_embeddings().unwrap();
695        let override_hits = overridden_reg
696            .search_with_method("cancel refund", 5, Origin::Direct, SearchMethod::Semantic)
697            .unwrap();
698        assert_eq!(
699            override_hits.first().map(|h| h.fact_id.as_str()),
700            Some("target")
701        );
702
703        let mut name_reg = with_embedder(Arc::new(StubEmbedder));
704        let mut named = fact(
705            "named",
706            "shop_address",
707            "unrelated",
708            &["general"],
709            PinMode::Retrieved,
710        );
711        named.experimental_searchable_description = Some("cancel refund".into());
712        name_reg.register(named);
713        name_reg.register(fact(
714            "name-decoy",
715            "decoy",
716            "cancel refund",
717            &[],
718            PinMode::Retrieved,
719        ));
720        name_reg.build_embeddings().unwrap();
721        let name_hits = name_reg
722            .search_with_method("shop address", 5, Origin::Direct, SearchMethod::Semantic)
723            .unwrap();
724        assert_eq!(name_hits.first().map(|h| h.fact_id.as_str()), Some("named"));
725
726        let mut tag_reg = with_embedder(Arc::new(StubEmbedder));
727        let mut tagged = fact(
728            "tagged",
729            "catalog",
730            "unrelated",
731            &["location"],
732            PinMode::Retrieved,
733        );
734        tagged.experimental_searchable_description = Some("cancel refund".into());
735        tag_reg.register(tagged);
736        tag_reg.register(fact(
737            "tag-decoy",
738            "decoy",
739            "cancel refund",
740            &[],
741            PinMode::Retrieved,
742        ));
743        tag_reg.build_embeddings().unwrap();
744        let tag_hits = tag_reg
745            .search_with_method("shop location", 5, Origin::Direct, SearchMethod::Semantic)
746            .unwrap();
747        assert_eq!(tag_hits.first().map(|h| h.fact_id.as_str()), Some("tagged"));
748    }
749
750    #[test]
751    fn re_register_updates_the_ranked_vector() {
752        let mut reg = with_embedder(Arc::new(StubEmbedder));
753        reg.register(fact(
754            "s",
755            "s",
756            "shop location address",
757            &["location"],
758            PinMode::Retrieved,
759        ));
760        reg.build_embeddings().unwrap();
761        reg.register(fact(
762            "s",
763            "s",
764            "cancel booking refund",
765            &["booking"],
766            PinMode::Retrieved,
767        ));
768        reg.build_embeddings().unwrap();
769        let hits = reg
770            .search_with_method(
771                "cancel and refund",
772                5,
773                Origin::Direct,
774                SearchMethod::Semantic,
775            )
776            .unwrap();
777        assert_eq!(hits.first().map(|h| h.fact_id.as_str()), Some("s"));
778        assert!(hits[0].score > 0.9, "ranks with the re-embedded vector");
779    }
780
781    #[test]
782    fn hybrid_emits_three_stages() {
783        let sink = Arc::new(MemorySink::new("s"));
784        let mut reg = with_embedder(Arc::new(StubEmbedder));
785        reg.set_trace_sink(sink.clone());
786        reg.register(fact(
787            "shop-address",
788            "shop-address",
789            "shop location",
790            &["location"],
791            PinMode::Always,
792        ));
793        reg.build_embeddings().unwrap();
794        reg.search_with_method("location", 5, Origin::Agent, SearchMethod::Hybrid)
795            .unwrap();
796        let events = sink.drain();
797        assert!(events.iter().any(|e| matches!(
798            &e.event,
799            TraceEvent::FactSearch { stages, .. }
800                if stages.iter().any(|s| s.name == "bm25")
801                && stages.iter().any(|s| s.name == "dense")
802                && stages.iter().any(|s| s.name == "rrf")
803        )));
804    }
805
806    #[test]
807    fn register_and_search_emit_trace_events() {
808        let sink = Arc::new(MemorySink::new("test-session"));
809        let mut reg = FactRegistry::with_trace_sink(sink.clone());
810        reg.register(fact(
811            "shop-address",
812            "shop-address",
813            "shop location address",
814            &["location"],
815            PinMode::Always,
816        ));
817        reg.search_with_origin("shop address", 5, Origin::Agent);
818
819        let events = sink.drain();
820        assert!(events.iter().any(|e| matches!(
821            e.event,
822            TraceEvent::FactChurn {
823                kind: ChurnKind::Add,
824                ..
825            }
826        )));
827        assert!(events.iter().any(|e| matches!(
828            &e.event,
829            TraceEvent::FactSearch { origin: Origin::Agent, hits, .. } if !hits.is_empty()
830        )));
831    }
832}