Skip to main content

evidential_protocol/
scoring.rs

1//! Continuous Multi-Axis Scoring for the Evidential Protocol.
2//!
3//! Defines a scoring engine that evaluates entities across weighted axes
4//! and produces composite scores. Supports ranking and outlier detection.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// ScoringAxis
11// ---------------------------------------------------------------------------
12
13/// A single named scoring dimension with a weight and value.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ScoringAxis {
16    /// Human-readable axis name (e.g. "accuracy", "latency").
17    pub name: String,
18    /// Weight of this axis in the composite score (0.0 to 1.0).
19    pub weight: f64,
20    /// Raw value for this axis (0.0 to 1.0).
21    pub value: f64,
22}
23
24// ---------------------------------------------------------------------------
25// ContinuousScore
26// ---------------------------------------------------------------------------
27
28/// A multi-axis score for a single entity.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ContinuousScore {
31    /// Identifier of the scored entity.
32    pub entity_id: String,
33    /// Individual axis scores.
34    pub axes: Vec<ScoringAxis>,
35    /// Weighted composite score (0.0 to 1.0).
36    pub composite_score: f64,
37    /// Confidence in the composite score (0.0 to 1.0).
38    pub confidence: f64,
39    /// ISO-8601 timestamp of when this score was assessed.
40    pub assessed_at: String,
41}
42
43// ---------------------------------------------------------------------------
44// ScoringEngine
45// ---------------------------------------------------------------------------
46
47/// Engine that defines scoring axes and computes multi-axis scores.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ScoringEngine {
50    /// Axis definitions: `(name, weight)` pairs.
51    axes: Vec<(String, f64)>,
52}
53
54impl Default for ScoringEngine {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl ScoringEngine {
61    /// Create an empty scoring engine with no axes defined.
62    pub fn new() -> Self {
63        Self { axes: Vec::new() }
64    }
65
66    /// Define the scoring axes and their weights.
67    ///
68    /// Replaces any previously defined axes.
69    pub fn define_axes(&mut self, axes: Vec<(&str, f64)>) {
70        self.axes = axes
71            .into_iter()
72            .map(|(name, weight)| (name.to_string(), weight))
73            .collect();
74    }
75
76    /// Score an entity by providing values for each defined axis.
77    ///
78    /// Values not present in `values` default to 0.0. The composite score
79    /// is the weighted average of all axes. Confidence is the fraction of
80    /// defined axes that had values provided.
81    pub fn score(&self, entity_id: &str, values: &HashMap<String, f64>) -> ContinuousScore {
82        let total_weight: f64 = self.axes.iter().map(|(_, w)| w).sum();
83        let mut weighted_sum = 0.0_f64;
84        let mut axes = Vec::new();
85        let mut provided_count = 0_usize;
86
87        for (name, weight) in &self.axes {
88            let value = values.get(name).copied().unwrap_or(0.0);
89            if values.contains_key(name) {
90                provided_count += 1;
91            }
92            weighted_sum += value * weight;
93            axes.push(ScoringAxis {
94                name: name.clone(),
95                weight: *weight,
96                value,
97            });
98        }
99
100        let composite_score = if total_weight > 0.0 {
101            (weighted_sum / total_weight).clamp(0.0, 1.0)
102        } else {
103            0.0
104        };
105
106        let confidence = if self.axes.is_empty() {
107            0.0
108        } else {
109            provided_count as f64 / self.axes.len() as f64
110        };
111
112        ContinuousScore {
113            entity_id: entity_id.to_string(),
114            axes,
115            composite_score,
116            confidence,
117            assessed_at: chrono::Utc::now().to_rfc3339(),
118        }
119    }
120
121    /// Sort a slice of scores by composite score in descending order.
122    pub fn rank(scores: &mut [ContinuousScore]) {
123        scores.sort_by(|a, b| {
124            b.composite_score
125                .partial_cmp(&a.composite_score)
126                .unwrap_or(std::cmp::Ordering::Equal)
127        });
128    }
129
130    /// Return scores whose composite value deviates from the mean by more
131    /// than `threshold` (in absolute terms).
132    pub fn get_outliers(
133        scores: &[ContinuousScore],
134        threshold: f64,
135    ) -> Vec<&ContinuousScore> {
136        if scores.is_empty() {
137            return Vec::new();
138        }
139
140        let mean: f64 =
141            scores.iter().map(|s| s.composite_score).sum::<f64>() / scores.len() as f64;
142
143        scores
144            .iter()
145            .filter(|s| (s.composite_score - mean).abs() > threshold)
146            .collect()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn basic_scoring() {
156        let mut engine = ScoringEngine::new();
157        engine.define_axes(vec![("accuracy", 0.6), ("speed", 0.4)]);
158
159        let mut values = HashMap::new();
160        values.insert("accuracy".to_string(), 0.9);
161        values.insert("speed".to_string(), 0.7);
162
163        let score = engine.score("agent-1", &values);
164        // composite = (0.9*0.6 + 0.7*0.4) / (0.6+0.4) = 0.82
165        assert!((score.composite_score - 0.82).abs() < 1e-10);
166        assert!((score.confidence - 1.0).abs() < 1e-10);
167        assert_eq!(score.axes.len(), 2);
168    }
169
170    #[test]
171    fn partial_values() {
172        let mut engine = ScoringEngine::new();
173        engine.define_axes(vec![("a", 0.5), ("b", 0.5)]);
174
175        let mut values = HashMap::new();
176        values.insert("a".to_string(), 0.8);
177        // "b" not provided — defaults to 0.0.
178
179        let score = engine.score("x", &values);
180        // composite = (0.8*0.5 + 0.0*0.5) / 1.0 = 0.4
181        assert!((score.composite_score - 0.4).abs() < 1e-10);
182        assert!((score.confidence - 0.5).abs() < 1e-10);
183    }
184
185    #[test]
186    fn ranking() {
187        let mut engine = ScoringEngine::new();
188        engine.define_axes(vec![("score", 1.0)]);
189
190        let mut scores: Vec<ContinuousScore> = (1..=5)
191            .map(|i| {
192                let mut v = HashMap::new();
193                v.insert("score".to_string(), i as f64 * 0.2);
194                engine.score(&format!("e-{}", i), &v)
195            })
196            .collect();
197
198        ScoringEngine::rank(&mut scores);
199        assert_eq!(scores[0].entity_id, "e-5");
200        assert_eq!(scores[4].entity_id, "e-1");
201    }
202
203    #[test]
204    fn outlier_detection() {
205        let mut engine = ScoringEngine::new();
206        engine.define_axes(vec![("val", 1.0)]);
207
208        let scores: Vec<ContinuousScore> = vec![0.5, 0.5, 0.5, 0.5, 0.95]
209            .into_iter()
210            .enumerate()
211            .map(|(i, v)| {
212                let mut vals = HashMap::new();
213                vals.insert("val".to_string(), v);
214                engine.score(&format!("e-{}", i), &vals)
215            })
216            .collect();
217
218        // Mean = (0.5*4 + 0.95)/5 = 0.59. Outlier at 0.95 deviates by 0.36.
219        let outliers = ScoringEngine::get_outliers(&scores, 0.3);
220        assert_eq!(outliers.len(), 1);
221        assert_eq!(outliers[0].entity_id, "e-4");
222    }
223}