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;