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}