stygian_plugin/reliability/score.rs
1//! `ReliabilityScore` value type and its discrete interpretation band.
2
3use serde::{Deserialize, Serialize};
4
5/// A 0.0–1.0 reliability score for an [`crate::domain::ExtractionResult`].
6///
7/// The score is the weighted sum of three sub-scores (see module-level docs
8/// for the table) and is paired with a discrete [`ReliabilityBand`] so
9/// callers can branch on coarse-grained quality without inspecting the
10/// continuous `overall` field.
11///
12/// # Example
13///
14/// ```
15/// use stygian_plugin::reliability::{ReliabilityScore, ReliabilityBand};
16///
17/// let score = ReliabilityScore {
18/// overall: 0.92,
19/// schema_completeness: 1.0,
20/// transformation_success: 1.0,
21/// retry_penalty: 0.0,
22/// band: ReliabilityBand::High,
23/// reasons: vec!["all regions extracted".to_string()],
24/// };
25/// assert_eq!(score.band, ReliabilityBand::High);
26/// ```
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct ReliabilityScore {
29 /// Weighted sum of the sub-scores, clamped to `[0.0, 1.0]`.
30 pub overall: f32,
31
32 /// Fraction of regions that produced data (0.0 = none, 1.0 = all).
33 pub schema_completeness: f32,
34
35 /// Fraction of regions whose transformations succeeded without error
36 /// (0.0 = none, 1.0 = all).
37 pub transformation_success: f32,
38
39 /// Penalty for retries taken to produce this result
40 /// (0.0 = no retries, 1.0 = capped retries).
41 pub retry_penalty: f32,
42
43 /// Discrete interpretation band.
44 pub band: ReliabilityBand,
45
46 /// Human-readable reasons that contributed to the score.
47 ///
48 /// Rendered as a list of short strings (one per contributing factor) so
49 /// log lines, MCP `debug` payloads, and JSON output stay compact.
50 pub reasons: Vec<String>,
51}
52
53impl ReliabilityScore {
54 /// Lower bound of the `High` band (inclusive).
55 pub const HIGH_THRESHOLD: f32 = 0.85;
56
57 /// Lower bound of the `Medium` band (inclusive).
58 pub const MEDIUM_THRESHOLD: f32 = 0.50;
59
60 /// Construct a `ReliabilityScore` from a raw `overall` value, clamping
61 /// it to `[0.0, 1.0]` and computing the matching band.
62 ///
63 /// # Example
64 ///
65 /// ```
66 /// use stygian_plugin::reliability::{ReliabilityScore, ReliabilityBand};
67 ///
68 /// let score = ReliabilityScore::from_overall(0.93);
69 /// assert_eq!(score.band, ReliabilityBand::High);
70 ///
71 /// let score = ReliabilityScore::from_overall(0.30);
72 /// assert_eq!(score.band, ReliabilityBand::Low);
73 /// ```
74 #[must_use]
75 pub fn from_overall(overall: f32) -> Self {
76 let overall = clamp_unit(overall);
77 Self {
78 overall,
79 schema_completeness: overall,
80 transformation_success: overall,
81 retry_penalty: 0.0,
82 band: ReliabilityBand::from_overall(overall),
83 reasons: Vec::new(),
84 }
85 }
86
87 /// Attach human-readable reasons to this score, returning the modified
88 /// value (consuming builder style).
89 ///
90 /// # Example
91 ///
92 /// ```
93 /// use stygian_plugin::reliability::ReliabilityScore;
94 ///
95 /// let score = ReliabilityScore::from_overall(0.7).with_reasons(vec!["partial".into()]);
96 /// assert_eq!(score.reasons, vec!["partial".to_string()]);
97 /// ```
98 #[must_use]
99 pub fn with_reasons(mut self, reasons: Vec<String>) -> Self {
100 self.reasons = reasons;
101 self
102 }
103}
104
105/// Discrete interpretation band for a [`ReliabilityScore`].
106///
107/// Boundaries: `Low` for `[0.0, 0.50)`, `Medium` for `[0.50, 0.85)`,
108/// `High` for `[0.85, 1.00]`. The enum serializes as a lowercase string
109/// (`"low"`, `"medium"`, `"high"`) so MCP and JSON consumers can branch
110/// without parsing the float `overall` field.
111///
112/// # Example
113///
114/// ```
115/// use stygian_plugin::reliability::ReliabilityBand;
116///
117/// assert_eq!(ReliabilityBand::from_overall(0.95), ReliabilityBand::High);
118/// assert_eq!(ReliabilityBand::from_overall(0.60), ReliabilityBand::Medium);
119/// assert_eq!(ReliabilityBand::from_overall(0.10), ReliabilityBand::Low);
120/// ```
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum ReliabilityBand {
124 /// Score in `[0.85, 1.00]` — production-ready.
125 High,
126
127 /// Score in `[0.50, 0.85)` — best-effort.
128 Medium,
129
130 /// Score in `[0.00, 0.50)` — unreliable; consider fallback.
131 Low,
132}
133
134impl ReliabilityBand {
135 /// Map a raw `overall` score to its [`ReliabilityBand`].
136 ///
137 /// # Example
138 ///
139 /// ```
140 /// use stygian_plugin::reliability::ReliabilityBand;
141 ///
142 /// assert_eq!(ReliabilityBand::from_overall(1.0), ReliabilityBand::High);
143 /// assert_eq!(ReliabilityBand::from_overall(0.8499), ReliabilityBand::Medium);
144 /// ```
145 #[must_use]
146 pub fn from_overall(overall: f32) -> Self {
147 if overall >= ReliabilityScore::HIGH_THRESHOLD {
148 Self::High
149 } else if overall >= ReliabilityScore::MEDIUM_THRESHOLD {
150 Self::Medium
151 } else {
152 Self::Low
153 }
154 }
155}
156
157impl std::fmt::Display for ReliabilityBand {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 match self {
160 Self::High => f.write_str("high"),
161 Self::Medium => f.write_str("medium"),
162 Self::Low => f.write_str("low"),
163 }
164 }
165}
166
167/// Clamp `value` to `[0.0, 1.0]`. NaN maps to `0.0`.
168#[inline]
169#[must_use]
170pub(crate) const fn clamp_unit(value: f32) -> f32 {
171 if value.is_nan() {
172 0.0
173 } else {
174 value.clamp(0.0, 1.0)
175 }
176}
177
178#[cfg(test)]
179#[allow(
180 clippy::unwrap_used,
181 clippy::expect_used,
182 clippy::panic,
183 clippy::indexing_slicing
184)]
185mod tests {
186 use super::*;
187
188 fn approx_eq(a: f32, b: f32) -> bool {
189 (a - b).abs() < f32::EPSILON
190 }
191
192 #[test]
193 fn test_from_overall_clamps_to_unit_interval() {
194 assert!(approx_eq(ReliabilityScore::from_overall(-0.5).overall, 0.0));
195 assert!(approx_eq(ReliabilityScore::from_overall(1.5).overall, 1.0));
196 assert!(approx_eq(
197 ReliabilityScore::from_overall(0.42).overall,
198 0.42
199 ));
200 }
201
202 #[test]
203 fn test_from_overall_nan_maps_to_zero() {
204 assert!(approx_eq(
205 ReliabilityScore::from_overall(f32::NAN).overall,
206 0.0
207 ));
208 }
209
210 #[test]
211 fn test_band_boundaries() {
212 assert_eq!(
213 ReliabilityBand::from_overall(ReliabilityScore::HIGH_THRESHOLD),
214 ReliabilityBand::High
215 );
216 assert_eq!(
217 ReliabilityBand::from_overall(ReliabilityScore::MEDIUM_THRESHOLD),
218 ReliabilityBand::Medium
219 );
220 assert_eq!(
221 ReliabilityBand::from_overall(ReliabilityScore::MEDIUM_THRESHOLD - 0.01),
222 ReliabilityBand::Low
223 );
224 }
225
226 #[test]
227 fn test_band_serde_is_lowercase() {
228 let json = serde_json::to_string(&ReliabilityBand::High).unwrap();
229 assert_eq!(json, "\"high\"");
230 let roundtrip: ReliabilityBand = serde_json::from_str(&json).unwrap();
231 assert_eq!(roundtrip, ReliabilityBand::High);
232 }
233
234 #[test]
235 fn test_with_reasons_preserves_other_fields() {
236 let mut score = ReliabilityScore::from_overall(0.6);
237 score.schema_completeness = 0.5;
238 score.transformation_success = 0.7;
239 let updated = score.clone().with_reasons(vec!["a".into(), "b".into()]);
240 assert_eq!(updated.reasons.len(), 2);
241 assert!((updated.overall - score.overall).abs() < f32::EPSILON);
242 assert!((updated.schema_completeness - 0.5).abs() < f32::EPSILON);
243 assert!((updated.transformation_success - 0.7).abs() < f32::EPSILON);
244 }
245
246 #[test]
247 fn test_band_display() {
248 assert_eq!(ReliabilityBand::High.to_string(), "high");
249 assert_eq!(ReliabilityBand::Medium.to_string(), "medium");
250 assert_eq!(ReliabilityBand::Low.to_string(), "low");
251 }
252}