score_set/metric_f32.rs
1//! Core implementation for `Score32 = f32`.
2//!
3//! This module provides the complete scoring framework: define metrics via a
4//! builder pipeline, combine them into a [`ScoreSet32`], and produce a closure
5//! that evaluates any `&C` context to either a weighted sum or a breakdown.
6
7use alloc::vec::Vec;
8use witnessed::{WitnessExt, Witnessed};
9
10use crate::value::{GtZero, NormalizedContainer, NormalizedWeight, Value01};
11
12// ---------------------------------------------------------------------------
13// Score32 type alias
14// ---------------------------------------------------------------------------
15
16/// The floating-point type used for all scores, weights, and contributions.
17pub type Score32 = f32;
18
19// ---------------------------------------------------------------------------
20// Map0132 — normalization strategy (data, not closures)
21// ---------------------------------------------------------------------------
22
23/// Normalization strategy that maps a raw measure to `[0, 1]`.
24///
25/// All variants except [`Custom`](Map0132::Custom) guarantee the output is in
26/// `[0, 1]` by construction. `Custom` is validated at evaluation time via
27/// [`Value01::witness`].
28#[derive(Clone, Debug)]
29pub enum Map0132 {
30 /// Clamp `raw` to `[0, 1]`.
31 Identity,
32 /// `raw / max`, clamped to `[0, 1]`.
33 Linear {
34 /// Upper bound for the raw value.
35 max: Score32,
36 },
37 /// Increasing sigmoid: `low → 0`, `high → 1`.
38 IncSigmoid {
39 /// Lower bound (≈0).
40 low: Score32,
41 /// Upper bound (≈1).
42 high: Score32,
43 },
44 /// Decreasing sigmoid: `low → 1`, `high → 0`.
45 DecSigmoid {
46 /// Lower bound (≈1).
47 low: Score32,
48 /// Upper bound (≈0).
49 high: Score32,
50 },
51 /// Cauchy (Lorentzian) distribution, symmetric about `center`.
52 Cauchy {
53 /// Peak center.
54 center: Score32,
55 /// Scale parameter.
56 scale: Score32,
57 },
58 /// User-provided normalization function.
59 ///
60 /// The function receives the raw measure value and must return a value in
61 /// `[0, 1]`. The output is validated at evaluation time.
62 Custom(fn(Score32) -> Score32),
63}
64
65impl Map0132 {
66 /// Apply the normalization to a raw score.
67 ///
68 /// Returns the normalized value. For `Custom`, the output is validated;
69 /// for all other variants correctness is guaranteed by construction.
70 #[inline]
71 pub fn apply(&self, raw: Score32) -> Result<Witnessed<Score32, Value01>, &'static str> {
72 let v = match self {
73 Self::Identity => raw.clamp(0.0, 1.0),
74 Self::Linear { max } => {
75 if *max <= 0.0 {
76 return Err("Map0132::Linear: max must be positive");
77 }
78 (raw / max).clamp(0.0, 1.0)
79 }
80 Self::IncSigmoid { low, high } => {
81 debug_assert!(high > low, "IncSigmoid: high must exceed low");
82 let mid = (low + high) / 2.0;
83 let steep = 10.0 / (high - low);
84 1.0 / (1.0 + libm::expf(-steep * (raw - mid)))
85 }
86 Self::DecSigmoid { low, high } => {
87 debug_assert!(high > low, "DecSigmoid: high must exceed low");
88 let mid = (low + high) / 2.0;
89 let steep = 10.0 / (high - low);
90 1.0 / (1.0 + libm::expf(steep * (raw - mid)))
91 }
92 Self::Cauchy { center, scale } => {
93 let z = (raw - center) / scale;
94 1.0 / (1.0 + z * z)
95 }
96 Self::Custom(f) => f(raw),
97 };
98 Value01::witness(v)
99 }
100}
101
102// ---------------------------------------------------------------------------
103// Metric32 — a single compiled scoring unit
104// ---------------------------------------------------------------------------
105
106/// A single named scoring metric with its normalization strategy.
107///
108/// `Metric32<C>` combines a pure measure function `fn(&C) -> Score32` with a
109/// [`Map0132`] normalization. It stores no closures that capture state, so
110/// [`Vec<Metric32<C>>`] works without trait objects.
111pub struct Metric32<C> {
112 /// Human-readable name for this metric.
113 pub name: &'static str,
114 measure: fn(&C) -> Score32,
115 map01: Map0132,
116}
117
118impl<C> Metric32<C> {
119 /// Evaluate this metric against a context.
120 ///
121 /// Returns the normalized score in `[0, 1]`, witnessed by [`Value01`].
122 #[inline]
123 pub fn eval(&self, ctx: &C) -> Result<Witnessed<Score32, Value01>, &'static str> {
124 let raw = (self.measure)(ctx);
125 self.map01.apply(raw)
126 }
127}
128
129impl<C> Clone for Metric32<C> {
130 fn clone(&self) -> Self {
131 Self {
132 name: self.name,
133 measure: self.measure,
134 map01: self.map01.clone(),
135 }
136 }
137}
138
139// ---------------------------------------------------------------------------
140// Metric32 builder pipeline
141// ---------------------------------------------------------------------------
142
143/// Entry point for building a [`Metric32`].
144///
145/// Created by [`metric32`].
146pub struct MetricNamingStage32 {
147 name: &'static str,
148}
149
150impl MetricNamingStage32 {
151 /// Transition to the measure stage.
152 #[inline]
153 pub fn measure(self) -> MeasureStage32 {
154 MeasureStage32 { name: self.name }
155 }
156}
157
158/// Waiting for a measure function.
159pub struct MeasureStage32 {
160 name: &'static str,
161}
162
163impl MeasureStage32 {
164 /// Provide the measure function `fn(&C) -> Score32`.
165 ///
166 /// The function must be a non-capturing closure or fn pointer that extracts
167 /// a raw score from the context `C`.
168 #[inline]
169 pub fn by<C>(self, measure: fn(&C) -> Score32) -> MeasuredStage32<C> {
170 MeasuredStage32 {
171 name: self.name,
172 measure,
173 }
174 }
175}
176
177/// Has a measure function, waiting for a [`Map0132`] strategy.
178pub struct MeasuredStage32<C> {
179 name: &'static str,
180 measure: fn(&C) -> Score32,
181}
182
183impl<C> MeasuredStage32<C> {
184 /// Transition to the map01 stage.
185 #[inline]
186 pub fn map01(self) -> Map01Stage32<C> {
187 Map01Stage32 {
188 name: self.name,
189 measure: self.measure,
190 }
191 }
192}
193
194/// Waiting for a normalization strategy.
195pub struct Map01Stage32<C> {
196 name: &'static str,
197 measure: fn(&C) -> Score32,
198}
199
200impl<C> Map01Stage32<C> {
201 /// Identity normalization: clamps raw to `[0, 1]`.
202 #[inline]
203 pub fn identity(self) -> Metric32<C> {
204 Metric32 {
205 name: self.name,
206 measure: self.measure,
207 map01: Map0132::Identity,
208 }
209 }
210
211 /// Linear normalization: `raw / max`, clamped to `[0, 1]`.
212 #[inline]
213 pub fn linear(self, max: Score32) -> Metric32<C> {
214 Metric32 {
215 name: self.name,
216 measure: self.measure,
217 map01: Map0132::Linear { max },
218 }
219 }
220
221 /// Increasing sigmoid: `low → ≈0`, `high → ≈1`.
222 ///
223 /// Uses a logistic curve with steepness `10 / (high - low)`.
224 #[inline]
225 pub fn inc_sigmoid(self, low: Score32, high: Score32) -> Metric32<C> {
226 Metric32 {
227 name: self.name,
228 measure: self.measure,
229 map01: Map0132::IncSigmoid { low, high },
230 }
231 }
232
233 /// Decreasing sigmoid: `low → ≈1`, `high → ≈0`.
234 ///
235 /// Uses a logistic curve with steepness `10 / (high - low)`, flipped.
236 #[inline]
237 pub fn dec_sigmoid(self, low: Score32, high: Score32) -> Metric32<C> {
238 Metric32 {
239 name: self.name,
240 measure: self.measure,
241 map01: Map0132::DecSigmoid { low, high },
242 }
243 }
244
245 /// Cauchy (Lorentzian) normalization.
246 ///
247 /// The function peaks at `center` and decays symmetrically with `scale`.
248 #[inline]
249 pub fn cauchy(self, center: Score32, scale: Score32) -> Metric32<C> {
250 Metric32 {
251 name: self.name,
252 measure: self.measure,
253 map01: Map0132::Cauchy { center, scale },
254 }
255 }
256
257 /// Custom normalization function.
258 ///
259 /// The function receives the raw measure value and must return a `[0, 1]`
260 /// score. Output is validated via [`Value01::witness`] at evaluation time.
261 #[inline]
262 pub fn by(self, map01: fn(Score32) -> Score32) -> Metric32<C> {
263 Metric32 {
264 name: self.name,
265 measure: self.measure,
266 map01: Map0132::Custom(map01),
267 }
268 }
269}
270
271// ---------------------------------------------------------------------------
272// Breakdown32 — per-metric detail
273// ---------------------------------------------------------------------------
274
275/// A single metric's contribution to the total score.
276///
277/// A single metric's contribution to the total score.
278///
279/// Returned by the lazy iterator from [`ScoreSet32::breakdown`].
280#[derive(Clone, Debug)]
281pub struct Breakdown32 {
282 /// Metric name.
283 pub name: &'static str,
284 /// Normalized score in `[0, 1]`.
285 pub score: Score32,
286 /// Normalized weight (sums to 1 across all metrics).
287 pub weight: Score32,
288 /// `score * weight`.
289 pub contribution: Score32,
290}
291
292// ---------------------------------------------------------------------------
293// ScoreSet32 — weighted score set builder & closure factory
294// ---------------------------------------------------------------------------
295
296/// Builder for a weighted set of [`Metric32`]s.
297///
298/// `ScoreSet32` collects metrics with raw weights, normalizes them, and produces
299/// a closure — either a weighted-sum function or a breakdown iterator.
300///
301/// # Examples
302///
303/// ```ignore
304/// let scorer = ScoreSet32::new()
305/// .push(2.0, gc_metric)?
306/// .push(1.0, len_metric)?
307/// .sum()?;
308///
309/// let total: f32 = scorer(&ctx);
310/// ```
311pub struct ScoreSet32<C> {
312 entries: Vec<(Score32, Metric32<C>)>,
313}
314
315impl<C> ScoreSet32<C> {
316 /// Create an empty score set builder.
317 #[inline]
318 pub fn new() -> Self {
319 Self {
320 entries: Vec::new(),
321 }
322 }
323
324 /// Add a metric with a raw (unnormalized) weight.
325 ///
326 /// The weight must be finite and strictly positive. Normalization happens
327 /// when [`sum`](Self::sum) or [`breakdown`](Self::breakdown) is called.
328 #[inline]
329 pub fn push(mut self, weight: Score32, metric: Metric32<C>) -> Result<Self, &'static str> {
330 let _validated = GtZero::witness(weight)?;
331 self.entries.push((weight, metric));
332 Ok(self)
333 }
334
335 /// Consume the builder and return a weighted-sum closure.
336 ///
337 /// Normalizes all weights so they sum to 1, then returns a closure
338 /// `impl Fn(&C) -> Score32` that evaluates every metric against the context
339 /// and returns the weighted sum.
340 ///
341 /// # Errors
342 ///
343 /// Returns an error if the set is empty or if weight normalization fails.
344 pub fn sum(self) -> Result<impl Fn(&C) -> Score32, &'static str> {
345 let members = self.normalize()?;
346 Ok(move |ctx: &C| {
347 let mut total: Score32 = 0.0;
348 for m in &members {
349 if let Ok(score) = m.metric.eval(ctx) {
350 total += score.into_inner() * m.weight.into_inner();
351 }
352 }
353 total
354 })
355 }
356
357 /// Consume the builder and return per-metric [`Breakdown32`] rows.
358 ///
359 /// Normalizes all weights so they sum to 1, evaluates every metric
360 /// against `ctx`, and returns the result as `impl IntoIterator`. The
361 /// returned value owns all data — no lifetime coupling to `ctx` — so
362 /// it can be passed out of local scopes freely.
363 ///
364 /// Use directly in a `for` loop or call `.into_iter()`.
365 ///
366 /// # Errors
367 ///
368 /// Returns an error if the set is empty or if weight normalization fails.
369 pub fn breakdown(self, ctx: &C) -> Result<impl IntoIterator<Item = Breakdown32>, &'static str> {
370 let members = self.normalize()?;
371 Ok(members
372 .into_iter()
373 .map(|m| {
374 let score = m.metric.eval(ctx).map(|w| w.into_inner()).unwrap_or(0.0);
375 let weight = m.weight.into_inner();
376 Breakdown32 {
377 name: m.metric.name,
378 score,
379 weight,
380 contribution: score * weight,
381 }
382 })
383 .collect::<Vec<_>>())
384 }
385
386 /// Normalize raw weights into a sorted, validated container.
387 fn normalize(self) -> Result<Vec<NormalizedMember32<C>>, &'static str> {
388 if self.entries.is_empty() {
389 return Err("ScoreSet32: must contain at least one metric");
390 }
391
392 let raw_weights: Vec<Score32> = self.entries.iter().map(|(w, _)| *w).collect();
393 let sum: Score32 = raw_weights.iter().sum();
394 let normalized_raw: Vec<Score32> = raw_weights.iter().map(|w| w / sum).collect();
395
396 // Sort a clone for binary search in NormalizedContainer
397 let mut sorted = normalized_raw.clone();
398 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
399
400 let container = NormalizedContainer::witness(sorted)?;
401
402 let members: Result<Vec<_>, _> = self
403 .entries
404 .into_iter()
405 .zip(normalized_raw.iter())
406 .map(|((_raw_weight, metric), &nw)| {
407 let weight = nw
408 .witness()
409 .by(|v| NormalizedWeight::from_normalized_container(*v, &container))?;
410 Ok(NormalizedMember32 { weight, metric })
411 })
412 .collect();
413
414 members
415 }
416}
417
418impl<C> Default for ScoreSet32<C> {
419 #[inline]
420 fn default() -> Self {
421 Self::new()
422 }
423}
424
425/// Internal: a metric paired with its normalized, witnessed weight.
426struct NormalizedMember32<C> {
427 weight: Witnessed<Score32, NormalizedWeight>,
428 metric: Metric32<C>,
429}
430
431// ---------------------------------------------------------------------------
432// Free function: metric32()
433// ---------------------------------------------------------------------------
434
435/// Create a new metric with the given name.
436///
437/// This is the entry point for the metric builder pipeline:
438///
439/// ```ignore
440/// let m = metric32("cleanliness")
441/// .measure()
442/// .by(|ctx: &Restaurant| ctx.cleanliness)
443/// .map01()
444/// .linear(100.0);
445/// ```
446#[inline]
447pub fn metric32(name: &'static str) -> MetricNamingStage32 {
448 MetricNamingStage32 { name }
449}
450
451// ---------------------------------------------------------------------------
452// Tests
453// ---------------------------------------------------------------------------
454
455#[cfg(test)]
456mod tests_for_attack;
457#[cfg(test)]
458mod tests_for_metric;
459#[cfg(test)]
460mod tests_for_score_set;