Skip to main content

score_set/
dynamic.rs

1use crate::float::Float;
2use crate::value::Value01;
3use witnessed::Witnessed;
4
5// ---------------------------------------------------------------------------
6// Scorable — type-erased metric trait (Layer 3 foundation)
7// ---------------------------------------------------------------------------
8
9/// A type-erased metric that can be evaluated against input `I`.
10///
11/// This trait enables dynamic dispatch over heterogeneous metric types via
12/// `Box<dyn Scorable<T, I>>`. It is the core abstraction behind
13/// [`DynamicScoreSet`](crate::DynamicScoreSet).
14///
15/// Any [`Metric`](crate::Metric) can be converted into a
16/// `Box<dyn Scorable<T, I>>` through the blanket implementation.
17///
18/// # Examples
19///
20/// ```ignore
21/// use score_set::*;
22///
23/// let gc = metric("gc")
24///     .measure().by(|dna: &&str| gc_ratio(dna))
25///     .map01().by(|raw: &f64, _: &&str| Value01::witness(*raw).unwrap());
26///
27/// let dyn_metric: Box<dyn Scorable<f64, &str>> = Box::new(gc);
28/// assert_eq!(dyn_metric.name(), "gc");
29/// let score = dyn_metric.eval(&"ACGT");
30/// ```
31pub trait Scorable<T: Float, I> {
32    /// Evaluate this metric against an input, producing a `[0, 1]` score.
33    fn eval(&self, input: &I) -> Witnessed<T, Value01>;
34
35    /// Return the metric's name.
36    fn name(&self) -> &str;
37}
38
39// ---------------------------------------------------------------------------
40// Blanket impl — any Metric is a Scorable
41// ---------------------------------------------------------------------------
42
43impl<T, I, Raw, M, F> Scorable<T, I> for crate::Metric<T, I, Raw, M, F>
44where
45    T: Float,
46    M: Fn(&I) -> Raw,
47    F: Fn(&Raw, &I) -> Witnessed<T, Value01>,
48{
49    #[inline]
50    fn eval(&self, input: &I) -> Witnessed<T, Value01> {
51        crate::Metric::eval(self, input)
52    }
53
54    #[inline]
55    fn name(&self) -> &str {
56        crate::Metric::name(self)
57    }
58}
59
60// ---------------------------------------------------------------------------
61// Scorable impl for Box<dyn Scorable<T, I>> — enables nesting
62// ---------------------------------------------------------------------------
63
64impl<T: Float, I> Scorable<T, I> for Box<dyn Scorable<T, I>> {
65    #[inline]
66    fn eval(&self, input: &I) -> Witnessed<T, Value01> {
67        (**self).eval(input)
68    }
69
70    #[inline]
71    fn name(&self) -> &str {
72        (**self).name()
73    }
74}
75
76// ===========================================================================
77// DynamicScoreSet — fully dynamic scoring set (Layer 3)
78// ===========================================================================
79
80use crate::breakdown::Breakdown;
81use crate::value::{GtZero, NormalizedContainer, NormalizedWeight};
82
83use witnessed::WitnessExt;
84
85// ---------------------------------------------------------------------------
86// DynamicMember — a single weighted metric in a DynamicScoreSet
87// ---------------------------------------------------------------------------
88
89/// A member of a [`DynamicScoreSet`]: a normalized weight paired with a
90/// type-erased metric.
91///
92/// See [`Member`](crate::Member) for the Layer-1 equivalent and
93/// [`FiniteMember`](crate::FiniteMember) for the Layer-2 equivalent.
94pub struct DynamicMember<T: Float, I> {
95    /// The normalized weight.
96    pub weight: Witnessed<T, NormalizedWeight>,
97    /// The type-erased metric.
98    pub metric: Box<dyn Scorable<T, I>>,
99}
100
101impl<T: Float, I> DynamicMember<T, I> {
102    /// Compute the weighted contribution of a metric score.
103    ///
104    /// `contribute(score) = score × normalized_weight`
105    #[inline]
106    pub fn contribute(&self, value: Witnessed<T, Value01>) -> T {
107        value.into_inner() * self.weight.into_inner()
108    }
109
110    /// Return a reference to the metric.
111    #[inline]
112    pub fn metric(&self) -> &dyn Scorable<T, I> {
113        &*self.metric
114    }
115}
116
117// ---------------------------------------------------------------------------
118// DynamicScoreSet — fully dynamic scoring set (Layer 3)
119// ---------------------------------------------------------------------------
120
121/// A weighted set of scoring operators using dynamic dispatch.
122///
123/// `DynamicScoreSet` stores a `Vec` of [`DynamicMember`]s, each holding a
124/// `Box<dyn Scorable<T, I>>`. Every evaluation call pays vtable overhead,
125/// but the set can contain completely heterogeneous metric types and can be
126/// assembled at runtime.
127///
128/// Construct via [`dynamic_score_set!`](crate::dynamic_score_set!), the
129/// [`DynamicScoreSetBuilder`], or call [`.score()`](DynamicScoreSet::score)
130/// directly.
131///
132/// # Type parameters
133///
134/// - `T: Float` — the floating-point type (`f32` or `f64`).
135/// - `I` — the input type passed to each metric.
136///
137/// # Example
138///
139/// ```ignore
140/// let gc: Box<dyn Scorable<f64, &str>> = Box::new(gc_metric);
141/// let len: Box<dyn Scorable<f64, &str>> = Box::new(len_metric);
142///
143/// let set = DynamicScoreSet::<f64, &str>::normalize(vec![
144///     (2.0, gc),
145///     (3.0, len),
146/// ])?;
147///
148/// let total = set.sum(&"ACGTACGT");
149/// ```
150pub struct DynamicScoreSet<T: Float, I> {
151    members: Vec<DynamicMember<T, I>>,
152}
153
154impl<T: Float, I> DynamicScoreSet<T, I> {
155    /// Normalize raw weights and validate the resulting set.
156    ///
157    /// Each weight must be finite and strictly positive. Weights are normalized
158    /// to sum to 1.
159    #[doc(hidden)]
160    pub fn normalize(entries: Vec<(T, Box<dyn Scorable<T, I>>)>) -> Result<Self, &'static str> {
161        if entries.is_empty() {
162            return Err("DynamicScoreSet: must have at least one member");
163        }
164
165        // Validate all weights are > 0
166        for (w, _) in &entries {
167            GtZero::witness(*w)?;
168        }
169
170        let sum: T = entries.iter().fold(T::zero(), |acc, (w, _)| acc + *w);
171
172        let mut normalized: Vec<T> = entries.iter().map(|(w, _)| *w / sum).collect();
173
174        // Sort a copy for binary search in NormalizedWeight
175        let mut sorted = normalized.clone();
176        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
177        let container = NormalizedContainer::witness(sorted)?;
178
179        let members: Vec<DynamicMember<T, I>> = entries
180            .into_iter()
181            .zip(normalized.drain(..))
182            .map(|((_, metric), nw)| {
183                let weight = nw
184                    .witness()
185                    .by(|v| NormalizedWeight::from_normalized_container(*v, &container))?;
186                Ok(DynamicMember { weight, metric })
187            })
188            .collect::<Result<Vec<_>, &'static str>>()?;
189
190        Ok(DynamicScoreSet { members })
191    }
192
193    /// Evaluate all metrics against `input` and sum their weighted contributions.
194    ///
195    /// Zero-allocation convenience for the most common aggregation.
196    /// For custom aggregation, use [`.score()`](Self::score) instead.
197    #[inline]
198    pub fn sum(&self, input: &I) -> T {
199        self.members
200            .iter()
201            .fold(T::zero(), |acc, m| acc + m.contribute(m.metric.eval(input)))
202    }
203
204    /// Enter the scoring stage, returning a reference to all members.
205    ///
206    /// Use [`.by()`](DynamicScoreStage::by) on the returned stage to apply a
207    /// custom aggregation, or [`.sum()`](Self::sum) for the standard
208    /// weighted-sum shortcut.
209    ///
210    /// # Example
211    ///
212    /// ```ignore
213    /// let total = set.score().by(|members| {
214    ///     members.iter().fold(0.0, |acc, m| {
215    ///         acc + m.contribute(m.metric().eval(&input))
216    ///     })
217    /// });
218    /// ```
219    #[inline]
220    pub fn score(&self) -> DynamicScoreStage<'_, T, I> {
221        DynamicScoreStage {
222            members: &self.members,
223        }
224    }
225
226    /// Return the number of members in this set.
227    #[inline]
228    pub fn len(&self) -> usize {
229        self.members.len()
230    }
231
232    /// Return `true` if the set has no members.
233    #[inline]
234    pub fn is_empty(&self) -> bool {
235        self.members.is_empty()
236    }
237
238    /// Iterate over the members.
239    #[inline]
240    pub fn iter(&self) -> impl Iterator<Item = &DynamicMember<T, I>> {
241        self.members.iter()
242    }
243
244    /// Evaluate all metrics against `input` and return a per-metric breakdown.
245    ///
246    /// Unlike [`.sum()`](Self::sum) which returns only the aggregate,
247    /// `breakdown` returns one [`Breakdown`] row per member with the metric's
248    /// name, raw score, normalized weight, and weighted contribution.
249    ///
250    /// # Example
251    ///
252    /// ```ignore
253    /// for row in set.breakdown(&ctx) {
254    ///     println!("{}: {:.3} × {:.3} = {:.3}",
255    ///         row.name, row.score, row.weight, row.contribution);
256    /// }
257    /// ```
258    #[inline]
259    pub fn breakdown(&self, input: &I) -> Vec<Breakdown<'_, T>> {
260        self.members
261            .iter()
262            .map(|m| {
263                let score_witness = m.metric.eval(input);
264                let score_val: T = *score_witness;
265                Breakdown {
266                    name: m.metric.name(),
267                    score: score_val,
268                    weight: m.weight.into_inner(),
269                    contribution: m.contribute(score_witness),
270                }
271            })
272            .collect()
273    }
274
275    /// Create a builder for incremental construction of a `DynamicScoreSet`.
276    ///
277    /// Use this when members are not known up front — push them one by one,
278    /// then call [`.build()`](DynamicScoreSetBuilder::build) to finalize.
279    ///
280    /// # Example
281    ///
282    /// ```ignore
283    /// let set = DynamicScoreSet::<f64, &str>::builder()
284    ///     .push(2.0, gc_metric.boxed())?
285    ///     .push(3.0, len_metric.boxed())?
286    ///     .build()?;
287    /// ```
288    #[inline]
289    pub fn builder() -> DynamicScoreSetBuilder<T, I> {
290        DynamicScoreSetBuilder {
291            entries: Vec::new(),
292        }
293    }
294}
295
296// ---------------------------------------------------------------------------
297// DynamicScoreStage — member reference for custom aggregation (Layer 3)
298// ---------------------------------------------------------------------------
299
300/// The scoring stage for a [`DynamicScoreSet`], created by
301/// [`DynamicScoreSet::score`].
302///
303/// Holds a reference to the set's members. Call
304/// [`.by()`](DynamicScoreStage::by) to apply a custom aggregation over the
305/// member slice. For the standard weighted-sum shortcut, use
306/// [`DynamicScoreSet::sum`] instead.
307///
308/// # Examples
309///
310/// ```ignore
311/// // Standard weighted sum via the stage:
312/// let total = set.score().by(|members| {
313///     members.iter().fold(0.0, |acc, m| {
314///         acc + m.contribute(m.metric().eval(&input))
315///     })
316/// });
317///
318/// // Custom: geometric mean of contributions
319/// let product = set.score().by(|members| {
320///     members.iter().map(|m| {
321///         m.contribute(m.metric().eval(&input))
322///     }).fold(1.0, |a, c| a * c)
323/// });
324/// ```
325pub struct DynamicScoreStage<'a, T: Float, I> {
326    members: &'a [DynamicMember<T, I>],
327}
328
329impl<'a, T: Float, I> DynamicScoreStage<'a, T, I> {
330    /// Apply a custom aggregation to the members.
331    ///
332    /// The closure receives a `&[DynamicMember<T, I>]` — one entry per member
333    /// in insertion order. Each [`DynamicMember`] provides
334    /// [`.metric()`](DynamicMember::metric) for evaluation and
335    /// [`.contribute()`](DynamicMember::contribute) for weighting. The closure
336    /// may return any type `R`.
337    #[inline]
338    pub fn by<F, R>(self, f: F) -> R
339    where
340        F: FnOnce(&[DynamicMember<T, I>]) -> R,
341    {
342        f(self.members)
343    }
344}
345
346// ---------------------------------------------------------------------------
347// DynamicScoreSetBuilder — incremental builder for DynamicScoreSet
348// ---------------------------------------------------------------------------
349
350/// Incremental builder for [`DynamicScoreSet`].
351///
352/// Accumulates raw `(weight, metric)` pairs via [`.push()`](Self::push), then
353/// normalizes them into a [`DynamicScoreSet`] via [`.build()`](Self::build).
354///
355/// Each weight is validated on push (must be finite and > 0). Normalization
356/// happens once at build time.
357///
358/// # Examples
359///
360/// Chain construction:
361///
362/// ```ignore
363/// let set = DynamicScoreSet::<f64, &str>::builder()
364///     .push(2.0, gc_metric.boxed())?
365///     .push(3.0, len_metric.boxed())?
366///     .build()?;
367/// ```
368///
369/// Conditional construction:
370///
371/// ```ignore
372/// let mut builder = DynamicScoreSet::<f64, &str>::builder();
373/// builder = builder.push(2.0, baseline_metric.boxed())?;
374/// if enable_extra {
375///     builder = builder.push(1.0, extra_metric.boxed())?;
376/// }
377/// let set = builder.build()?;
378/// ```
379pub struct DynamicScoreSetBuilder<T: Float, I> {
380    entries: Vec<(T, Box<dyn Scorable<T, I>>)>,
381}
382
383impl<T: Float, I> DynamicScoreSetBuilder<T, I> {
384    /// Push a metric with a raw weight into the builder.
385    ///
386    /// The weight must be finite and strictly positive. This is validated
387    /// immediately (fail-fast). Takes and returns `Self` for chaining.
388    ///
389    /// For incremental construction, rebind the result:
390    ///
391    /// ```ignore
392    /// let mut builder = DynamicScoreSet::builder();
393    /// builder = builder.push(2.0, gc_metric.boxed())?;
394    /// if some_condition {
395    ///     builder = builder.push(1.0, extra_metric.boxed())?;
396    /// }
397    /// let set = builder.build()?;
398    /// ```
399    ///
400    /// # Errors
401    ///
402    /// Returns an error if `weight` is zero, negative, or not finite.
403    #[inline]
404    pub fn push(
405        mut self,
406        weight: T,
407        metric: Box<dyn Scorable<T, I>>,
408    ) -> Result<Self, &'static str> {
409        GtZero::witness(weight)?;
410        self.entries.push((weight, metric));
411        Ok(self)
412    }
413
414    /// Consume the builder and produce a [`DynamicScoreSet`] with normalized
415    /// weights.
416    ///
417    /// # Errors
418    ///
419    /// Returns an error if no members were pushed.
420    #[inline]
421    pub fn build(self) -> Result<DynamicScoreSet<T, I>, &'static str> {
422        DynamicScoreSet::normalize(self.entries)
423    }
424}
425
426#[cfg(test)]
427mod tests_for_dynamic;