Skip to main content

somatize_memory/
retrieval.rs

1//! Ranked retrieval over the experiment pool.
2//!
3//! The pool exists to answer "what have I already tried that bears on
4//! this?", which is four questions at once: does the text match, does
5//! the architecture look like mine, is it recent enough to still be
6//! about the same code, and is it worth reading at all. The score adds
7//! them:
8//!
9//! ```text
10//! 0.40 · lexical + 0.25 · structural + 0.15 · recency + 0.20 · importance
11//! ```
12//!
13//! **Additive, not multiplicative.** A product lets any single term veto
14//! a record: an experiment from last year scores ~0 on recency and
15//! therefore ~0 overall, when a year-old dead end is exactly the kind of
16//! thing the pool exists to surface. Terms that do not apply (no query
17//! architecture, no timestamps to compare) have their weight
18//! redistributed over the rest, so scores stay comparable across
19//! queries instead of silently shrinking.
20//!
21//! Lexical relevance is BM25 (`k1 = 1.2`, `b = 0.75`) over a document
22//! built by repeating each field: a term in the experiment's *name*
23//! counts three times, in its conclusion headline twice, in its notes
24//! once. No stemming — experiment vocabulary is mostly identifiers and
25//! acronyms, which stemmers mangle.
26//!
27//! **Failures rank.** [`importance`] puts a floor under any record that
28//! failed, crashed or regressed *and* carries a conclusion. Cutting
29//! experimental cost means not repeating dead ends, not only repeating
30//! wins.
31//!
32//! Everything here is deterministic: `now` is a parameter, ordering
33//! ties break on id, and no clock or RNG is read.
34
35use crate::record::ExperimentRecord;
36use chrono::{DateTime, Utc};
37use somatize_core::fingerprint::{ArchitectureFingerprint, structural_similarity};
38use somatize_core::summary::RunOutcome;
39use std::collections::HashMap;
40
41/// BM25 term-frequency saturation.
42const K1: f64 = 1.2;
43/// BM25 length normalization.
44const B: f64 = 0.75;
45
46/// Composite weights. See the module docs for why they are added.
47const W_LEXICAL: f64 = 0.40;
48const W_STRUCTURAL: f64 = 0.25;
49const W_RECENCY: f64 = 0.15;
50const W_IMPORTANCE: f64 = 0.20;
51
52/// Age at which recency has halved.
53pub const DEFAULT_HALF_LIFE_DAYS: f64 = 30.0;
54
55/// Turns text into a vector. Not implemented by soma: the seam exists
56/// so an embedding model can be plugged in from outside (a
57/// sentence-transformer behind the Python worker, an HTTP endpoint)
58/// without soma taking a dependency on one.
59///
60/// `id` identifies the model. It is stored next to every vector so
61/// vectors from different models are never silently compared — a
62/// cosine between two different embedding spaces is a number, not a
63/// similarity.
64pub trait Embedder: Send + Sync {
65    /// Stable identifier of the model, e.g. `"minilm-l6-v2"`.
66    fn id(&self) -> &str;
67
68    /// Embed one text.
69    fn embed(&self, text: &str) -> somatize_core::error::Result<Vec<f32>>;
70}
71
72/// The text an embedder should be given for a record: the same fields
73/// BM25 indexes, without the repetition weighting.
74pub 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/// What to retrieve, and what to measure it against.
97#[derive(Debug, Clone)]
98pub struct RetrievalQuery {
99    /// Free text. Empty is allowed: the other three terms still rank.
100    pub text: String,
101    /// Architecture to compare against, when the caller has one.
102    pub architecture: Option<ArchitectureFingerprint>,
103    /// Reference time for recency. A parameter, not `Utc::now()`, so
104    /// ranking is reproducible.
105    pub now: DateTime<Utc>,
106    /// Recency half-life in days; [`DEFAULT_HALF_LIFE_DAYS`] unless set.
107    pub half_life_days: f64,
108    /// Query vector, from the same embedder that produced the records'.
109    /// When present, it blends into the lexical term for records
110    /// carrying a vector from the *same* model.
111    pub embedding: Option<(String, Vec<f32>)>,
112    /// Maximum number of results returned.
113    pub limit: usize,
114    /// Restrict to one research line.
115    pub research_line: Option<String>,
116    /// Every listed tag must be present.
117    pub tags: Vec<String>,
118}
119
120impl RetrievalQuery {
121    /// A text query against `now`, with the default half-life.
122    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    /// Compare candidates structurally against this fingerprint,
136    /// activating the structural term.
137    pub fn with_architecture(mut self, architecture: ArchitectureFingerprint) -> Self {
138        self.architecture = Some(architecture);
139        self
140    }
141
142    /// Cap the number of results.
143    pub fn with_limit(mut self, limit: usize) -> Self {
144        self.limit = limit;
145        self
146    }
147
148    /// Restrict candidates to one research line, before scoring.
149    pub fn in_line(mut self, line: impl Into<String>) -> Self {
150        self.research_line = Some(line.into());
151        self
152    }
153
154    /// Require every listed tag on a candidate, before scoring.
155    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
156        self.tags = tags;
157        self
158    }
159}
160
161/// The four terms behind a score, so a result can explain itself.
162#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
163pub struct ScoreComponents {
164    /// BM25 relevance normalized against the best hit, optionally
165    /// blended with cosine similarity — in `[0, 1]`.
166    pub lexical: f64,
167    /// Architecture similarity to the query's fingerprint, in `[0, 1]`.
168    pub structural: f64,
169    /// Exponential age decay (see [`recency`]), in `(0, 1]`.
170    pub recency: f64,
171    /// How much the record is worth reading (see [`importance`]).
172    pub importance: f64,
173}
174
175/// A record and why it came back.
176#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
177pub struct ScoredRecord {
178    /// The matching record itself.
179    pub record: ExperimentRecord,
180    /// Composite score in `[0, 1]`.
181    pub score: f64,
182    /// The four terms the score was added from.
183    pub components: ScoreComponents,
184}
185
186impl ScoredRecord {
187    /// One-line, deterministic explanation of the ranking.
188    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
197/// Rank `records` against `query`, best first.
198///
199/// Ties break on record id, so the order is total and reproducible.
200pub 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    // BM25 is unbounded; normalizing against the best hit in this
223    // candidate set makes the term comparable with the other three.
224    let peak = raw.iter().cloned().fold(0.0_f64, f64::max);
225
226    // Which terms this query can actually speak to.
227    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
289/// Fold cosine similarity into the lexical term when — and only when —
290/// both sides carry a vector from the same model.
291fn 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
303/// Cosine similarity clamped to `[0, 1]` — a negative cosine is "not
304/// similar", not "less than nothing".
305fn 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
321/// Exponential decay with a half-life, in `(0, 1]`. A record from the
322/// future (clock skew) scores 1.0 rather than exploding.
323pub 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
334/// How much this record is worth reading, in `[0, 1]`.
335///
336/// Rewards recorded human intent (a hypothesis, notes), a machine
337/// conclusion, and a deliberate derivation. Puts a **floor of 0.6**
338/// under dead ends that carry a conclusion: a failure someone bothered
339/// to explain is one of the most valuable things in the pool.
340pub 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
360/// Whether the move that produced this record improved any metric.
361///
362/// Direction is not knowable in general (lower loss is better, higher
363/// f1 is better), so "improved" means: the objective metric went up
364/// when one is declared, else any metric went up.
365fn 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
378/// A run that failed, crashed, or moved every metric the wrong way.
379pub 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
392// ── BM25 ────────────────────────────────────────────────────────────
393
394/// Field weights, expressed as how many times a field's tokens are
395/// repeated in the document. A name match should beat a note match.
396const 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
423/// The tokens one field contributes, before repetition.
424fn 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
449/// Bag of terms for one record, with fields weighted by repetition.
450fn 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
460/// Lowercase alphanumeric terms, splitting `snake_case`, `kebab-case`,
461/// dotted paths and `camelCase`. No stemming: experiment vocabulary is
462/// identifiers and acronyms, which stemmers only damage.
463///
464/// Two camel boundaries, both needed by real filter names: `aB`
465/// (`valF1` → `val f1`) and `ABc`, the end of an acronym (`MoSHead` →
466/// `mo s head`).
467pub 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
496/// A BM25 index over one candidate set. Built per query: the pool is a
497/// journal of at most tens of thousands of lines, and rebuilding keeps
498/// scoring stateless and obviously correct.
499struct Bm25Index {
500    docs: Vec<HashMap<String, usize>>,
501    lengths: Vec<f64>,
502    avg_length: f64,
503    /// term → number of documents containing it.
504    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                // Standard BM25 idf, floored at zero: a term in every
548                // document is uninformative, never evidence against.
549                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    // ── Tokenizer ───────────────────────────────────────────────────
608
609    #[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        // No stemming: "training" must not collapse into "train".
621        assert_eq!(tokenize("training trains"), vec!["training", "trains"]);
622    }
623
624    // ── Lexical ─────────────────────────────────────────────────────
625
626    #[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        // The lexical weight was redistributed, not left on the floor.
678        assert!(hits[0].score > 0.5, "{}", hits[0].score);
679    }
680
681    // ── Structural ──────────────────────────────────────────────────
682
683    #[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        // 0.40 + 0.15 + 0.20 = 0.75 of weight, renormalized to 1.
719        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    // ── Recency ─────────────────────────────────────────────────────
725
726    #[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        // Never zero: an old record is faint, not invisible.
732        assert!(recency(now() - Duration::days(3650), now(), 30.0) > 0.0);
733        // Clock skew must not produce a score above 1.
734        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        // The whole reason the score is additive: a year-old dead end
740        // that matches the query must not be buried by today's noise.
741        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    // ── Importance ──────────────────────────────────────────────────
756
757    #[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(&regressed));
778        assert!(importance(&regressed) >= 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        // The objective went down; another metric going up is not a win.
793        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    // ── Ranking as a whole ──────────────────────────────────────────
812
813    #[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        // Every listed tag must be present, not just one.
887        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    // ── Embeddings ──────────────────────────────────────────────────
915
916    #[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            // Identical text everywhere, so the only thing that can
922            // move the lexical term is the vector.
923            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            // Numerically aligned, but from another embedding space:
934            // comparing it would be meaningless, so it must not count.
935            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}