Skip to main content

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}