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;