Skip to main content

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;