Skip to main content

stygian_plugin/reliability/
selector.rs

1//! Score-weighted selection over a list of `(name, score)` candidates.
2//!
3//! The selector is the hook that lets a fallback chain (or any other
4//! higher-level orchestrator) consult the reliability score. The pattern:
5//!
6//! 1. Each candidate runs its extraction and produces a
7//!    [`ReliabilityScore`].
8//! 2. The orchestrator hands all `(name, score)` pairs to
9//!    [`ScoreWeightedSelector::pick_best`].
10//! 3. The candidate with the highest `overall` score wins; ties are
11//!    broken by the first registered candidate (preserving the registration
12//!    order so callers retain control).
13//!
14//! The selector is pure and stateless — pass it into any orchestrator
15//! without wiring concerns.
16
17use serde::{Deserialize, Serialize};
18
19use super::score::ReliabilityScore;
20
21/// A `(name, score)` candidate for [`ScoreWeightedSelector`].
22///
23/// `name` is an arbitrary caller-supplied label (typically the chain entry
24/// name or the candidate's URL). `score` is the [`ReliabilityScore`]
25/// produced by the candidate's extraction.
26///
27/// # Example
28///
29/// ```
30/// use stygian_plugin::reliability::{ReliabilityScore, ScoredCandidate};
31///
32/// let candidate = ScoredCandidate {
33///     name: "primary".to_string(),
34///     score: ReliabilityScore::from_overall(0.7),
35/// };
36/// ```
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ScoredCandidate {
39    /// Caller-supplied label (typically chain entry name).
40    pub name: String,
41
42    /// Reliability score produced by the candidate's extraction.
43    pub score: ReliabilityScore,
44}
45
46impl ScoredCandidate {
47    /// Construct a new candidate with the supplied name and score.
48    ///
49    /// # Example
50    ///
51    /// ```
52    /// use stygian_plugin::reliability::{ReliabilityScore, ScoredCandidate};
53    ///
54    /// let candidate = ScoredCandidate::new("primary", ReliabilityScore::from_overall(0.9));
55    /// assert_eq!(candidate.name, "primary");
56    /// ```
57    #[must_use]
58    pub fn new(name: impl Into<String>, score: ReliabilityScore) -> Self {
59        Self {
60            name: name.into(),
61            score,
62        }
63    }
64}
65
66/// Stateless selector that picks the highest-scoring [`ScoredCandidate`].
67///
68/// Ties are broken by registration order (the first candidate in the input
69/// vector wins). An empty input returns `None`.
70///
71/// # Example
72///
73/// ```
74/// use stygian_plugin::reliability::{ReliabilityScore, ScoreWeightedSelector, ScoredCandidate};
75///
76/// let candidates = vec![
77///     ScoredCandidate::new("primary", ReliabilityScore::from_overall(0.6)),
78///     ScoredCandidate::new("plugin", ReliabilityScore::from_overall(0.9)),
79/// ];
80/// let winner = ScoreWeightedSelector::pick_best(candidates).unwrap();
81/// assert_eq!(winner.name, "plugin");
82/// ```
83#[derive(Debug, Clone, Copy, Default)]
84pub struct ScoreWeightedSelector;
85
86impl ScoreWeightedSelector {
87    /// Pick the highest-scoring candidate. Returns `None` for an empty input.
88    ///
89    /// The first candidate with the maximum score wins on ties — this
90    /// preserves the registration order so callers retain deterministic
91    /// control over tie-breaking.
92    #[must_use]
93    pub fn pick_best(candidates: Vec<ScoredCandidate>) -> Option<ScoredCandidate> {
94        candidates.into_iter().reduce(|best, current| {
95            if current.score.overall > best.score.overall {
96                current
97            } else {
98                best
99            }
100        })
101    }
102
103    /// Pick the highest-scoring candidate by reference. Useful when the
104    /// caller already owns the candidates and does not want to move them.
105    #[must_use]
106    pub fn pick_best_ref(candidates: &[ScoredCandidate]) -> Option<&ScoredCandidate> {
107        candidates.iter().reduce(|best, current| {
108            if current.score.overall > best.score.overall {
109                current
110            } else {
111                best
112            }
113        })
114    }
115}
116
117#[cfg(test)]
118#[allow(
119    clippy::unwrap_used,
120    clippy::expect_used,
121    clippy::panic,
122    clippy::indexing_slicing
123)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_pick_best_empty_input_returns_none() {
129        assert!(ScoreWeightedSelector::pick_best(vec![]).is_none());
130        let empty: Vec<ScoredCandidate> = vec![];
131        assert!(ScoreWeightedSelector::pick_best_ref(&empty).is_none());
132    }
133
134    #[test]
135    fn test_pick_best_single_candidate() {
136        let candidates = vec![ScoredCandidate::new(
137            "only",
138            ReliabilityScore::from_overall(0.5),
139        )];
140        let winner = ScoreWeightedSelector::pick_best(candidates).unwrap();
141        assert_eq!(winner.name, "only");
142    }
143
144    #[test]
145    fn test_pick_best_picks_highest() {
146        let candidates = vec![
147            ScoredCandidate::new("low", ReliabilityScore::from_overall(0.3)),
148            ScoredCandidate::new("high", ReliabilityScore::from_overall(0.9)),
149            ScoredCandidate::new("mid", ReliabilityScore::from_overall(0.6)),
150        ];
151        let winner = ScoreWeightedSelector::pick_best(candidates).unwrap();
152        assert_eq!(winner.name, "high");
153    }
154
155    #[test]
156    fn test_pick_best_tie_broken_by_first() {
157        let candidates = vec![
158            ScoredCandidate::new("first", ReliabilityScore::from_overall(0.7)),
159            ScoredCandidate::new("second", ReliabilityScore::from_overall(0.7)),
160        ];
161        let winner = ScoreWeightedSelector::pick_best(candidates).unwrap();
162        assert_eq!(
163            winner.name, "first",
164            "ties must be broken by registration order"
165        );
166    }
167
168    #[test]
169    fn test_pick_best_ref_matches_pick_best() {
170        let candidates = vec![
171            ScoredCandidate::new("a", ReliabilityScore::from_overall(0.4)),
172            ScoredCandidate::new("b", ReliabilityScore::from_overall(0.8)),
173        ];
174        let winner_ref = ScoreWeightedSelector::pick_best_ref(&candidates).unwrap();
175        assert_eq!(winner_ref.name, "b");
176    }
177}