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;