Skip to main content

termivar_scanner/planner/
scoring.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use termivar_core::Probability;
3
4use crate::planner::PlannerError;
5
6const MAX_BASIS_POINTS: u16 = 10_000;
7
8/// Normalized gain or business-value score in basis points.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
10#[serde(transparent)]
11pub struct BenefitScore(u16);
12
13impl BenefitScore {
14    /// No expected benefit.
15    pub const NONE: Self = Self(0);
16
17    /// Maximum normalized benefit.
18    pub const MAX: Self = Self(MAX_BASIS_POINTS);
19
20    /// Creates a benefit score from basis points.
21    pub fn from_basis_points(value: u16) -> Result<Self, PlannerError> {
22        if value > MAX_BASIS_POINTS {
23            return Err(PlannerError::BenefitOutOfRange(value));
24        }
25        Ok(Self(value))
26    }
27
28    /// Creates a benefit score from an integer percentage.
29    pub fn from_percent(value: u8) -> Result<Self, PlannerError> {
30        Self::from_basis_points(u16::from(value) * 100)
31    }
32
33    /// Returns the normalized score in basis points.
34    pub const fn basis_points(self) -> u16 {
35        self.0
36    }
37}
38
39impl<'de> Deserialize<'de> for BenefitScore {
40    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
41    where
42        D: Deserializer<'de>,
43    {
44        let value = u16::deserialize(deserializer)?;
45        Self::from_basis_points(value).map_err(serde::de::Error::custom)
46    }
47}
48
49/// Normalized operational risk in non-zero basis points.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
51#[serde(transparent)]
52pub struct RiskScore(u16);
53
54impl RiskScore {
55    /// Maximum normalized risk.
56    pub const MAX: Self = Self(MAX_BASIS_POINTS);
57
58    /// Creates a non-zero risk score from basis points.
59    pub fn from_basis_points(value: u16) -> Result<Self, PlannerError> {
60        if value == 0 || value > MAX_BASIS_POINTS {
61            return Err(PlannerError::RiskOutOfRange(value));
62        }
63        Ok(Self(value))
64    }
65
66    /// Creates a non-zero risk score from an integer percentage.
67    pub fn from_percent(value: u8) -> Result<Self, PlannerError> {
68        Self::from_basis_points(u16::from(value) * 100)
69    }
70
71    /// Returns the normalized score in basis points.
72    pub const fn basis_points(self) -> u16 {
73        self.0
74    }
75}
76
77impl<'de> Deserialize<'de> for RiskScore {
78    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
79    where
80        D: Deserializer<'de>,
81    {
82        let value = u16::deserialize(deserializer)?;
83        Self::from_basis_points(value).map_err(serde::de::Error::custom)
84    }
85}
86
87/// Positive estimated execution cost in planner-defined units.
88///
89/// A deployment may define one unit as one request, one second, or another
90/// consistent resource measure. Actions in one planner must use the same unit.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
92#[serde(transparent)]
93pub struct ActionCost(u32);
94
95impl ActionCost {
96    /// Creates a positive execution cost.
97    pub fn new(units: u32) -> Result<Self, PlannerError> {
98        if units == 0 {
99            return Err(PlannerError::ZeroCost);
100        }
101        Ok(Self(units))
102    }
103
104    /// Returns the estimated cost units.
105    pub const fn units(self) -> u32 {
106        self.0
107    }
108}
109
110impl<'de> Deserialize<'de> for ActionCost {
111    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112    where
113        D: Deserializer<'de>,
114    {
115        let value = u32::deserialize(deserializer)?;
116        Self::new(value).map_err(serde::de::Error::custom)
117    }
118}
119
120/// Fixed-point utility used only for deterministic ordering.
121///
122/// The value is not a probability. It is calculated as
123/// `gain * confidence * business_value / cost / risk` using the integer units
124/// exposed by each input type.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
126#[serde(transparent)]
127pub struct UtilityScore(u64);
128
129impl UtilityScore {
130    /// Zero utility.
131    pub const ZERO: Self = Self(0);
132
133    /// Smallest positive utility accepted by a default planning context.
134    pub const MIN_POSITIVE: Self = Self(1);
135
136    /// Creates a threshold or persisted score from raw utility units.
137    pub const fn from_units(units: u64) -> Self {
138        Self(units)
139    }
140
141    /// Returns raw fixed-point utility units.
142    pub const fn units(self) -> u64 {
143        self.0
144    }
145}
146
147/// Explainable inputs and result of one utility calculation.
148///
149/// # Example
150///
151/// ```rust
152/// use termivar_core::Probability;
153/// use termivar_scanner::{ActionCost, BenefitScore, RiskScore, UtilityBreakdown};
154///
155/// let utility = UtilityBreakdown::calculate(
156///     BenefitScore::from_percent(80)?,
157///     Probability::from_percent(75)?,
158///     BenefitScore::from_percent(90)?,
159///     ActionCost::new(100)?,
160///     RiskScore::from_percent(20)?,
161/// );
162///
163/// assert_eq!(utility.score().units(), 270_000_000);
164/// # Ok::<(), Box<dyn std::error::Error>>(())
165/// ```
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
167pub struct UtilityBreakdown {
168    gain: BenefitScore,
169    confidence: Probability,
170    business_value: BenefitScore,
171    pub(super) cost: ActionCost,
172    risk: RiskScore,
173    pub(super) score: UtilityScore,
174}
175
176impl<'de> Deserialize<'de> for UtilityBreakdown {
177    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
178    where
179        D: Deserializer<'de>,
180    {
181        #[derive(Deserialize)]
182        struct WireUtility {
183            gain: BenefitScore,
184            confidence: Probability,
185            business_value: BenefitScore,
186            cost: ActionCost,
187            risk: RiskScore,
188            score: UtilityScore,
189        }
190
191        let wire = WireUtility::deserialize(deserializer)?;
192        let utility = Self::calculate(
193            wire.gain,
194            wire.confidence,
195            wire.business_value,
196            wire.cost,
197            wire.risk,
198        );
199        if utility.score != wire.score {
200            return Err(serde::de::Error::custom(format!(
201                "serialized utility {} does not match computed utility {}",
202                wire.score.units(),
203                utility.score.units()
204            )));
205        }
206        Ok(utility)
207    }
208}
209
210impl UtilityBreakdown {
211    /// Calculates utility with integer arithmetic and half-up rounding.
212    pub fn calculate(
213        gain: BenefitScore,
214        confidence: Probability,
215        business_value: BenefitScore,
216        cost: ActionCost,
217        risk: RiskScore,
218    ) -> Self {
219        let numerator = u128::from(gain.basis_points())
220            * u128::from(confidence.parts_per_million())
221            * u128::from(business_value.basis_points());
222        let denominator = u128::from(cost.units()) * u128::from(risk.basis_points());
223        let rounded = (numerator + denominator / 2) / denominator;
224        let score = u64::try_from(rounded).expect("validated utility factors fit in u64");
225        Self {
226            gain,
227            confidence,
228            business_value,
229            cost,
230            risk,
231            score: UtilityScore(score),
232        }
233    }
234
235    /// Returns expected information or security gain.
236    pub fn gain(&self) -> BenefitScore {
237        self.gain
238    }
239
240    /// Returns the selected Bayesian hypothesis posterior.
241    pub fn confidence(&self) -> Probability {
242        self.confidence
243    }
244
245    /// Returns target business value.
246    pub fn business_value(&self) -> BenefitScore {
247        self.business_value
248    }
249
250    /// Returns estimated execution cost.
251    pub fn cost(&self) -> ActionCost {
252        self.cost
253    }
254
255    /// Returns normalized operational risk.
256    pub fn risk(&self) -> RiskScore {
257        self.risk
258    }
259
260    /// Returns the final fixed-point utility.
261    pub fn score(&self) -> UtilityScore {
262        self.score
263    }
264}