1use crate::record::ExperimentRecord;
36use chrono::{DateTime, Utc};
37use somatize_core::fingerprint::{ArchitectureFingerprint, structural_similarity};
38use somatize_core::summary::RunOutcome;
39use std::collections::HashMap;
40
41const K1: f64 = 1.2;
43const B: f64 = 0.75;
45
46const W_LEXICAL: f64 = 0.40;
48const W_STRUCTURAL: f64 = 0.25;
49const W_RECENCY: f64 = 0.15;
50const W_IMPORTANCE: f64 = 0.20;
51
52pub const DEFAULT_HALF_LIFE_DAYS: f64 = 30.0;
54
55pub trait Embedder: Send + Sync {
65 fn id(&self) -> &str;
67
68 fn embed(&self, text: &str) -> somatize_core::error::Result<Vec<f32>>;
70}
71
72pub fn embedding_text(record: &ExperimentRecord) -> String {
75 let mut parts: Vec<&str> = vec![&record.name, &record.pipeline_summary];
76 if let Some(h) = &record.hypothesis {
77 parts.push(h);
78 }
79 if let Some(c) = &record.conclusion {
80 parts.push(&c.headline);
81 }
82 if let Some(d) = &record.derivation {
83 parts.push(&d.summary);
84 }
85 if let Some(n) = &record.notes {
86 parts.push(n);
87 }
88 parts.extend(record.tags.iter().map(String::as_str));
89 parts
90 .into_iter()
91 .filter(|p| !p.is_empty())
92 .collect::<Vec<_>>()
93 .join(" — ")
94}
95
96#[derive(Debug, Clone)]
98pub struct RetrievalQuery {
99 pub text: String,
101 pub architecture: Option<ArchitectureFingerprint>,
103 pub now: DateTime<Utc>,
106 pub half_life_days: f64,
108 pub embedding: Option<(String, Vec<f32>)>,
112 pub limit: usize,
114 pub research_line: Option<String>,
116 pub tags: Vec<String>,
118}
119
120impl RetrievalQuery {
121 pub fn new(text: impl Into<String>, now: DateTime<Utc>) -> Self {
123 Self {
124 text: text.into(),
125 architecture: None,
126 now,
127 half_life_days: DEFAULT_HALF_LIFE_DAYS,
128 embedding: None,
129 limit: 10,
130 research_line: None,
131 tags: Vec::new(),
132 }
133 }
134
135 pub fn with_architecture(mut self, architecture: ArchitectureFingerprint) -> Self {
138 self.architecture = Some(architecture);
139 self
140 }
141
142 pub fn with_limit(mut self, limit: usize) -> Self {
144 self.limit = limit;
145 self
146 }
147
148 pub fn in_line(mut self, line: impl Into<String>) -> Self {
150 self.research_line = Some(line.into());
151 self
152 }
153
154 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
156 self.tags = tags;
157 self
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
163pub struct ScoreComponents {
164 pub lexical: f64,
167 pub structural: f64,
169 pub recency: f64,
171 pub importance: f64,
173}
174
175#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
177pub struct ScoredRecord {
178 pub record: ExperimentRecord,
180 pub score: f64,
182 pub components: ScoreComponents,
184}
185
186impl ScoredRecord {
187 pub fn why(&self) -> String {
189 let c = &self.components;
190 format!(
191 "score {:.2} (text {:.2}, structure {:.2}, recency {:.2}, importance {:.2})",
192 self.score, c.lexical, c.structural, c.recency, c.importance
193 )
194 }
195}
196
197pub fn rank(records: &[ExperimentRecord], query: &RetrievalQuery) -> Vec<ScoredRecord> {
201 let candidates: Vec<&ExperimentRecord> = records
202 .iter()
203 .filter(|r| {
204 query
205 .research_line
206 .as_ref()
207 .is_none_or(|line| r.research_line.as_deref() == Some(line.as_str()))
208 })
209 .filter(|r| query.tags.iter().all(|t| r.tags.contains(t)))
210 .collect();
211 if candidates.is_empty() {
212 return Vec::new();
213 }
214
215 let bm25 = Bm25Index::build(&candidates);
216 let query_terms = tokenize(&query.text);
217 let raw: Vec<f64> = candidates
218 .iter()
219 .enumerate()
220 .map(|(i, _)| bm25.score(i, &query_terms))
221 .collect();
222 let peak = raw.iter().cloned().fold(0.0_f64, f64::max);
225
226 let has_text = !query_terms.is_empty() && peak > 0.0;
228 let has_structure = query
229 .architecture
230 .as_ref()
231 .is_some_and(|a| !a.nodes.is_empty());
232 let applicable = [
233 (has_text, W_LEXICAL),
234 (has_structure, W_STRUCTURAL),
235 (true, W_RECENCY),
236 (true, W_IMPORTANCE),
237 ];
238 let total: f64 = applicable
239 .iter()
240 .filter(|(on, _)| *on)
241 .map(|(_, w)| w)
242 .sum();
243 let weight = |on: bool, w: f64| if on && total > 0.0 { w / total } else { 0.0 };
244 let (w_lex, w_struct) = (
245 weight(has_text, W_LEXICAL),
246 weight(has_structure, W_STRUCTURAL),
247 );
248 let (w_rec, w_imp) = (weight(true, W_RECENCY), weight(true, W_IMPORTANCE));
249
250 let mut scored: Vec<ScoredRecord> = candidates
251 .iter()
252 .enumerate()
253 .map(|(i, record)| {
254 let lexical =
255 blend_semantic(if peak > 0.0 { raw[i] / peak } else { 0.0 }, record, query);
256 let structural = match (&query.architecture, &record.architecture) {
257 (Some(a), Some(b)) => structural_similarity(a, b),
258 _ => 0.0,
259 };
260 let recency = recency(record.timestamp, query.now, query.half_life_days);
261 let importance = importance(record);
262 let components = ScoreComponents {
263 lexical,
264 structural,
265 recency,
266 importance,
267 };
268 ScoredRecord {
269 record: (*record).clone(),
270 score: w_lex * lexical
271 + w_struct * structural
272 + w_rec * recency
273 + w_imp * importance,
274 components,
275 }
276 })
277 .collect();
278
279 scored.sort_by(|a, b| {
280 b.score
281 .partial_cmp(&a.score)
282 .unwrap_or(std::cmp::Ordering::Equal)
283 .then_with(|| a.record.id.cmp(&b.record.id))
284 });
285 scored.truncate(query.limit);
286 scored
287}
288
289fn blend_semantic(lexical: f64, record: &ExperimentRecord, query: &RetrievalQuery) -> f64 {
292 let (Some((query_id, query_vec)), Some(embedding)) = (&query.embedding, &record.embedding)
293 else {
294 return lexical;
295 };
296 if &embedding.embedder_id != query_id {
297 return lexical;
298 }
299 let cosine = cosine(query_vec, &embedding.vector);
300 0.5 * lexical + 0.5 * cosine
301}
302
303fn cosine(a: &[f32], b: &[f32]) -> f64 {
306 if a.len() != b.len() || a.is_empty() {
307 return 0.0;
308 }
309 let (mut dot, mut na, mut nb) = (0.0f64, 0.0f64, 0.0f64);
310 for (x, y) in a.iter().zip(b) {
311 dot += (*x as f64) * (*y as f64);
312 na += (*x as f64).powi(2);
313 nb += (*y as f64).powi(2);
314 }
315 if na == 0.0 || nb == 0.0 {
316 return 0.0;
317 }
318 (dot / (na.sqrt() * nb.sqrt())).clamp(0.0, 1.0)
319}
320
321pub fn recency(timestamp: DateTime<Utc>, now: DateTime<Utc>, half_life_days: f64) -> f64 {
324 if half_life_days <= 0.0 {
325 return 1.0;
326 }
327 let age_days = (now - timestamp).num_seconds() as f64 / 86_400.0;
328 if age_days <= 0.0 {
329 return 1.0;
330 }
331 (-std::f64::consts::LN_2 * age_days / half_life_days).exp()
332}
333
334pub fn importance(record: &ExperimentRecord) -> f64 {
341 let mut score: f64 = 0.3;
342 if record.conclusion.as_ref().is_some_and(|c| !c.is_empty()) {
343 score += 0.2;
344 }
345 if record.hypothesis.is_some() || record.notes.is_some() {
346 score += 0.2;
347 }
348 if record.derivation.is_some() {
349 score += 0.1;
350 }
351 if improved(record) {
352 score += 0.2;
353 }
354 if is_dead_end(record) && record.has_conclusion() {
355 score = score.max(0.6);
356 }
357 score.clamp(0.0, 1.0)
358}
359
360fn improved(record: &ExperimentRecord) -> bool {
366 let Some(derivation) = &record.derivation else {
367 return false;
368 };
369 match record.objective.as_deref() {
370 Some(objective) => derivation
371 .metric_delta
372 .get(objective)
373 .is_some_and(|d| d.delta > 0.0),
374 None => derivation.metric_delta.values().any(|d| d.delta > 0.0),
375 }
376}
377
378pub fn is_dead_end(record: &ExperimentRecord) -> bool {
380 let failed = record.conclusion.as_ref().is_some_and(|c| {
381 matches!(
382 c.outcome,
383 Some(RunOutcome::Failed) | Some(RunOutcome::Crashed)
384 )
385 });
386 let regressed = record.derivation.as_ref().is_some_and(|d| {
387 !d.metric_delta.is_empty() && d.metric_delta.values().all(|m| m.delta < 0.0)
388 });
389 failed || regressed
390}
391
392const FIELD_WEIGHTS: &[(Field, usize)] = &[
397 (Field::Name, 3),
398 (Field::Hypothesis, 3),
399 (Field::Headline, 2),
400 (Field::Tags, 2),
401 (Field::Pipeline, 2),
402 (Field::Derivation, 2),
403 (Field::Notes, 1),
404 (Field::ResearchLine, 1),
405 (Field::Keys, 1),
406 (Field::Architecture, 1),
407];
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410enum Field {
411 Name,
412 Hypothesis,
413 Headline,
414 Tags,
415 Pipeline,
416 Derivation,
417 Notes,
418 ResearchLine,
419 Keys,
420 Architecture,
421}
422
423fn field_tokens(record: &ExperimentRecord, field: Field) -> Vec<String> {
425 match field {
426 Field::Name => tokenize(&record.name),
427 Field::Hypothesis => tokenize(record.hypothesis.as_deref().unwrap_or("")),
428 Field::Headline => tokenize(record.conclusion.as_ref().map_or("", |c| &c.headline)),
429 Field::Tags => record.tags.iter().flat_map(|t| tokenize(t)).collect(),
430 Field::Pipeline => tokenize(&record.pipeline_summary),
431 Field::Derivation => tokenize(record.derivation.as_ref().map_or("", |d| &d.summary)),
432 Field::Notes => tokenize(record.notes.as_deref().unwrap_or("")),
433 Field::ResearchLine => tokenize(record.research_line.as_deref().unwrap_or("")),
434 Field::Keys => record
435 .params
436 .keys()
437 .chain(record.metrics.keys())
438 .flat_map(|k| tokenize(k))
439 .collect(),
440 Field::Architecture => record
441 .architecture
442 .iter()
443 .flat_map(|a| a.node_tokens())
444 .flat_map(|t| tokenize(&t))
445 .collect(),
446 }
447}
448
449fn document_terms(record: &ExperimentRecord) -> HashMap<String, usize> {
451 let mut counts: HashMap<String, usize> = HashMap::new();
452 for (field, weight) in FIELD_WEIGHTS {
453 for token in field_tokens(record, *field) {
454 *counts.entry(token).or_default() += weight;
455 }
456 }
457 counts
458}
459
460pub fn tokenize(text: &str) -> Vec<String> {
468 let chars: Vec<char> = text.chars().collect();
469 let mut tokens = Vec::new();
470 let mut current = String::new();
471
472 for (i, &ch) in chars.iter().enumerate() {
473 if !ch.is_alphanumeric() {
474 if !current.is_empty() {
475 tokens.push(std::mem::take(&mut current));
476 }
477 continue;
478 }
479 let prev = i.checked_sub(1).map(|j| chars[j]);
480 let next = chars.get(i + 1).copied();
481 let after_lower_or_digit = prev.is_some_and(|p| p.is_lowercase() || p.is_numeric());
482 let acronym_end = prev.is_some_and(char::is_uppercase)
483 && ch.is_uppercase()
484 && next.is_some_and(char::is_lowercase);
485 if ((ch.is_uppercase() && after_lower_or_digit) || acronym_end) && !current.is_empty() {
486 tokens.push(std::mem::take(&mut current));
487 }
488 current.extend(ch.to_lowercase());
489 }
490 if !current.is_empty() {
491 tokens.push(current);
492 }
493 tokens
494}
495
496struct Bm25Index {
500 docs: Vec<HashMap<String, usize>>,
501 lengths: Vec<f64>,
502 avg_length: f64,
503 doc_freq: HashMap<String, usize>,
505}
506
507impl Bm25Index {
508 fn build(records: &[&ExperimentRecord]) -> Self {
509 let docs: Vec<HashMap<String, usize>> = records.iter().map(|r| document_terms(r)).collect();
510 let lengths: Vec<f64> = docs
511 .iter()
512 .map(|d| d.values().sum::<usize>() as f64)
513 .collect();
514 let avg_length = if lengths.is_empty() {
515 0.0
516 } else {
517 lengths.iter().sum::<f64>() / lengths.len() as f64
518 };
519 let mut doc_freq: HashMap<String, usize> = HashMap::new();
520 for doc in &docs {
521 for term in doc.keys() {
522 *doc_freq.entry(term.clone()).or_default() += 1;
523 }
524 }
525 Self {
526 docs,
527 lengths,
528 avg_length,
529 doc_freq,
530 }
531 }
532
533 fn score(&self, doc: usize, query_terms: &[String]) -> f64 {
534 if self.avg_length == 0.0 {
535 return 0.0;
536 }
537 let n = self.docs.len() as f64;
538 let length_norm = K1 * (1.0 - B + B * self.lengths[doc] / self.avg_length);
539 query_terms
540 .iter()
541 .map(|term| {
542 let tf = *self.docs[doc].get(term).unwrap_or(&0) as f64;
543 if tf == 0.0 {
544 return 0.0;
545 }
546 let df = *self.doc_freq.get(term).unwrap_or(&0) as f64;
547 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln().max(0.0);
550 idf * (tf * (K1 + 1.0)) / (tf + length_norm)
551 })
552 .sum()
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use crate::derivation::{DerivationMove, MetricDelta};
560 use chrono::Duration;
561 use somatize_core::summary::RunConclusion;
562 use std::collections::BTreeMap;
563
564 fn now() -> DateTime<Utc> {
565 DateTime::parse_from_rfc3339("2026-07-30T12:00:00Z")
566 .unwrap()
567 .with_timezone(&Utc)
568 }
569
570 fn record(id: &str, name: &str) -> ExperimentRecord {
571 let mut r = ExperimentRecord::new(id, name);
572 r.timestamp = now();
573 r
574 }
575
576 fn with_conclusion(mut r: ExperimentRecord, outcome: RunOutcome) -> ExperimentRecord {
577 r.conclusion = Some(RunConclusion {
578 headline: format!("{} something", outcome.verb()),
579 outcome: Some(outcome),
580 ..RunConclusion::default()
581 });
582 r
583 }
584
585 fn derivation(deltas: &[(&str, f64)]) -> DerivationMove {
586 DerivationMove {
587 from: "p".into(),
588 to: "c".into(),
589 changes: Vec::new(),
590 metric_delta: deltas
591 .iter()
592 .map(|(name, delta)| {
593 (
594 (*name).to_string(),
595 MetricDelta {
596 before: 0.5,
597 after: 0.5 + delta,
598 delta: *delta,
599 },
600 )
601 })
602 .collect::<BTreeMap<_, _>>(),
603 summary: String::new(),
604 }
605 }
606
607 #[test]
610 fn tokenizer_splits_the_shapes_experiment_names_actually_use() {
611 assert_eq!(tokenize("val_f1"), vec!["val", "f1"]);
612 assert_eq!(tokenize("StandardScaler"), vec!["standard", "scaler"]);
613 assert_eq!(tokenize("rocket-znorm"), vec!["rocket", "znorm"]);
614 assert_eq!(
615 tokenize("filter:MoSHead"),
616 vec!["filter", "mo", "s", "head"]
617 );
618 assert_eq!(tokenize("encoder/layers.0"), vec!["encoder", "layers", "0"]);
619 assert_eq!(tokenize(" "), Vec::<String>::new());
620 assert_eq!(tokenize("training trains"), vec!["training", "trains"]);
622 }
623
624 #[test]
627 fn a_name_match_outranks_a_note_match() {
628 let mut in_name = record("a", "dropout sweep");
629 in_name.notes = Some("nothing to see".into());
630 let mut in_notes = record("b", "unrelated run");
631 in_notes.notes = Some("we tried dropout here".into());
632
633 let hits = rank(&[in_name, in_notes], &RetrievalQuery::new("dropout", now()));
634 assert_eq!(hits[0].record.id, "a");
635 assert!(hits[0].components.lexical > hits[1].components.lexical);
636 }
637
638 #[test]
639 fn a_record_matching_nothing_scores_zero_lexically() {
640 let hits = rank(
641 &[record("a", "dropout sweep"), record("b", "batch norm")],
642 &RetrievalQuery::new("dropout", now()),
643 );
644 let miss = hits.iter().find(|h| h.record.id == "b").unwrap();
645 assert_eq!(miss.components.lexical, 0.0);
646 assert!(miss.score > 0.0, "recency and importance still count");
647 }
648
649 #[test]
650 fn the_derivation_summary_is_searchable() {
651 let mut moved = record("a", "run");
652 let mut d = derivation(&[("f1", 0.1)]);
653 d.summary = "swapped SVM for RandomForest".into();
654 moved.derivation = Some(d);
655
656 let hits = rank(
657 &[moved, record("b", "run")],
658 &RetrievalQuery::new("random forest", now()),
659 );
660 assert_eq!(hits[0].record.id, "a");
661 assert!(hits[0].components.lexical > 0.0);
662 }
663
664 #[test]
665 fn an_empty_query_ranks_on_the_other_terms_alone() {
666 let old = {
667 let mut r = record("old", "ancient");
668 r.timestamp = now() - Duration::days(365);
669 r
670 };
671 let hits = rank(
672 &[record("new", "fresh"), old],
673 &RetrievalQuery::new("", now()),
674 );
675 assert_eq!(hits[0].record.id, "new");
676 assert!(hits.iter().all(|h| h.components.lexical == 0.0));
677 assert!(hits[0].score > 0.5, "{}", hits[0].score);
679 }
680
681 #[test]
684 fn architecture_similarity_breaks_a_text_tie() {
685 use somatize_core::graph::{Node, linear_pipeline};
686
687 let mine = ArchitectureFingerprint::of(&linear_pipeline(vec![
688 Node::filter("Scaler"),
689 Node::filter("SVM"),
690 ]))
691 .unwrap();
692 let same = mine.clone();
693 let other = ArchitectureFingerprint::of(&linear_pipeline(vec![
694 Node::filter("Tokenizer"),
695 Node::filter("Transformer"),
696 ]))
697 .unwrap();
698
699 let mut a = record("same", "run");
700 a.architecture = Some(same);
701 let mut b = record("other", "run");
702 b.architecture = Some(other);
703
704 let hits = rank(
705 &[b, a],
706 &RetrievalQuery::new("run", now()).with_architecture(mine),
707 );
708 assert_eq!(hits[0].record.id, "same");
709 assert_eq!(hits[0].components.structural, 1.0);
710 assert_eq!(hits[1].components.structural, 0.0);
711 }
712
713 #[test]
714 fn without_a_query_architecture_the_structural_weight_is_redistributed() {
715 let query = RetrievalQuery::new("run", now());
716 let hits = rank(&[record("a", "run")], &query);
717 assert_eq!(hits[0].components.structural, 0.0);
718 let c = hits[0].components;
720 let expected = (0.40 * c.lexical + 0.15 * c.recency + 0.20 * c.importance) / 0.75;
721 assert!((hits[0].score - expected).abs() < 1e-9);
722 }
723
724 #[test]
727 fn recency_halves_every_half_life() {
728 assert!((recency(now(), now(), 30.0) - 1.0).abs() < 1e-9);
729 assert!((recency(now() - Duration::days(30), now(), 30.0) - 0.5).abs() < 1e-6);
730 assert!((recency(now() - Duration::days(60), now(), 30.0) - 0.25).abs() < 1e-6);
731 assert!(recency(now() - Duration::days(3650), now(), 30.0) > 0.0);
733 assert_eq!(recency(now() + Duration::days(5), now(), 30.0), 1.0);
735 }
736
737 #[test]
738 fn an_old_record_still_beats_a_new_irrelevant_one() {
739 let mut old = record("old", "dropout collapse investigation");
742 old.timestamp = now() - Duration::days(365);
743 old = with_conclusion(old, RunOutcome::Failed);
744 old.notes = Some("dropout above 0.5 collapses the encoder".into());
745
746 let fresh = record("fresh", "unrelated batch norm run");
747
748 let hits = rank(
749 &[old, fresh],
750 &RetrievalQuery::new("dropout collapse", now()),
751 );
752 assert_eq!(hits[0].record.id, "old");
753 }
754
755 #[test]
758 fn a_failure_with_a_conclusion_has_a_floor() {
759 let bare = record("bare", "nothing recorded");
760 assert!((importance(&bare) - 0.3).abs() < 1e-9);
761
762 let failed = with_conclusion(record("failed", "boom"), RunOutcome::Failed);
763 assert!(
764 importance(&failed) >= 0.6,
765 "a dead end with a conclusion must stay retrievable: {}",
766 importance(&failed)
767 );
768
769 let crashed = with_conclusion(record("crashed", "oom"), RunOutcome::Crashed);
770 assert!(importance(&crashed) >= 0.6);
771 }
772
773 #[test]
774 fn a_regression_counts_as_a_dead_end() {
775 let mut regressed = with_conclusion(record("r", "worse"), RunOutcome::Completed);
776 regressed.derivation = Some(derivation(&[("f1", -0.1), ("auc", -0.05)]));
777 assert!(is_dead_end(®ressed));
778 assert!(importance(®ressed) >= 0.6);
779
780 let mut mixed = with_conclusion(record("m", "mixed"), RunOutcome::Completed);
781 mixed.derivation = Some(derivation(&[("f1", -0.1), ("auc", 0.05)]));
782 assert!(!is_dead_end(&mixed), "one metric up is not a dead end");
783 }
784
785 #[test]
786 fn improvement_is_judged_against_the_declared_objective() {
787 let mut up = with_conclusion(record("up", "better"), RunOutcome::Completed);
788 up.objective = Some("f1".into());
789 up.derivation = Some(derivation(&[("f1", 0.1), ("loss", 0.2)]));
790 assert!(improved(&up));
791
792 let mut down = with_conclusion(record("down", "worse"), RunOutcome::Completed);
794 down.objective = Some("f1".into());
795 down.derivation = Some(derivation(&[("f1", -0.1), ("loss", 0.2)]));
796 assert!(!improved(&down));
797 assert!(importance(&up) > importance(&down));
798 }
799
800 #[test]
801 fn importance_stays_in_range() {
802 let mut everything = with_conclusion(record("e", "all"), RunOutcome::Completed);
803 everything.hypothesis = Some("h".into());
804 everything.notes = Some("n".into());
805 everything.derivation = Some(derivation(&[("f1", 0.1)]));
806 let score = importance(&everything);
807 assert!((0.0..=1.0).contains(&score), "{score}");
808 assert!(score > importance(&record("bare", "bare")));
809 }
810
811 #[test]
814 fn scores_are_bounded_and_ordering_is_total() {
815 let records: Vec<ExperimentRecord> = (0..20)
816 .map(|i| {
817 let mut r = record(&format!("e{i:02}"), "dropout sweep");
818 r.timestamp = now() - Duration::days(i);
819 r
820 })
821 .collect();
822 let hits = rank(
823 &records,
824 &RetrievalQuery::new("dropout", now()).with_limit(20),
825 );
826 assert_eq!(hits.len(), 20);
827 for hit in &hits {
828 assert!((0.0..=1.0).contains(&hit.score), "{}", hit.score);
829 }
830 for pair in hits.windows(2) {
831 assert!(pair[0].score >= pair[1].score);
832 }
833 }
834
835 #[test]
836 fn ranking_is_insertion_order_independent_and_repeatable() {
837 let mut records = vec![
838 record("a", "dropout sweep"),
839 with_conclusion(record("b", "dropout collapse"), RunOutcome::Failed),
840 record("c", "batch norm"),
841 ];
842 let query = RetrievalQuery::new("dropout", now());
843 let forward: Vec<String> = rank(&records, &query)
844 .into_iter()
845 .map(|h| h.record.id)
846 .collect();
847 records.reverse();
848 let reversed: Vec<String> = rank(&records, &query)
849 .into_iter()
850 .map(|h| h.record.id)
851 .collect();
852 assert_eq!(forward, reversed);
853 assert_eq!(
854 forward,
855 rank(&records, &query)
856 .into_iter()
857 .map(|h| h.record.id)
858 .collect::<Vec<_>>()
859 );
860 }
861
862 #[test]
863 fn identical_records_tie_break_on_id() {
864 let a = record("zzz", "same");
865 let b = record("aaa", "same");
866 let hits = rank(&[a, b], &RetrievalQuery::new("same", now()));
867 assert_eq!(hits[0].record.id, "aaa");
868 }
869
870 #[test]
871 fn filters_apply_before_scoring() {
872 let mut in_line = record("a", "run");
873 in_line.research_line = Some("mos".into());
874 in_line.tags = vec!["gpu".into(), "v2".into()];
875 let mut other_line = record("b", "run");
876 other_line.research_line = Some("other".into());
877
878 let query = RetrievalQuery::new("run", now()).in_line("mos");
879 let hits = rank(&[in_line.clone(), other_line.clone()], &query);
880 assert_eq!(hits.len(), 1);
881 assert_eq!(hits[0].record.id, "a");
882
883 let query = RetrievalQuery::new("run", now()).with_tags(vec!["gpu".into()]);
884 assert_eq!(rank(&[in_line.clone(), other_line], &query).len(), 1);
885
886 let query =
888 RetrievalQuery::new("run", now()).with_tags(vec!["gpu".into(), "missing".into()]);
889 assert!(rank(&[in_line], &query).is_empty());
890 }
891
892 #[test]
893 fn limit_is_honored_and_an_empty_pool_is_empty() {
894 let records: Vec<ExperimentRecord> =
895 (0..10).map(|i| record(&format!("e{i}"), "run")).collect();
896 assert_eq!(
897 rank(&records, &RetrievalQuery::new("run", now()).with_limit(3)).len(),
898 3
899 );
900 assert!(rank(&[], &RetrievalQuery::new("run", now())).is_empty());
901 }
902
903 #[test]
904 fn why_explains_the_score() {
905 let hits = rank(
906 &[record("a", "dropout")],
907 &RetrievalQuery::new("dropout", now()),
908 );
909 let why = hits[0].why();
910 assert!(why.starts_with("score "), "{why}");
911 assert!(why.contains("importance"), "{why}");
912 }
913
914 #[test]
917 fn a_matching_embedder_blends_in_but_a_mismatched_one_is_ignored() {
918 use crate::record::Embedding;
919
920 let embedded = |id: &str, model: &str, vector: Vec<f32>| {
921 let mut r = record(id, "run");
924 r.embedding = Some(Embedding {
925 embedder_id: model.into(),
926 vector,
927 });
928 r
929 };
930 let records = vec![
931 embedded("aligned", "minilm", vec![1.0, 0.0]),
932 embedded("orthogonal", "minilm", vec![0.0, 1.0]),
933 embedded("other-model", "a-different-model", vec![1.0, 0.0]),
936 ];
937
938 let mut query = RetrievalQuery::new("run", now());
939 query.embedding = Some(("minilm".to_string(), vec![1.0, 0.0]));
940 let hits = rank(&records, &query);
941 let lexical = |id: &str| {
942 hits.iter()
943 .find(|h| h.record.id == id)
944 .unwrap()
945 .components
946 .lexical
947 };
948
949 assert_eq!(lexical("aligned"), 1.0, "a matching vector agrees");
950 assert_eq!(lexical("orthogonal"), 0.5, "a matching vector disagrees");
951 assert_eq!(
952 lexical("other-model"),
953 1.0,
954 "a vector from another model is left out of the blend entirely"
955 );
956 assert_eq!(hits.last().unwrap().record.id, "orthogonal");
957 }
958
959 #[test]
960 fn cosine_handles_degenerate_vectors() {
961 assert_eq!(cosine(&[], &[]), 0.0);
962 assert_eq!(cosine(&[1.0], &[1.0, 2.0]), 0.0, "length mismatch");
963 assert_eq!(cosine(&[0.0, 0.0], &[1.0, 1.0]), 0.0, "zero vector");
964 assert_eq!(cosine(&[1.0, 0.0], &[-1.0, 0.0]), 0.0, "opposite clamps");
965 assert!((cosine(&[1.0, 1.0], &[2.0, 2.0]) - 1.0).abs() < 1e-9);
966 }
967
968 #[test]
969 fn embedding_text_gathers_the_prose() {
970 let mut r = record("e", "mos baseline");
971 r.pipeline_summary = "a → b".into();
972 r.hypothesis = Some("wider helps".into());
973 r.tags = vec!["mos".into()];
974 let text = embedding_text(&r);
975 for expected in ["mos baseline", "a → b", "wider helps", "mos"] {
976 assert!(text.contains(expected), "{text}");
977 }
978 }
979}