termivar_scanner/planner/
scoring.rs1use serde::{Deserialize, Deserializer, Serialize};
2use termivar_core::Probability;
3
4use crate::planner::PlannerError;
5
6const MAX_BASIS_POINTS: u16 = 10_000;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
10#[serde(transparent)]
11pub struct BenefitScore(u16);
12
13impl BenefitScore {
14 pub const NONE: Self = Self(0);
16
17 pub const MAX: Self = Self(MAX_BASIS_POINTS);
19
20 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 pub fn from_percent(value: u8) -> Result<Self, PlannerError> {
30 Self::from_basis_points(u16::from(value) * 100)
31 }
32
33 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
51#[serde(transparent)]
52pub struct RiskScore(u16);
53
54impl RiskScore {
55 pub const MAX: Self = Self(MAX_BASIS_POINTS);
57
58 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 pub fn from_percent(value: u8) -> Result<Self, PlannerError> {
68 Self::from_basis_points(u16::from(value) * 100)
69 }
70
71 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
92#[serde(transparent)]
93pub struct ActionCost(u32);
94
95impl ActionCost {
96 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
126#[serde(transparent)]
127pub struct UtilityScore(u64);
128
129impl UtilityScore {
130 pub const ZERO: Self = Self(0);
132
133 pub const MIN_POSITIVE: Self = Self(1);
135
136 pub const fn from_units(units: u64) -> Self {
138 Self(units)
139 }
140
141 pub const fn units(self) -> u64 {
143 self.0
144 }
145}
146
147#[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 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 pub fn gain(&self) -> BenefitScore {
237 self.gain
238 }
239
240 pub fn confidence(&self) -> Probability {
242 self.confidence
243 }
244
245 pub fn business_value(&self) -> BenefitScore {
247 self.business_value
248 }
249
250 pub fn cost(&self) -> ActionCost {
252 self.cost
253 }
254
255 pub fn risk(&self) -> RiskScore {
257 self.risk
258 }
259
260 pub fn score(&self) -> UtilityScore {
262 self.score
263 }
264}