Skip to main content

score_set/
metric_f32.rs

1//! Core implementation for `f32` scoring.
2//!
3//! Defines [`Metric32`], [`ScoreSetTrait32`], [`Scored32`], [`Breakdown32`],
4//! the builder pipeline, and the [`score_set32!`] macro (generated into
5//! `gen_score_set32.rs`).
6
7use witnessed::Witnessed;
8
9use crate::value::{GtZero, Value01};
10
11// ---------------------------------------------------------------------------
12// Map0132 — normalization strategy (data, not closures)
13// ---------------------------------------------------------------------------
14
15/// Normalization strategy that maps a raw measure to `[0, 1]`.
16///
17/// All variants except [`Custom`](Map0132::Custom) guarantee the output is in
18/// `[0, 1]` by construction. `Custom` is validated at evaluation time via
19/// [`Value01::witness`].
20#[derive(Clone, Debug)]
21pub enum Map0132 {
22    /// Clamp `raw` to `[0, 1]`.
23    Identity,
24    /// `raw / max`, clamped to `[0, 1]`.
25    Linear {
26        /// Upper bound for the raw value.
27        max: f32,
28    },
29    /// Increasing sigmoid: `low → ≈0`, `high → ≈1`.
30    ///
31    /// Steepness is auto-calibrated: `k = 2·ln(1/ε − 1) / (high − low)` where
32    /// `ε = 10·f32::EPSILON`. At `raw = low` output ≈ ε, at `raw = high` ≈ 1−ε.
33    IncSigmoid {
34        /// Lower bound (≈0).
35        low: f32,
36        /// Upper bound (≈1).
37        high: f32,
38    },
39    /// Decreasing sigmoid: `low → ≈1`, `high → ≈0`.
40    ///
41    /// Same auto-calibrated steepness as [`IncSigmoid`](Map0132::IncSigmoid),
42    /// with the sign of `k` flipped. At `raw = low` output ≈ 1−ε, at
43    /// `raw = high` ≈ ε.
44    DecSigmoid {
45        /// Lower bound (≈1).
46        low: f32,
47        /// Upper bound (≈0).
48        high: f32,
49    },
50    /// Asymmetric Cauchy (Lorentzian) with independent left/right half-widths.
51    ///
52    /// Peaks at `center` with value 1. The half-width at half-maximum is
53    /// `half_left` for `raw < center` and `half_right` for `raw >= center`.
54    /// When `half_left == half_right` this is the classic symmetric Cauchy.
55    Cauchy {
56        /// Peak center.
57        center: f32,
58        /// Half-width at half-maximum for the left side (`raw < center`).
59        half_left: f32,
60        /// Half-width at half-maximum for the right side (`raw >= center`).
61        half_right: f32,
62    },
63    /// User-provided normalization function.
64    ///
65    /// The function receives the raw measure value and must return a value in
66    /// `[0, 1]`. The output is validated at evaluation time.
67    Custom(fn(f32) -> f32),
68}
69
70impl Map0132 {
71    /// Apply the normalization to a raw score.
72    ///
73    /// Returns the normalized value. For `Custom`, the output is validated;
74    /// for all other variants correctness is guaranteed by construction.
75    #[inline]
76    pub fn apply(&self, raw: f32) -> Result<Witnessed<f32, Value01>, &'static str> {
77        let v = match self {
78            Self::Identity => raw.clamp(0.0, 1.0),
79            Self::Linear { max } => {
80                if *max <= 0.0 {
81                    return Err("Map0132::Linear: max must be positive");
82                }
83                (raw / max).clamp(0.0, 1.0)
84            }
85            Self::IncSigmoid { low, high } => {
86                debug_assert!(high > low, "IncSigmoid: high must exceed low");
87                let two = 2.0_f32;
88                let eps = 10.0 * f32::EPSILON;
89                let x0 = (low + high) / two;
90                let k = two * libm::logf(1.0 / eps - 1.0) / (high - low);
91                1.0 / (1.0 + libm::expf(-k * (raw - x0)))
92            }
93            Self::DecSigmoid { low, high } => {
94                debug_assert!(high > low, "DecSigmoid: high must exceed low");
95                let two = 2.0_f32;
96                let eps = 10.0 * f32::EPSILON;
97                let x0 = (low + high) / two;
98                let k = two * libm::logf(1.0 / eps - 1.0) / (high - low);
99                1.0 / (1.0 + libm::expf(k * (raw - x0)))
100            }
101            Self::Cauchy {
102                center,
103                half_left,
104                half_right,
105            } => {
106                let h = if raw < *center {
107                    *half_left
108                } else {
109                    *half_right
110                };
111                let z = (raw - center) / h;
112                1.0 / (1.0 + z * z)
113            }
114            Self::Custom(f) => f(raw),
115        };
116        Value01::witness(v)
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Metric32 — a single compiled scoring unit
122// ---------------------------------------------------------------------------
123
124/// A single named scoring metric with its normalization strategy.
125///
126/// `Metric32<C, F>` combines a measure closure `F: Fn(&C) -> f32` with a
127/// [`Map0132`] normalization. The default `F = fn(&C) -> f32` keeps backward
128/// compatibility for fn-pointer metrics used with [`ScoreSet32`].
129///
130/// Use capturing closures for partial application (e.g. thresholds, config
131/// parameters), then combine heterogeneous metrics via the [`score_set32!`] macro.
132pub struct Metric32<C, F = fn(&C) -> f32> {
133    /// Human-readable name for this metric.
134    pub name: &'static str,
135    measure: F,
136    map01: Map0132,
137    _phantom: core::marker::PhantomData<fn(&C)>,
138}
139
140impl<C, F: Fn(&C) -> f32> Metric32<C, F> {
141    /// Evaluate this metric against a context.
142    ///
143    /// Returns the normalized score in `[0, 1]`, witnessed by [`Value01`].
144    #[inline]
145    pub fn eval(&self, ctx: &C) -> Result<Witnessed<f32, Value01>, &'static str> {
146        let raw = (self.measure)(ctx);
147        self.map01.apply(raw)
148    }
149
150    /// Produce a single [`Breakdown32`] row for this metric.
151    ///
152    /// Evaluates the measure closure and normalization against `ctx`, then
153    /// packs the result together with the given `weight` into a breakdown row.
154    ///
155    /// This is `pub` (not `pub(crate)`) because the `#[macro_export]`
156    /// [`score_set32!`] macro expands in the caller's crate — `$crate` items
157    /// must be fully public to be accessible across crate boundaries.
158    #[inline]
159    pub fn make_breakdown(&self, weight: f32, ctx: &C) -> Breakdown32 {
160        let raw = (self.measure)(ctx);
161        let score = self
162            .map01
163            .apply(raw)
164            .map(Witnessed::into_inner)
165            .unwrap_or(0.0);
166        Breakdown32 {
167            name: self.name,
168            raw,
169            score,
170            weight,
171            contribution: score * weight,
172        }
173    }
174}
175
176impl<C, F: Clone> Clone for Metric32<C, F> {
177    fn clone(&self) -> Self {
178        Self {
179            name: self.name,
180            measure: self.measure.clone(),
181            map01: self.map01.clone(),
182            _phantom: core::marker::PhantomData,
183        }
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Metric32 builder pipeline
189// ---------------------------------------------------------------------------
190
191/// Entry point for building a [`Metric32`].
192///
193/// Created by [`metric32`].
194pub struct MetricNamingStage32 {
195    name: &'static str,
196}
197
198impl MetricNamingStage32 {
199    /// Transition to the measure stage.
200    #[inline]
201    pub fn measure(self) -> MeasureStage32 {
202        MeasureStage32 { name: self.name }
203    }
204}
205
206/// Waiting for a measure function.
207pub struct MeasureStage32 {
208    name: &'static str,
209}
210
211impl MeasureStage32 {
212    /// Provide the measure closure `F: Fn(&C) -> f32`.
213    ///
214    /// Accepts both function pointers (`fn(&C) -> f32`) and capturing closures.
215    /// For use with [`ScoreSet32`], pass an fn pointer or a non-capturing
216    /// closure that coerces to one. For heterogeneous metric types, use
217    /// the [`score_set32!`] macro.
218    #[inline]
219    pub fn by<C, F>(self, measure: F) -> MeasuredStage32<C, F>
220    where
221        F: Fn(&C) -> f32,
222    {
223        MeasuredStage32::<C, F> {
224            name: self.name,
225            measure,
226            _phantom: core::marker::PhantomData,
227        }
228    }
229}
230
231/// Has a measure function, waiting for a [`Map0132`] strategy.
232pub struct MeasuredStage32<C, F = fn(&C) -> f32> {
233    name: &'static str,
234    measure: F,
235    _phantom: core::marker::PhantomData<fn(&C)>,
236}
237
238impl<C, F> MeasuredStage32<C, F> {
239    /// Transition to the map01 stage.
240    #[inline]
241    pub fn map01(self) -> Map01Stage32<C, F> {
242        Map01Stage32::<C, F> {
243            name: self.name,
244            measure: self.measure,
245            _phantom: core::marker::PhantomData,
246        }
247    }
248}
249
250/// Waiting for a normalization strategy.
251pub struct Map01Stage32<C, F = fn(&C) -> f32> {
252    name: &'static str,
253    measure: F,
254    _phantom: core::marker::PhantomData<fn(&C)>,
255}
256
257impl<C, F> Map01Stage32<C, F> {
258    /// Identity normalization: clamps raw to `[0, 1]`.
259    #[inline]
260    pub fn identity(self) -> Metric32<C, F> {
261        Metric32::<C, F> {
262            name: self.name,
263            measure: self.measure,
264            map01: Map0132::Identity,
265            _phantom: core::marker::PhantomData,
266        }
267    }
268
269    /// Linear normalization: `raw / max`, clamped to `[0, 1]`.
270    #[inline]
271    pub fn linear(self, max: f32) -> Metric32<C, F> {
272        Metric32::<C, F> {
273            name: self.name,
274            measure: self.measure,
275            map01: Map0132::Linear { max },
276            _phantom: core::marker::PhantomData,
277        }
278    }
279
280    /// Increasing sigmoid: `low → ≈0`, `high → ≈1`.
281    ///
282    /// Uses auto-calibrated steepness `k = 2·ln(1/ε − 1) / (high − low)` where
283    /// `ε = 10·f32::EPSILON`. At `raw = low` output ≈ ε, at `raw = high` ≈ 1−ε.
284    #[inline]
285    pub fn inc_sigmoid(self, low: f32, high: f32) -> Metric32<C, F> {
286        Metric32::<C, F> {
287            name: self.name,
288            measure: self.measure,
289            map01: Map0132::IncSigmoid { low, high },
290            _phantom: core::marker::PhantomData,
291        }
292    }
293
294    /// Decreasing sigmoid: `low → ≈1`, `high → ≈0`.
295    ///
296    /// Same auto-calibrated steepness as [`inc_sigmoid`](Self::inc_sigmoid),
297    /// with the sign flipped.
298    #[inline]
299    pub fn dec_sigmoid(self, low: f32, high: f32) -> Metric32<C, F> {
300        Metric32::<C, F> {
301            name: self.name,
302            measure: self.measure,
303            map01: Map0132::DecSigmoid { low, high },
304            _phantom: core::marker::PhantomData,
305        }
306    }
307
308    /// Asymmetric Cauchy (Lorentzian) normalization.
309    ///
310    /// Peaks at `center` with value 1. `half_left` controls the spread for
311    /// `raw < center`, `half_right` for `raw >= center`. When both are equal
312    /// this is the classic symmetric Cauchy.
313    #[inline]
314    pub fn cauchy(self, center: f32, half_left: f32, half_right: f32) -> Metric32<C, F> {
315        Metric32::<C, F> {
316            name: self.name,
317            measure: self.measure,
318            map01: Map0132::Cauchy {
319                center,
320                half_left,
321                half_right,
322            },
323            _phantom: core::marker::PhantomData,
324        }
325    }
326
327    /// Custom normalization function.
328    ///
329    /// The function receives the raw measure value and must return a `[0, 1]`
330    /// score. Output is validated via [`Value01::witness`] at evaluation time.
331    #[inline]
332    pub fn by(self, map01: fn(f32) -> f32) -> Metric32<C, F> {
333        Metric32::<C, F> {
334            name: self.name,
335            measure: self.measure,
336            map01: Map0132::Custom(map01),
337            _phantom: core::marker::PhantomData,
338        }
339    }
340}
341
342// ---------------------------------------------------------------------------
343// Breakdown32 — per-metric detail
344// ---------------------------------------------------------------------------
345
346/// A single metric's contribution to the total score.
347///
348/// Returned by the `.breakdown()` method on a [`score_set32!`] scorer.
349#[derive(Clone, Debug)]
350pub struct Breakdown32 {
351    /// Metric name.
352    pub name: &'static str,
353    /// Raw measured value, before [`Map0132`] normalization.
354    pub raw: f32,
355    /// Normalized score in `[0, 1]`.
356    pub score: f32,
357    /// Normalized weight (sums to 1 across all metrics).
358    pub weight: f32,
359    /// `score * weight`.
360    pub contribution: f32,
361}
362
363// ---------------------------------------------------------------------------
364// ScoreSetTrait32 — trait for evaluating a tuple of heterogeneous metrics
365// ---------------------------------------------------------------------------
366
367/// Trait implemented by tuples of [`Metric32`]s for weighted evaluation.
368///
369/// Per-arity impls are generated by xtask into `gen_score_set32.rs`.
370pub trait ScoreSetTrait32<C> {
371    /// Compute the weighted sum of all metric scores.
372    fn weighted_sum(&self, weights: &[f32], ctx: &C) -> f32;
373    /// Collect per-metric [`Breakdown32`] rows.
374    fn collect_breakdown(&self, weights: &[f32], ctx: &C) -> alloc::vec::Vec<Breakdown32>;
375}
376
377// ---------------------------------------------------------------------------
378// Scored32 — a validated flat heterogeneous scorer
379// ---------------------------------------------------------------------------
380
381/// A validated weighted scorer holding a flat tuple of [`Metric32`]s.
382///
383/// Created by the [`score_set32!`] macro.  Provides [`score`](Scored32::score)
384/// and [`breakdown`](Scored32::breakdown) via static dispatch.
385pub struct Scored32<C, T: ScoreSetTrait32<C>> {
386    metrics: T,
387    weights: alloc::vec::Vec<f32>,
388    _phantom: core::marker::PhantomData<fn(&C)>,
389}
390
391impl<C, T: ScoreSetTrait32<C>> Scored32<C, T> {
392    /// Build from a tuple of metrics and raw weights (validated).
393    ///
394    /// Called by the `score_set32!` macro via `$crate::Scored32::new`.
395    /// Public because `#[macro_export]` expands in the caller's crate.
396    #[inline]
397    pub fn new(metrics: T, raw_weights: &[f32]) -> Result<Self, &'static str> {
398        for &w in raw_weights {
399            let _ = GtZero::witness(w)?;
400        }
401        let sum: f32 = raw_weights.iter().sum();
402        let weights: alloc::vec::Vec<f32> = raw_weights.iter().map(|w| w / sum).collect();
403        Ok(Self {
404            metrics,
405            weights,
406            _phantom: core::marker::PhantomData,
407        })
408    }
409
410    /// Evaluate the weighted sum against a context.
411    #[inline]
412    pub fn score(&self, ctx: &C) -> f32 {
413        self.metrics.weighted_sum(&self.weights, ctx)
414    }
415
416    /// Produce per-metric breakdown rows.
417    #[inline]
418    pub fn breakdown(&self, ctx: &C) -> alloc::vec::Vec<Breakdown32> {
419        self.metrics.collect_breakdown(&self.weights, ctx)
420    }
421}
422
423// ---------------------------------------------------------------------------
424// Free function: metric32()
425// ---------------------------------------------------------------------------
426
427/// Create a new metric with the given name.
428///
429/// This is the entry point for the metric builder pipeline:
430///
431/// ```ignore
432/// let m = metric32("cleanliness")
433///     .measure()
434///     .by(|ctx: &Restaurant| ctx.cleanliness)
435///     .map01()
436///     .linear(100.0);
437/// ```
438#[inline]
439pub fn metric32(name: &'static str) -> MetricNamingStage32 {
440    MetricNamingStage32 { name }
441}
442
443// ---------------------------------------------------------------------------
444// Tests
445// ---------------------------------------------------------------------------
446
447#[cfg(test)]
448mod tests_for_attack;
449#[cfg(test)]
450mod tests_for_metric;
451#[cfg(test)]
452mod tests_for_score_set;