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
16pub struct FactHit {
19 pub fact_id: String,
21 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
36pub struct FactRegistry {
41 facts: IndexMap<String, Fact>,
46 sink: Arc<dyn TraceSink>,
47 experimental_catalog_definitions: bool,
48 bm25: Bm25Cache,
51 dense: DenseCache,
53}
54
55impl Default for FactRegistry {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61impl FactRegistry {
62 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 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 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 pub fn set_trace_sink(&mut self, sink: Arc<dyn TraceSink>) {
99 self.sink = sink;
100 }
101
102 pub fn experimental_enable_catalog_definitions(&mut self) {
104 self.experimental_catalog_definitions = true;
105 }
106
107 pub fn record_event(&self, event: TraceEvent) {
111 self.sink.record(event);
112 }
113
114 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 self.dense.invalidate(&fact_id);
135 }
136 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 pub fn len(&self) -> usize {
149 self.facts.len()
150 }
151
152 pub fn is_empty(&self) -> bool {
154 self.facts.is_empty()
155 }
156
157 pub fn pinned(&self) -> Vec<&Fact> {
162 self.facts
163 .values()
164 .filter(|f| f.pin == PinMode::Always)
165 .collect()
166 }
167
168 pub fn get(&self, fact_id: &str) -> Option<&Fact> {
170 self.facts.get(fact_id)
171 }
172
173 pub fn search(&self, query: &str, top_k: usize) -> Vec<FactHit> {
199 self.search_with_origin(query, top_k, Origin::Direct)
200 }
201
202 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 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 pub fn build_embeddings(&self) -> Result<(), EmbedderError> {
234 self.dense.extend(self.facts.values(), self.sink.as_ref())
235 }
236
237 pub fn rebuild_embeddings(&self) -> Result<(), EmbedderError> {
244 self.dense.rebuild(self.facts.values(), self.sink.as_ref())
245 }
246
247 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 fn bm25_index(&self) -> Arc<crate::search::Bm25Index> {
257 self.bm25.get_or_build(|| self.bm25_docs())
258 }
259
260 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 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 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 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 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 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 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}