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 /// Raw measured value, before [`Map0132`] normalization.
310 pub raw: f32,
311 /// Normalized score in `[0, 1]`.
312 pub score: f32,
313 /// Normalized weight (sums to 1 across all metrics).
314 pub weight: f32,
315 /// `score * weight`.
316 pub contribution: f32,
317}
318
319// ---------------------------------------------------------------------------
320// ScoreSet32 — weighted score set builder & closure factory
321// ---------------------------------------------------------------------------
322
323/// Builder for a weighted set of [`Metric32`]s.
324///
325/// `ScoreSet32` collects metrics with raw weights, normalizes them, and produces
326/// a closure — either a weighted-sum function or a breakdown iterator.
327///
328/// # Examples
329///
330/// ```ignore
331/// let scorer = ScoreSet32::new()
332/// .push(2.0, gc_metric)?
333/// .push(1.0, len_metric)?
334/// .sum()?;
335///
336/// let total: f32 = scorer(&ctx);
337/// ```
338pub struct ScoreSet32<C> {
339 entries: Vec<(f32, Metric32<C>)>,
340}
341
342impl<C> ScoreSet32<C> {
343 /// Create an empty score set builder.
344 #[inline]
345 pub fn new() -> Self {
346 Self {
347 entries: Vec::new(),
348 }
349 }
350
351 /// Add a metric with a raw (unnormalized) weight.
352 ///
353 /// The weight must be finite and strictly positive. Normalization happens
354 /// when [`sum`](Self::sum) or [`breakdown`](Self::breakdown) is called.
355 #[inline]
356 pub fn push(mut self, weight: f32, metric: Metric32<C>) -> Result<Self, &'static str> {
357 let _validated = GtZero::witness(weight)?;
358 self.entries.push((weight, metric));
359 Ok(self)
360 }
361
362 /// Consume the builder and return a weighted-sum closure.
363 ///
364 /// Normalizes all weights so they sum to 1, then returns a closure
365 /// `impl Fn(&C) -> f32` that evaluates every metric against the context
366 /// and returns the weighted sum.
367 ///
368 /// # Errors
369 ///
370 /// Returns an error if the set is empty or if weight normalization fails.
371 pub fn sum(self) -> Result<impl Fn(&C) -> f32, &'static str> {
372 let members = self.normalize()?;
373 Ok(move |ctx: &C| {
374 let mut total: f32 = 0.0;
375 for m in &members {
376 if let Ok(score) = m.metric.eval(ctx) {
377 total += score.into_inner() * m.weight.into_inner();
378 }
379 }
380 total
381 })
382 }
383
384 /// Consume the builder and return per-metric [`Breakdown32`] rows.
385 ///
386 /// Normalizes all weights so they sum to 1, evaluates every metric
387 /// against `ctx`, and returns the result as `impl IntoIterator`. The
388 /// returned value owns all data — no lifetime coupling to `ctx` — so
389 /// it can be passed out of local scopes freely.
390 ///
391 /// Use directly in a `for` loop or call `.into_iter()`.
392 ///
393 /// # Errors
394 ///
395 /// Returns an error if the set is empty or if weight normalization fails.
396 pub fn breakdown(self, ctx: &C) -> Result<impl IntoIterator<Item = Breakdown32>, &'static str> {
397 let members = self.normalize()?;
398 Ok(members
399 .into_iter()
400 .map(|m| {
401 let raw = (m.metric.measure)(ctx);
402 let score = m
403 .metric
404 .map01
405 .apply(raw)
406 .map(|w| w.into_inner())
407 .unwrap_or(0.0);
408 let weight = m.weight.into_inner();
409 Breakdown32 {
410 name: m.metric.name,
411 raw,
412 score,
413 weight,
414 contribution: score * weight,
415 }
416 })
417 .collect::<Vec<_>>())
418 }
419
420 /// Normalize raw weights into a sorted, validated container.
421 fn normalize(self) -> Result<Vec<NormalizedMember32<C>>, &'static str> {
422 if self.entries.is_empty() {
423 return Err("ScoreSet32: must contain at least one metric");
424 }
425
426 let raw_weights: Vec<f32> = self.entries.iter().map(|(w, _)| *w).collect();
427 let sum: f32 = raw_weights.iter().sum();
428 let normalized_raw: Vec<f32> = raw_weights.iter().map(|w| w / sum).collect();
429
430 // Sort a clone for binary search in NormalizedContainer
431 let mut sorted = normalized_raw.clone();
432 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
433
434 let container = NormalizedContainer::witness(sorted)?;
435
436 let members: Result<Vec<_>, _> = self
437 .entries
438 .into_iter()
439 .zip(normalized_raw.iter())
440 .map(|((_raw_weight, metric), &nw)| {
441 let weight = nw
442 .witness()
443 .by(|v| NormalizedWeight::from_normalized_container(*v, &container))?;
444 Ok(NormalizedMember32 { weight, metric })
445 })
446 .collect();
447
448 members
449 }
450}
451
452impl<C> Default for ScoreSet32<C> {
453 #[inline]
454 fn default() -> Self {
455 Self::new()
456 }
457}
458
459/// Internal: a metric paired with its normalized, witnessed weight.
460struct NormalizedMember32<C> {
461 weight: Witnessed<f32, NormalizedWeight>,
462 metric: Metric32<C>,
463}
464
465// ---------------------------------------------------------------------------
466// Free function: metric32()
467// ---------------------------------------------------------------------------
468
469/// Create a new metric with the given name.
470///
471/// This is the entry point for the metric builder pipeline:
472///
473/// ```ignore
474/// let m = metric32("cleanliness")
475/// .measure()
476/// .by(|ctx: &Restaurant| ctx.cleanliness)
477/// .map01()
478/// .linear(100.0);
479/// ```
480#[inline]
481pub fn metric32(name: &'static str) -> MetricNamingStage32 {
482 MetricNamingStage32 { name }
483}
484
485// ---------------------------------------------------------------------------
486// Tests
487// ---------------------------------------------------------------------------
488
489#[cfg(test)]
490mod tests_for_attack;
491#[cfg(test)]
492mod tests_for_metric;
493#[cfg(test)]
494mod tests_for_score_set;