Skip to main content

score_set/
metric_f32.rs

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