Skip to main content

uqa_operators/
fusion_wrappers.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Operator-side facades over attention, learned, multi-field, and calibrated
8//! vector fusion in the [`uqa_fusion`] family.
9//!
10//! Each wrapper folds the per-signal [`PostingList`]s its child
11//! operators emit into a single [`PostingList`]. In the heterogeneous
12//! fusers (attention, learned), unmatched documents receive a
13//! coverage-scaled default probability via
14//! [`crate::hybrid::coverage_based_default`] so they participate in
15//! the fusion rather than being dropped. The multi-field text fuser uses
16//! Lucene-style sparse absence: an unmatched field contributes zero.
17
18#![allow(
19    clippy::needless_pass_by_value,
20    clippy::similar_names,
21    clippy::too_many_lines,
22    clippy::explicit_iter_loop
23)]
24
25use std::collections::{BTreeMap, BTreeSet};
26use std::sync::Arc;
27
28use uqa_core::{Payload, PostingEntry, PostingList};
29use uqa_fusion::{AttentionFusion, LearnedFusion, MultiHeadAttentionFusion};
30use uqa_scoring::VectorProbabilityTransform;
31use uqa_storage::{StorageBackendError, StorageBackendResult};
32
33use crate::base::{
34    missing_backend, require_finite_score, require_probability, ExecutionContext, Operator,
35    OperatorResult,
36};
37use crate::hybrid::coverage_based_default;
38use crate::primitive::{ScoreOperator, TermOperator};
39
40type ScoreMap = BTreeMap<u64, f64>;
41type CollectedScores = (Vec<ScoreMap>, BTreeSet<u64>);
42
43fn collect_score_maps(
44    signals: &[Arc<dyn Operator>],
45    ctx: &ExecutionContext,
46) -> StorageBackendResult<CollectedScores> {
47    let mut maps: Vec<ScoreMap> = Vec::with_capacity(signals.len());
48    let mut all_ids: BTreeSet<u64> = BTreeSet::new();
49    for sig in signals {
50        let pl = sig.execute(ctx)?;
51        let mut m: BTreeMap<u64, f64> = BTreeMap::new();
52        for entry in pl.iter() {
53            require_probability(entry.payload.score, "learned/attention fusion")?;
54            m.insert(entry.doc_id, entry.payload.score);
55            all_ids.insert(entry.doc_id);
56        }
57        maps.push(m);
58    }
59    Ok((maps, all_ids))
60}
61
62fn require_single_active_evidence(probabilities: &[Option<f64>]) -> StorageBackendResult<f64> {
63    probabilities
64        .iter()
65        .flatten()
66        .next()
67        .copied()
68        .ok_or_else(|| {
69            StorageBackendError::Other(
70                "multi-field fusion invariant violated: the single active signal has no evidence"
71                    .to_string(),
72            )
73        })
74}
75
76/// Single-head or multi-head attention-weighted fusion operator. Shares
77/// dispatch with [`MultiHeadAttentionFusion`] via the
78/// [`AttentionFuser`] enum.
79pub enum AttentionFuser {
80    Single(AttentionFusion),
81    MultiHead(MultiHeadAttentionFusion),
82}
83
84impl AttentionFuser {
85    fn validate_inputs(
86        &self,
87        signal_count: usize,
88        query_feature_count: usize,
89    ) -> Result<(), &'static str> {
90        match self {
91            AttentionFuser::Single(attention) => {
92                attention.validate_inputs(signal_count, query_feature_count)
93            }
94            AttentionFuser::MultiHead(attention) => {
95                attention.validate_inputs(signal_count, query_feature_count)
96            }
97        }
98    }
99
100    fn fuse_batch(
101        &self,
102        probabilities: &[Vec<f64>],
103        query_features: &[f64],
104    ) -> Result<Vec<f64>, &'static str> {
105        match self {
106            AttentionFuser::Single(attention) => {
107                attention.fuse_batch(probabilities, query_features)
108            }
109            AttentionFuser::MultiHead(attention) => {
110                attention.fuse_batch(probabilities, query_features)
111            }
112        }
113    }
114}
115
116pub struct AttentionFusionOperator {
117    pub signals: Vec<Arc<dyn Operator>>,
118    pub attention: AttentionFuser,
119    pub query_features: Vec<f64>,
120}
121
122impl AttentionFusionOperator {
123    pub fn new(
124        signals: Vec<Arc<dyn Operator>>,
125        attention: AttentionFuser,
126        query_features: Vec<f64>,
127    ) -> Self {
128        Self {
129            signals,
130            attention,
131            query_features,
132        }
133    }
134}
135
136impl Operator for AttentionFusionOperator {
137    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
138        self.attention
139            .validate_inputs(self.signals.len(), self.query_features.len())
140            .map_err(|error| StorageBackendError::Other(error.to_string()))?;
141        let (score_maps, all_ids) = collect_score_maps(&self.signals, ctx)?;
142        let total = all_ids.len();
143        if total == 0 {
144            return Ok(PostingList::default());
145        }
146        let defaults: Vec<f64> = score_maps
147            .iter()
148            .map(|m| coverage_based_default(m.len(), total, 0.01))
149            .collect();
150        let mut candidate_ids = Vec::with_capacity(total);
151        let mut probabilities = Vec::with_capacity(total);
152        for doc_id in all_ids {
153            let probs: Vec<f64> = score_maps
154                .iter()
155                .enumerate()
156                .map(|(j, m)| *m.get(&doc_id).unwrap_or(&defaults[j]))
157                .collect();
158            candidate_ids.push(doc_id);
159            probabilities.push(probs);
160        }
161        let fused = self
162            .attention
163            .fuse_batch(&probabilities, &self.query_features)
164            .map_err(|error| StorageBackendError::Other(error.to_string()))?;
165        if fused.len() != candidate_ids.len() {
166            return Err(StorageBackendError::Other(format!(
167                "attention fusion returned {} scores for {} candidates",
168                fused.len(),
169                candidate_ids.len()
170            )));
171        }
172        let entries = candidate_ids
173            .into_iter()
174            .zip(fused)
175            .map(|(doc_id, score)| {
176                PostingEntry::new(
177                    doc_id,
178                    Payload {
179                        score,
180                        ..Default::default()
181                    },
182                )
183            })
184            .collect();
185        Ok(PostingList::from_sorted_unchecked(entries))
186    }
187}
188
189/// Learned-weight multi-signal fusion operator.
190pub struct LearnedFusionOperator {
191    pub signals: Vec<Arc<dyn Operator>>,
192    pub learned: LearnedFusion,
193}
194
195impl LearnedFusionOperator {
196    pub fn new(signals: Vec<Arc<dyn Operator>>, learned: LearnedFusion) -> Self {
197        Self { signals, learned }
198    }
199}
200
201impl Operator for LearnedFusionOperator {
202    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
203        self.learned
204            .validate_inputs(self.signals.len())
205            .map_err(|error| StorageBackendError::Other(error.to_string()))?;
206        let (score_maps, all_ids) = collect_score_maps(&self.signals, ctx)?;
207        let total = all_ids.len();
208        if total == 0 {
209            return Ok(PostingList::default());
210        }
211        let defaults: Vec<f64> = score_maps
212            .iter()
213            .map(|m| coverage_based_default(m.len(), total, 0.01))
214            .collect();
215        let mut entries: Vec<PostingEntry> = Vec::with_capacity(total);
216        for doc_id in all_ids {
217            let probs: Vec<f64> = score_maps
218                .iter()
219                .enumerate()
220                .map(|(j, m)| *m.get(&doc_id).unwrap_or(&defaults[j]))
221                .collect();
222            let fused = self
223                .learned
224                .fuse(&probs)
225                .map_err(|error| StorageBackendError::Other(error.to_string()))?;
226            entries.push(PostingEntry::new(
227                doc_id,
228                Payload {
229                    score: fused,
230                    ..Default::default()
231                },
232            ));
233        }
234        Ok(PostingList::from_sorted_unchecked(entries))
235    }
236}
237
238/// Multi-field Bayesian BM25 search (Section 12.2 #1, Paper 3).
239/// Searches every `field` with its corresponding query, scores each field
240/// through a prior-free [`uqa_scoring::BayesianBM25Scorer`], and fuses
241/// the per-field evidence through weighted robust positive-evidence pooling
242/// (`uqa_fusion::positive_evidence`); the configured `base_rate` enters the
243/// pool exactly once.
244pub struct MultiFieldSearchOperator {
245    pub fields: Vec<String>,
246    pub queries: Vec<String>,
247    pub weights: Vec<f64>,
248    pub bayesian_params: uqa_scoring::BayesianBM25Params,
249    pub fusion_alpha: f64,
250}
251
252impl MultiFieldSearchOperator {
253    pub fn new(fields: Vec<String>, query: impl Into<String>, weights: Option<Vec<f64>>) -> Self {
254        let n = fields.len();
255        let query = query.into();
256        Self {
257            fields,
258            queries: vec![query; n],
259            weights: weights.unwrap_or_else(|| vec![1.0; n]),
260            bayesian_params: uqa_scoring::BayesianBM25Params::default(),
261            fusion_alpha: 0.5,
262        }
263    }
264
265    pub fn with_queries(
266        fields: Vec<String>,
267        queries: Vec<String>,
268        weights: Option<Vec<f64>>,
269    ) -> Self {
270        let n = fields.len();
271        Self {
272            fields,
273            queries,
274            weights: weights.unwrap_or_else(|| vec![1.0; n]),
275            bayesian_params: uqa_scoring::BayesianBM25Params::default(),
276            fusion_alpha: 0.5,
277        }
278    }
279}
280
281impl Operator for MultiFieldSearchOperator {
282    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
283        use std::sync::Arc as StdArc;
284        use uqa_scoring::{BayesianBM25Scorer, Scorer};
285
286        let Some(idx) = ctx.inverted_index.as_ref() else {
287            return Err(missing_backend("inverted-index", "multi-field search"));
288        };
289        if self.fields.is_empty() {
290            return Err(StorageBackendError::Other(
291                "multi-field search requires at least one field".to_string(),
292            ));
293        }
294        if self.weights.len() != self.fields.len() {
295            return Err(StorageBackendError::Other(format!(
296                "multi-field search has {} fields but {} weights",
297                self.fields.len(),
298                self.weights.len()
299            )));
300        }
301        if self.queries.len() != self.fields.len() {
302            return Err(StorageBackendError::Other(format!(
303                "multi-field search has {} fields but {} queries",
304                self.fields.len(),
305                self.queries.len()
306            )));
307        }
308        // Score each field independently and collect the resulting
309        // probabilities per doc id. The scoring terms come from the
310        // same per-field search analyzer that [`TermOperator`] uses
311        // for matching, so term-frequency lookups see the tokens that
312        // were actually indexed.
313        let mut per_field: Vec<BTreeMap<u64, f64>> = Vec::with_capacity(self.fields.len());
314        let mut all_ids: BTreeSet<u64> = BTreeSet::new();
315        for (field, query) in self.fields.iter().zip(&self.queries) {
316            let analyzer = idx.get_search_analyzer(field);
317            let terms = analyzer.analyze(query)?;
318            let term_op: Arc<dyn Operator> = Arc::new(TermOperator::new(query, field));
319            let scorer: Arc<dyn Scorer> = Arc::new(
320                BayesianBM25Scorer::new(
321                    self.bayesian_params
322                        .scaled_for_query_terms(terms.len())
323                        .evidence_params(),
324                    StdArc::new(idx.field_stats(field)?),
325                )
326                .map_err(|error| StorageBackendError::Other(error.to_string()))?,
327            );
328            let score_op = ScoreOperator::new(scorer, term_op, terms, field);
329            let pl = score_op.execute(ctx)?;
330            let mut m: BTreeMap<u64, f64> = BTreeMap::new();
331            for entry in pl.iter() {
332                require_probability(entry.payload.score, "multi-field search")?;
333                m.insert(entry.doc_id, entry.payload.score);
334                all_ids.insert(entry.doc_id);
335            }
336            per_field.push(m);
337        }
338
339        let total = all_ids.len();
340        if total == 0 {
341            return Ok(PostingList::default());
342        }
343
344        let weight_sum: f64 = self.weights.iter().sum();
345        let normalized: Vec<f64> = if weight_sum > 0.0
346            && self
347                .weights
348                .iter()
349                .all(|weight| weight.is_finite() && *weight >= 0.0)
350        {
351            self.weights.iter().map(|w| w / weight_sum).collect()
352        } else {
353            return Err(StorageBackendError::Other(
354                "multi-field weights must be non-negative and have a positive finite sum"
355                    .to_string(),
356            ));
357        };
358
359        let active_field_count = per_field.iter().filter(|scores| !scores.is_empty()).count();
360        let mut fusion = uqa_fusion::RobustPositiveEvidencePool::new(self.fusion_alpha)
361            .map_err(|error| StorageBackendError::Other(error.to_string()))?;
362        if self.bayesian_params.base_rate > 0.0 {
363            fusion = fusion
364                .with_base_rate(self.bayesian_params.base_rate)
365                .map_err(|error| StorageBackendError::Other(error.to_string()))?;
366        }
367        let mut entries: Vec<PostingEntry> = Vec::with_capacity(total);
368        for doc_id in all_ids {
369            let probabilities: Vec<Option<f64>> = per_field
370                .iter()
371                .map(|scores| scores.get(&doc_id).copied())
372                .collect();
373            let fused = if active_field_count == 1 {
374                // A de-facto single signal skips the weighted mean and
375                // sqrt(n) scaling, but a configured prior still enters.
376                let evidence = require_single_active_evidence(&probabilities)?;
377                fusion.fuse(&[evidence])
378            } else {
379                fusion
380                    .fuse_weighted_sparse(&probabilities, &normalized)
381                    .map_err(|error| StorageBackendError::Other(error.to_string()))?
382            };
383            entries.push(PostingEntry::new(
384                doc_id,
385                Payload {
386                    score: fused,
387                    ..Default::default()
388                },
389            ));
390        }
391        Ok(PostingList::from_sorted_unchecked(entries))
392    }
393
394    fn cost_estimate(&self, stats: &uqa_core::IndexStats) -> f64 {
395        stats.total_docs as f64 * self.fields.len() as f64
396    }
397}
398
399// -------------------------------------------------------------------------
400// Calibrated vector
401// -------------------------------------------------------------------------
402
403/// How the relevant-document sample (`f_R`) is split from the
404/// retrieved pool before fitting the likelihood-ratio calibration.
405#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
406pub enum RelevantSampleSplit {
407    /// The closest quarter of the pool models the relevant density.
408    #[default]
409    TopQuartile,
410    /// Strategy 4.6.1 (Paper 5): documents before the dominant gap in
411    /// the sorted distances model the relevant density. Falls back to
412    /// the top quartile when the pool has no positive gap.
413    DistanceGap,
414}
415
416/// Query-pool vector score transform.
417///
418/// Fits the likelihood-ratio calibration from the retrieved pool at
419/// query time: the head of the sorted distance distribution (per
420/// [`RelevantSampleSplit`]) estimates the relevant density `f_R`, the
421/// tail estimates the background density `f_G`, and each candidate's
422/// posterior is `sigmoid(log(f_R(d) / f_G(d)) + logit(base_rate))` via
423/// [`VectorProbabilityTransform`]. An uninformative pool (too small,
424/// zero spread, or no head/tail separation) yields the prior for every
425/// candidate instead of fabricating discrimination. Because the same selected
426/// pool supplies both pseudo-classes, this is an unsupervised ranking transform,
427/// not a reusable calibrated-probability model. Use
428/// [`uqa_scoring::VectorCalibrationModel`] for the latter contract.
429pub struct QueryPoolVectorScoreOperator {
430    pub query_vector: Vec<f32>,
431    pub k: usize,
432    pub field: String,
433    /// Relevance prior folded into the posterior. The default `0.5`
434    /// contributes zero log-odds, so the output doubles as prior-free
435    /// evidence for fusion-level priors.
436    pub base_rate: f64,
437    pub split: RelevantSampleSplit,
438}
439
440/// Compatibility name for the former query-pool operator. Its output has
441/// never carried a held-out calibration guarantee.
442#[deprecated(
443    since = "0.1.0",
444    note = "use QueryPoolVectorScoreOperator; use VectorCalibrationModel for reusable calibrated probabilities"
445)]
446pub type CalibratedVectorOperator = QueryPoolVectorScoreOperator;
447
448impl QueryPoolVectorScoreOperator {
449    pub fn new(query_vector: Vec<f32>, k: usize, field: impl Into<String>) -> Self {
450        Self {
451            query_vector,
452            k,
453            field: field.into(),
454            base_rate: 0.5,
455            split: RelevantSampleSplit::default(),
456        }
457    }
458
459    pub fn with_split(mut self, split: RelevantSampleSplit) -> Self {
460        self.split = split;
461        self
462    }
463
464    pub fn with_base_rate(mut self, base_rate: f64) -> Self {
465        self.base_rate = base_rate;
466        self
467    }
468}
469
470/// Convert a retrieved cosine-similarity pool into query-local probability evidence without executing another vector lookup.
471pub fn calibrate_query_pool_postings(
472    raw: &PostingList,
473    split: RelevantSampleSplit,
474    base_rate: f64,
475) -> StorageBackendResult<PostingList> {
476    if !base_rate.is_finite() || base_rate <= 0.0 || base_rate >= 1.0 {
477        return Err(StorageBackendError::Other(format!(
478            "calibrated vector base_rate must be finite and in (0, 1), got {base_rate}"
479        )));
480    }
481    if raw.is_empty() {
482        return Ok(PostingList::default());
483    }
484
485    let mut distances = Vec::with_capacity(raw.len());
486    for entry in raw.entries() {
487        require_finite_score(entry.payload.score, "calibrated vector search")?;
488        if !(-1.0..=1.0).contains(&entry.payload.score) {
489            return Err(StorageBackendError::Other(format!(
490                "calibrated vector search requires cosine scores in [-1, 1], got {}",
491                entry.payload.score
492            )));
493        }
494        distances.push(1.0 - entry.payload.score);
495    }
496    let calibrator = fit_pool_calibration(&distances, split, base_rate)?;
497
498    let mut out_entries: Vec<PostingEntry> = Vec::with_capacity(raw.len());
499    for (entry, distance) in raw.iter().zip(&distances) {
500        let posterior = match calibrator.as_ref() {
501            Some(transform) => transform
502                .calibrate_one(*distance)
503                .map_err(|error| StorageBackendError::Other(error.to_string()))?,
504            None => base_rate,
505        };
506        out_entries.push(PostingEntry::new(
507            entry.doc_id,
508            Payload {
509                score: posterior.clamp(1e-6, 1.0 - 1e-6),
510                ..Default::default()
511            },
512        ));
513    }
514    out_entries.sort_by_key(|entry| entry.doc_id);
515    Ok(PostingList::from_sorted_unchecked(out_entries))
516}
517
518impl Operator for QueryPoolVectorScoreOperator {
519    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
520        if !self.base_rate.is_finite() || self.base_rate <= 0.0 || self.base_rate >= 1.0 {
521            return Err(StorageBackendError::Other(format!(
522                "calibrated vector base_rate must be finite and in (0, 1), got {}",
523                self.base_rate
524            )));
525        }
526        if self.query_vector.is_empty()
527            || self
528                .query_vector
529                .iter()
530                .any(|component| !component.is_finite())
531        {
532            return Err(StorageBackendError::Other(
533                "calibrated vector search requires a non-empty finite query vector".to_string(),
534            ));
535        }
536        let Some(idx) = ctx.vector_indexes.get(&self.field) else {
537            return Err(missing_backend("vector-index", "calibrated vector search"));
538        };
539        let raw = idx.search_knn(&self.query_vector, self.k)?;
540        calibrate_query_pool_postings(&raw, self.split, self.base_rate)
541    }
542}
543
544/// Fit the two-Gaussian likelihood-ratio calibration from a retrieved
545/// distance pool. Returns `None` when the pool carries no usable
546/// relevance signal: fewer than two candidates, negligible spread, or
547/// a head that is not closer than the tail.
548pub fn fit_pool_calibration(
549    distances: &[f64],
550    split: RelevantSampleSplit,
551    base_rate: f64,
552) -> StorageBackendResult<Option<VectorProbabilityTransform>> {
553    if !base_rate.is_finite() || base_rate <= 0.0 || base_rate >= 1.0 {
554        return Err(StorageBackendError::Other(format!(
555            "pool calibration base_rate must be finite and in (0, 1), got {base_rate}"
556        )));
557    }
558    if distances.iter().any(|distance| !distance.is_finite()) {
559        return Err(StorageBackendError::Other(
560            "pool calibration distances must be finite".to_string(),
561        ));
562    }
563    if distances.len() < 2 {
564        return Ok(None);
565    }
566    let mut sorted = distances.to_vec();
567    sorted.sort_by(f64::total_cmp);
568
569    let head_len = match split {
570        RelevantSampleSplit::TopQuartile => quartile_head(sorted.len()),
571        RelevantSampleSplit::DistanceGap => {
572            distance_gap_split(&sorted).unwrap_or_else(|| quartile_head(sorted.len()))
573        }
574    }
575    .clamp(1, sorted.len() - 1);
576
577    let mu_match = mean(&sorted[..head_len]);
578    let mu_random = mean(&sorted[head_len..]);
579    let sigma = standard_deviation(&sorted);
580    if sigma <= f64::EPSILON || mu_random - mu_match <= f64::EPSILON {
581        return Ok(None);
582    }
583    Ok(Some(
584        VectorProbabilityTransform::new(mu_match, mu_random, sigma, base_rate)
585            .map_err(|error| StorageBackendError::Other(error.to_string()))?,
586    ))
587}
588
589fn quartile_head(pool_size: usize) -> usize {
590    pool_size.div_ceil(4)
591}
592
593/// Strategy 4.6.1: index of the first element after the dominant gap
594/// between consecutive sorted distances, provided a positive gap exists.
595fn distance_gap_split(sorted: &[f64]) -> Option<usize> {
596    let mut max_gap = 0.0f64;
597    let mut split_index = None;
598    for (index, window) in sorted.windows(2).enumerate() {
599        let gap = window[1] - window[0];
600        if gap > max_gap {
601            max_gap = gap;
602            split_index = Some(index + 1);
603        }
604    }
605    split_index
606}
607
608fn mean(values: &[f64]) -> f64 {
609    values.iter().sum::<f64>() / values.len() as f64
610}
611
612fn standard_deviation(values: &[f64]) -> f64 {
613    let mu = mean(values);
614    let variance = values
615        .iter()
616        .map(|value| {
617            let difference = value - mu;
618            difference * difference
619        })
620        .sum::<f64>()
621        / values.len() as f64;
622    variance.sqrt()
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use uqa_core::{Payload, PostingEntry, PostingList};
629
630    struct LiteralOperator(Vec<(u64, f64)>);
631    impl Operator for LiteralOperator {
632        fn execute(&self, _ctx: &ExecutionContext) -> OperatorResult {
633            Ok(PostingList::from_sorted_unchecked(
634                self.0
635                    .iter()
636                    .map(|(d, s)| {
637                        PostingEntry::new(
638                            *d,
639                            Payload {
640                                score: *s,
641                                ..Default::default()
642                            },
643                        )
644                    })
645                    .collect(),
646            ))
647        }
648    }
649
650    #[test]
651    fn learned_fusion_combines_two_signals() {
652        let signals: Vec<Arc<dyn Operator>> = vec![
653            Arc::new(LiteralOperator(vec![(1, 0.8), (2, 0.6)])),
654            Arc::new(LiteralOperator(vec![(1, 0.7), (3, 0.4)])),
655        ];
656        let learned = LearnedFusion::new(2, 0.0);
657        let op = LearnedFusionOperator::new(signals, learned);
658        let pl = op.execute(&ExecutionContext::new()).unwrap();
659        let ids: Vec<u64> = pl.iter().map(|e| e.doc_id).collect();
660        assert_eq!(ids, vec![1, 2, 3]);
661    }
662
663    #[test]
664    fn missing_single_active_evidence_is_an_invariant_error() {
665        let error = require_single_active_evidence(&[None, None]).unwrap_err();
666        assert!(error.to_string().contains("single active signal"));
667    }
668
669    #[test]
670    fn query_pool_vector_missing_index_is_an_execution_error() {
671        let op = QueryPoolVectorScoreOperator::new(vec![0.0; 3], 0, "missing").with_base_rate(0.5);
672        let error = op.execute(&ExecutionContext::new()).unwrap_err();
673        assert!(error.to_string().contains("vector-index"));
674    }
675
676    #[test]
677    fn pool_calibration_discriminates_head_from_tail() {
678        let distances = [0.02, 0.05, 0.30, 0.35, 0.40, 0.45, 0.50, 0.55];
679        let transform = fit_pool_calibration(&distances, RelevantSampleSplit::TopQuartile, 0.5)
680            .expect("valid fit request")
681            .expect("separated pool fits");
682        let head = transform.calibrate_one(0.02).unwrap();
683        let mid = transform.calibrate_one(0.30).unwrap();
684        let tail = transform.calibrate_one(0.55).unwrap();
685        assert!(head > mid && mid > tail, "{head} > {mid} > {tail}");
686        assert!(head > 0.5, "head evidence must be positive, got {head}");
687        assert!(tail < 0.5, "tail evidence must be negative, got {tail}");
688    }
689
690    #[test]
691    fn pool_calibration_rejects_uninformative_pools() {
692        assert!(
693            fit_pool_calibration(&[0.3], RelevantSampleSplit::TopQuartile, 0.5)
694                .unwrap()
695                .is_none()
696        );
697        assert!(
698            fit_pool_calibration(&[0.3, 0.3, 0.3, 0.3], RelevantSampleSplit::TopQuartile, 0.5)
699                .unwrap()
700                .is_none()
701        );
702    }
703
704    #[test]
705    fn pool_calibration_rejects_invalid_numeric_inputs() {
706        assert!(
707            fit_pool_calibration(&[f64::NAN, 0.2], RelevantSampleSplit::TopQuartile, 0.5).is_err()
708        );
709        assert!(fit_pool_calibration(&[0.1, 0.2], RelevantSampleSplit::TopQuartile, 1.0).is_err());
710    }
711
712    #[test]
713    fn distance_gap_split_finds_the_semantic_cliff() {
714        let sorted = [0.05, 0.06, 0.07, 0.40, 0.42, 0.44];
715        assert_eq!(distance_gap_split(&sorted), Some(3));
716        assert_eq!(distance_gap_split(&[0.3, 0.3, 0.3]), None);
717    }
718}