score_set/finite.rs
1// ===========================================================================
2// finite_metric! — generate a zero-vtable finite metric enum (Layer 2)
3// ===========================================================================
4
5/// Declare a finite enum of metric types with static-dispatch `eval`.
6///
7/// This macro generates an enum whose variants each wrap a concrete metric
8/// type. The generated [`Scorable`](crate::Scorable) implementation uses
9/// `match` + static dispatch — zero vtable overhead.
10///
11/// # Syntax — concrete form
12///
13/// `float` and `subject` are concrete types of the scoring domain:
14///
15/// ```ignore
16/// finite_metric! {
17/// metric => DnaKind,
18/// float => f64,
19/// subject => DnaContext<'static>,
20/// dimensions =>
21/// Gc(GcRatio),
22/// Len(SeqLen),
23/// }
24/// ```
25///
26/// # Syntax — generic form
27///
28/// `T, I` in angle brackets on the metric name declare type parameters.
29/// No separate `float`/`subject` keys needed — the names appear in `<T, I>`:
30///
31/// ```ignore
32/// finite_metric! {
33/// pub metric => MetricKind<T, I>,
34/// dimensions =>
35/// Gc(GcRatio<T, I>),
36/// Tm(TmScore<T, I>),
37/// }
38/// }
39/// ```
40///
41/// # Generated items
42///
43/// - An enum with the listed variants.
44/// - A [`Scorable`](crate::Scorable) implementation with
45/// static-dispatch `eval` and `name` methods.
46///
47/// # Requirements on variant types
48///
49/// Each variant's inner type must provide:
50/// - `fn eval(&self, input: &I) -> Witnessed<T, Value01>`
51/// - `fn name(&self) -> &str`
52///
53/// Both [`Metric`](crate::Metric) and `Box<dyn Scorable<T, I>>` satisfy
54/// this contract.
55#[macro_export]
56macro_rules! finite_metric {
57 // ---- concrete: metric => Name, float => f64, subject => DnaContext ----
58 (
59 $(#[$attr:meta])*
60 $vis:vis
61 metric => $name:ident,
62 float => $T:ty,
63 subject => $I:ty,
64 dimensions => $($variant:ident($ty:ty)),+ $(,)?
65 ) => {
66 $(#[$attr])*
67 #[allow(clippy::pub_enum_variant_fields)]
68 $vis enum $name {
69 $($variant($ty),)+
70 }
71
72 impl $crate::Scorable<$T, $I> for $name {
73 #[inline]
74 fn eval(&self, input: &$I) -> $crate::Witnessed<$T, $crate::Value01> {
75 match self {
76 $(Self::$variant(m) => m.eval(input)),+
77 }
78 }
79
80 #[inline]
81 fn name(&self) -> &str {
82 match self {
83 $(Self::$variant(m) => m.name()),+
84 }
85 }
86 }
87 };
88
89 // ---- generic: metric => Name<T, I>, dimensions => ... ----
90 (
91 $(#[$attr:meta])*
92 $vis:vis
93 metric => $name:ident<$T:ident, $I:ident>,
94 dimensions => $($variant:ident($ty:ty)),+ $(,)?
95 ) => {
96 $(#[$attr])*
97 #[allow(clippy::pub_enum_variant_fields)]
98 $vis enum $name<$T: $crate::Float, $I> {
99 $($variant($ty)),+
100 }
101
102 impl<$T: $crate::Float, $I> $crate::Scorable<$T, $I> for $name<$T, $I> {
103 #[inline]
104 fn eval(&self, input: &$I) -> $crate::Witnessed<$T, $crate::Value01> {
105 match self {
106 $(Self::$variant(m) => m.eval(input)),+
107 }
108 }
109
110 #[inline]
111 fn name(&self) -> &str {
112 match self {
113 $(Self::$variant(m) => m.name()),+
114 }
115 }
116 }
117 };
118}
119
120// ===========================================================================
121// FiniteScoreSet — weighted set with finite-enum dispatch (Layer 2)
122// ===========================================================================
123
124use crate::breakdown::Breakdown;
125use crate::dynamic::Scorable;
126use crate::float::Float;
127use crate::value::{GtZero, NormalizedContainer, NormalizedWeight, Value01};
128use core::marker::PhantomData;
129use witnessed::{WitnessExt, Witnessed};
130
131// ---------------------------------------------------------------------------
132// FiniteMember — a single weighted metric in a FiniteScoreSet
133// ---------------------------------------------------------------------------
134
135/// A member of a [`FiniteScoreSet`]: a normalized weight paired with a metric
136/// enum variant.
137///
138/// See [`Member`](crate::Member) for the Layer-1 equivalent.
139pub struct FiniteMember<T: Float, E> {
140 /// The normalized weight.
141 pub weight: Witnessed<T, NormalizedWeight>,
142 /// The metric enum variant.
143 pub metric: E,
144}
145
146impl<T: Float, E> FiniteMember<T, E> {
147 /// Compute the weighted contribution of a metric score.
148 ///
149 /// `contribute(score) = score × normalized_weight`
150 #[inline]
151 pub fn contribute(&self, value: Witnessed<T, Value01>) -> T {
152 value.into_inner() * self.weight.into_inner()
153 }
154
155 /// Return a reference to the metric.
156 #[inline]
157 pub fn metric(&self) -> &E {
158 &self.metric
159 }
160}
161
162// ---------------------------------------------------------------------------
163// FiniteScoreSet — weighted set with enum-based static dispatch (Layer 2)
164// ---------------------------------------------------------------------------
165
166/// A weighted set of scoring operators using enum-based static dispatch.
167///
168/// `FiniteScoreSet` stores a `Vec` of [`FiniteMember`]s, each wrapping a
169/// variant of a user-declared metric enum. At evaluation time, the enum's
170/// `eval` method dispatches via `match` — zero vtable overhead for all
171/// non-`Custom` variants.
172///
173/// Construct via [`finite_score_set!`](crate::finite_score_set!), the
174/// [`FiniteScoreSetBuilder`], or call [`.score()`](FiniteScoreSet::score)
175/// directly.
176///
177/// # Type parameters
178///
179/// - `T: Float` — the floating-point type (`f32` or `f64`).
180/// - `I` — the input type passed to each metric.
181/// - `E: Scorable<T, I>` — the metric enum generated by
182/// [`finite_metric!`](crate::finite_metric!).
183///
184/// # Example
185///
186/// ```ignore
187/// let set = FiniteScoreSet::<f64, &str, TestKind<f64, &str>>::normalize(vec![
188/// (2.0, TestKind::AlwaysZero(ConstMetric::new("zero", 0.0))),
189/// (3.0, TestKind::AlwaysOne(ConstMetric::new("one", 1.0))),
190/// ])?;
191///
192/// let total = set.sum(&"input");
193/// // total = 0.4 * 0 + 0.6 * 1 = 0.6
194/// ```
195pub struct FiniteScoreSet<T: Float, I, E> {
196 members: Vec<FiniteMember<T, E>>,
197 _phantom: PhantomData<I>,
198}
199
200impl<T: Float, I, E: Scorable<T, I>> FiniteScoreSet<T, I, E> {
201 /// Normalize raw weights and validate the resulting set.
202 ///
203 /// Each weight must be finite and strictly positive. Weights are normalized
204 /// to sum to 1.
205 #[doc(hidden)]
206 pub fn normalize(entries: Vec<(T, E)>) -> Result<Self, &'static str> {
207 if entries.is_empty() {
208 return Err("FiniteScoreSet: must have at least one member");
209 }
210
211 // Validate all weights are > 0
212 for (w, _) in &entries {
213 GtZero::witness(*w)?;
214 }
215
216 let sum: T = entries.iter().fold(T::zero(), |acc, (w, _)| acc + *w);
217
218 let mut normalized: Vec<T> = entries.iter().map(|(w, _)| *w / sum).collect();
219
220 // Sort a copy for binary search in NormalizedWeight
221 let mut sorted = normalized.clone();
222 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
223 let container = NormalizedContainer::witness(sorted)?;
224
225 let members: Vec<FiniteMember<T, E>> = entries
226 .into_iter()
227 .zip(normalized.drain(..))
228 .map(|((_, metric), nw)| {
229 let weight = nw
230 .witness()
231 .by(|v| NormalizedWeight::from_normalized_container(*v, &container))?;
232 Ok(FiniteMember { weight, metric })
233 })
234 .collect::<Result<Vec<_>, &'static str>>()?;
235
236 Ok(FiniteScoreSet {
237 members,
238 _phantom: PhantomData,
239 })
240 }
241
242 /// Evaluate all metrics against `input` and sum their weighted contributions.
243 ///
244 /// This is the most common aggregation: each metric is evaluated, multiplied
245 /// by its normalized weight, and summed. Zero-allocation convenience.
246 ///
247 /// For custom aggregation, use [`.score()`](Self::score) instead.
248 #[inline]
249 pub fn sum(&self, input: &I) -> T {
250 self.members
251 .iter()
252 .fold(T::zero(), |acc, m| acc + m.contribute(m.metric.eval(input)))
253 }
254
255 /// Enter the scoring stage, returning a reference to all members.
256 ///
257 /// Use [`.by()`](FiniteScoreStage::by) on the returned stage to apply a
258 /// custom aggregation, or [`.sum()`](Self::sum) for the standard
259 /// weighted-sum shortcut.
260 ///
261 /// # Example
262 ///
263 /// ```ignore
264 /// let total = set.score().by(|members| {
265 /// members.iter().fold(0.0, |acc, m| {
266 /// acc + m.contribute(m.metric().eval(&input))
267 /// })
268 /// });
269 /// ```
270 #[inline]
271 pub fn score(&self) -> FiniteScoreStage<'_, T, I, E> {
272 FiniteScoreStage {
273 members: &self.members,
274 _phantom: PhantomData,
275 }
276 }
277
278 /// Return the number of members in this set.
279 #[inline]
280 pub fn len(&self) -> usize {
281 self.members.len()
282 }
283
284 /// Return `true` if the set has no members.
285 #[inline]
286 pub fn is_empty(&self) -> bool {
287 self.members.is_empty()
288 }
289
290 /// Iterate over the members.
291 #[inline]
292 pub fn iter(&self) -> impl Iterator<Item = &FiniteMember<T, E>> {
293 self.members.iter()
294 }
295
296 /// Evaluate all metrics against `input` and return a per-metric breakdown.
297 ///
298 /// Unlike [`.sum()`](Self::sum) which returns only the aggregate,
299 /// `breakdown` returns one [`Breakdown`] row per member with the metric's
300 /// name, raw score, normalized weight, and weighted contribution.
301 #[inline]
302 pub fn breakdown(&self, input: &I) -> Vec<Breakdown<'_, T>> {
303 self.members
304 .iter()
305 .map(|m| {
306 let score_witness = m.metric.eval(input);
307 let score_val: T = *score_witness;
308 Breakdown {
309 name: m.metric.name(),
310 score: score_val,
311 weight: m.weight.into_inner(),
312 contribution: m.contribute(score_witness),
313 }
314 })
315 .collect()
316 }
317
318 /// Create a builder for incremental construction of a `FiniteScoreSet`.
319 ///
320 /// Use this when members are not known up front — push them one by one,
321 /// then call [`.build()`](FiniteScoreSetBuilder::build) to finalize.
322 #[inline]
323 pub fn builder() -> FiniteScoreSetBuilder<T, I, E> {
324 FiniteScoreSetBuilder {
325 entries: Vec::new(),
326 _phantom: PhantomData,
327 }
328 }
329}
330
331// ---------------------------------------------------------------------------
332// FiniteScoreStage — member reference for custom aggregation (Layer 2)
333// ---------------------------------------------------------------------------
334
335/// The scoring stage for a [`FiniteScoreSet`], created by
336/// [`FiniteScoreSet::score`].
337///
338/// Holds a reference to the set's members. Call
339/// [`.by()`](FiniteScoreStage::by) to apply a custom aggregation over the
340/// member slice. For the standard weighted-sum shortcut, use
341/// [`FiniteScoreSet::sum`] instead.
342///
343/// # Examples
344///
345/// ```ignore
346/// // Standard weighted sum via the stage:
347/// let total = set.score().by(|members| {
348/// members.iter().fold(0.0, |acc, m| {
349/// acc + m.contribute(m.metric().eval(&input))
350/// })
351/// });
352///
353/// // Custom: use only the worst contribution
354/// let worst = set.score().by(|members| {
355/// members.iter().map(|m| {
356/// m.contribute(m.metric().eval(&input))
357/// }).fold(f64::INFINITY, f64::min)
358/// });
359/// ```
360pub struct FiniteScoreStage<'a, T: Float, I, E> {
361 members: &'a [FiniteMember<T, E>],
362 _phantom: PhantomData<I>,
363}
364
365impl<'a, T: Float, I, E: Scorable<T, I>> FiniteScoreStage<'a, T, I, E> {
366 /// Apply a custom aggregation to the members.
367 ///
368 /// The closure receives a `&[FiniteMember<T, E>]` — one entry per member
369 /// in insertion order. Each [`FiniteMember`] provides
370 /// [`.metric()`](FiniteMember::metric) for evaluation and
371 /// [`.contribute()`](FiniteMember::contribute) for weighting. The closure
372 /// may return any type `R`.
373 #[inline]
374 pub fn by<F, R>(self, f: F) -> R
375 where
376 F: FnOnce(&[FiniteMember<T, E>]) -> R,
377 {
378 f(self.members)
379 }
380}
381
382// ---------------------------------------------------------------------------
383// FiniteScoreSetBuilder — incremental builder for FiniteScoreSet
384// ---------------------------------------------------------------------------
385
386/// Incremental builder for [`FiniteScoreSet`].
387///
388/// Accumulates raw `(weight, variant)` pairs via [`.push()`](Self::push), then
389/// normalizes them into a [`FiniteScoreSet`] via [`.build()`](Self::build).
390///
391/// Each weight is validated on push (must be finite and > 0). Normalization
392/// happens once at build time.
393///
394/// # Examples
395///
396/// Chain construction:
397///
398/// ```ignore
399/// let set = FiniteScoreSet::<f64, &str, TestKind<f64, &str>>::builder()
400/// .push(2.0, TestKind::AlwaysZero(const_metric("zero", 0.0)))?
401/// .push(3.0, TestKind::AlwaysOne(const_metric("one", 1.0)))?
402/// .build()?;
403/// ```
404///
405/// Conditional construction:
406///
407/// ```ignore
408/// let mut builder = FiniteScoreSet::<f64, &str, TestKind<f64, &str>>::builder();
409/// builder = builder.push(2.0, baseline_variant)?;
410/// if enable_extra {
411/// builder = builder.push(1.0, extra_variant)?;
412/// }
413/// let set = builder.build()?;
414/// ```
415pub struct FiniteScoreSetBuilder<T: Float, I, E> {
416 entries: Vec<(T, E)>,
417 _phantom: PhantomData<I>,
418}
419
420impl<T: Float, I, E: Scorable<T, I>> FiniteScoreSetBuilder<T, I, E> {
421 /// Push a metric enum variant with a raw weight into the builder.
422 ///
423 /// The weight must be finite and strictly positive. This is validated
424 /// immediately (fail-fast). Takes and returns `Self` for chaining.
425 ///
426 /// For incremental construction, rebind the result:
427 ///
428 /// ```ignore
429 /// let mut builder = FiniteScoreSet::builder();
430 /// builder = builder.push(2.0, variant_a)?;
431 /// if some_condition {
432 /// builder = builder.push(1.0, variant_b)?;
433 /// }
434 /// let set = builder.build()?;
435 /// ```
436 ///
437 /// # Errors
438 ///
439 /// Returns an error if `weight` is zero, negative, or not finite.
440 #[inline]
441 pub fn push(mut self, weight: T, variant: E) -> Result<Self, &'static str> {
442 GtZero::witness(weight)?;
443 self.entries.push((weight, variant));
444 Ok(self)
445 }
446
447 /// Consume the builder and produce a [`FiniteScoreSet`] with normalized
448 /// weights.
449 ///
450 /// # Errors
451 ///
452 /// Returns an error if no members were pushed.
453 #[inline]
454 pub fn build(self) -> Result<FiniteScoreSet<T, I, E>, &'static str> {
455 FiniteScoreSet::normalize(self.entries)
456 }
457}
458
459#[cfg(test)]
460mod tests_for_finite;