Skip to main content

rustev_eval/
selection.rs

1//! Declarative selection evidence (spec 029, 3.12), never an execution grant.
2use std::collections::{BTreeMap, BTreeSet};
3
4use rustev_contract::decimal::Decimal;
5use rustev_contract::ids::ContentDigest;
6use rustev_contract::limits::{DESCRIPTOR_V1, REPLAY_V1};
7use rustev_contract::{Document, Identified};
8use serde::{Deserialize, Serialize};
9
10use crate::baseline::{Baseline, valid_name};
11use crate::comparison::{
12    NamedCandidate, RepeatEvidence, check_names, compatible, observed, verify, verify_baseline,
13};
14use crate::config::Direction;
15use crate::repeat::{
16    BandDoc, CohortReport, CohortStatus, FigureDoc, HeldOut, MetricReport, Observation, SplitRole,
17};
18
19pub const SELECTION_GATE: &str = "rustev.selection-gate/1";
20pub const SELECTION: &str = "rustev.selection/1";
21/// Conservative declaration bounds, matching DESCRIPTOR_V1 collection sizes.
22pub const MAX_GATE_ITEMS: usize = 1024;
23pub const MAX_METRIC_NAME: usize = 256;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct SelectionGate {
28    pub schema: String,
29    pub gate: String,
30    pub per: Per,
31    pub metrics: BTreeMap<String, Direction>,
32    pub min_comparable_coverage: Decimal,
33    pub min_labeled_coverage: Decimal,
34    pub require: Vec<Clause>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub and_any: Option<Vec<Clause>>,
37    pub rank_by: Vec<String>,
38}
39crate::doc::eval_document!(SelectionGate, SELECTION_GATE, DESCRIPTOR_V1, ContentDigest);
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Per {
44    Variant,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(untagged, deny_unknown_fields)]
49pub enum MetricClause {
50    NotWorse {
51        metric: String,
52        not_worse_than: BaselineTarget,
53        within: Within,
54    },
55    Better {
56        metric: String,
57        better_than: BaselineTarget,
58    },
59}
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum BaselineTarget {
63    Baseline,
64}
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case", deny_unknown_fields)]
67pub enum Within {
68    Noise,
69    Absolute(Decimal),
70}
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(untagged, deny_unknown_fields)]
73pub enum Clause {
74    Metric(MetricClause),
75    AllSlices { all_slices: MetricClause },
76    HeldOut { held_out: HeldOutLimit },
77}
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct HeldOutLimit {
81    pub max_evaluations: u64,
82}
83
84impl MetricClause {
85    fn metric(&self) -> &str {
86        match self {
87            Self::NotWorse { metric, .. } | Self::Better { metric, .. } => metric,
88        }
89    }
90}
91impl SelectionGate {
92    /// Semantic checks accompany the strict document parser, as for RepeatConfig.
93    pub fn check(&self) -> Result<(), String> {
94        if self.schema != SELECTION_GATE || !valid_name(&self.gate) {
95            return Err("wrong schema or invalid gate name".into());
96        }
97        if [self.min_comparable_coverage, self.min_labeled_coverage]
98            .iter()
99            .any(|v| *v < Decimal::ZERO || *v > Decimal::from_i64(1))
100        {
101            return Err("coverage minima must be in [0, 1]".into());
102        }
103        if self.require.is_empty()
104            || self.require.len() > MAX_GATE_ITEMS
105            || self
106                .and_any
107                .as_ref()
108                .is_some_and(|a| a.is_empty() || a.len() > MAX_GATE_ITEMS)
109            || self.metrics.len() > MAX_GATE_ITEMS
110            || self.rank_by.len() > MAX_GATE_ITEMS
111        {
112            return Err("require and present and_any must contain 1 to 1024 clauses; metrics and rank_by at most 1024".into());
113        }
114        if self
115            .metrics
116            .keys()
117            .any(|m| m.is_empty() || m.len() > MAX_METRIC_NAME)
118        {
119            return Err("metric names must contain 1 to 256 bytes".into());
120        }
121        for c in self.require.iter().chain(self.and_any.iter().flatten()) {
122            let m = match c {
123                Clause::Metric(m) | Clause::AllSlices { all_slices: m } => m,
124                Clause::HeldOut { .. } => continue,
125            };
126            if !self.metrics.contains_key(m.metric()) {
127                return Err("clause metric has no declared direction".into());
128            }
129            if let MetricClause::NotWorse {
130                within: Within::Absolute(t),
131                ..
132            } = m
133                && t.is_negative()
134            {
135                return Err("absolute tolerance is negative".into());
136            }
137        }
138        let mut seen = BTreeSet::new();
139        for m in &self.rank_by {
140            if !self.metrics.contains_key(m) || !seen.insert(m) {
141                return Err("rank metrics must have a direction and be unique".into());
142            }
143        }
144        Ok(())
145    }
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct Selection {
151    pub schema: String,
152    pub gate: ContentDigest,
153    pub baseline: ContentDigest,
154    pub candidates: Vec<CandidateSelection>,
155    pub ranked: Vec<String>,
156    pub unranked: Vec<Unranked>,
157}
158crate::doc::eval_document!(Selection, SELECTION, REPLAY_V1);
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct CandidateSelection {
163    pub name: String,
164    pub repeat_report: ContentDigest,
165    pub verdict: Verdict,
166    pub clauses: Vec<ClauseResult>,
167}
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case", deny_unknown_fields)]
170pub enum Verdict {
171    Qualifies,
172    Rejected { clause: String },
173    Unknown { clause: String },
174}
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct ClauseResult {
178    pub clause: String,
179    pub result: ClauseOutcome,
180    /// Each declared slice's result, retained even after a failure.
181    pub slices: Vec<SliceResult>,
182}
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct SliceResult {
186    pub slice: String,
187    pub result: ClauseOutcome,
188}
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case", deny_unknown_fields)]
191pub enum ClauseOutcome {
192    Pass { reason: String },
193    Fail { reason: String },
194    Unknown { reason: String },
195}
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct Unranked {
199    pub name: String,
200    pub reason: String,
201}
202impl Selection {
203    /// The library equivalent of the command's 0, 11 and 12 status contract.
204    pub fn exit_code(&self) -> u8 {
205        if self
206            .candidates
207            .iter()
208            .any(|c| matches!(c.verdict, Verdict::Unknown { .. }))
209        {
210            12
211        } else if self
212            .candidates
213            .iter()
214            .any(|c| c.verdict == Verdict::Qualifies)
215        {
216            0
217        } else {
218            11
219        }
220    }
221}
222fn pass(s: impl Into<String>) -> ClauseOutcome {
223    ClauseOutcome::Pass { reason: s.into() }
224}
225fn fail(s: impl Into<String>) -> ClauseOutcome {
226    ClauseOutcome::Fail { reason: s.into() }
227}
228fn unknown(s: impl Into<String>) -> ClauseOutcome {
229    ClauseOutcome::Unknown { reason: s.into() }
230}
231fn outcome(ok: bool, s: &str) -> ClauseOutcome {
232    if ok { pass(s) } else { fail(s) }
233}
234fn result(clause: String, result: ClauseOutcome) -> ClauseResult {
235    ClauseResult {
236        clause,
237        result,
238        slices: vec![],
239    }
240}
241
242/// The metric a clause gates, if any.
243fn clause_metric(clause: &Clause) -> Option<&str> {
244    match clause {
245        Clause::Metric(m) | Clause::AllSlices { all_slices: m } => Some(m.metric()),
246        Clause::HeldOut { .. } => None,
247    }
248}
249
250/// Whether a run set's evaluator configuration produces `metric` from an
251/// opaque scorer, or an agreement with one (spec 041, 3.12).
252fn opaque(e: &RepeatEvidence<'_>, metric: &str) -> bool {
253    e.inputs.runs.iter().any(|r| {
254        r.evaluated
255            .config
256            .declarations()
257            .is_some_and(|d| d.metric_names().get(metric).is_some_and(|(_, o)| *o))
258    })
259}
260
261/// Verify all supplied bindings before comparing values. A failed precondition
262/// makes that candidate unknown. Once preconditions hold, any definite clause
263/// failure dominates unknown clauses, including unknown coverage denominators.
264pub fn evaluate_selection(
265    gate: &SelectionGate,
266    baseline: &Baseline,
267    baseline_evidence: &RepeatEvidence<'_>,
268    candidates: &[NamedCandidate<'_>],
269) -> Result<Selection, String> {
270    gate.check()?;
271    SelectionGate::parse(&gate.canonical().map_err(|e| e.to_string())?)
272        .map_err(|e| e.to_string())?;
273    check_names(candidates)?;
274    let base = verify_baseline(baseline, baseline_evidence);
275    let mut decisions = Vec::new();
276    let mut ranks = Vec::new();
277    let mut unranked = Vec::new();
278    for c in candidates {
279        let precondition = base
280            .clone()
281            .and_then(|()| compatible(baseline_evidence, &c.evidence))
282            .and_then(|()| verify(&c.evidence))
283            .and_then(|()| stable(baseline_evidence, &c.evidence));
284        let mut clauses = Vec::new();
285        let precondition_reason = precondition.err();
286        clauses.push(result(
287            "preconditions".into(),
288            match &precondition_reason {
289                Some(r) => unknown(r),
290                None => pass("bindings and cohorts verified"),
291            },
292        ));
293        if precondition_reason.is_none() {
294            for (side, e) in [("baseline", baseline_evidence), ("candidate", &c.evidence)] {
295                for (r, run) in e.inputs.runs.iter().enumerate() {
296                    for (metric, min) in [
297                        ("coverage", gate.min_comparable_coverage),
298                        ("labeled_proposal_coverage", gate.min_labeled_coverage),
299                    ] {
300                        let value = match run
301                            .evaluated
302                            .detail
303                            .count(metric)
304                            .filter(|v| v.denominator > 0)
305                        {
306                            None => unknown("coverage has no count or a zero denominator"),
307                            Some(v) => outcome(
308                                i128::from(v.numerator) * 1_000_000_000
309                                    >= min.units() * i128::from(v.denominator),
310                                "coverage minimum",
311                            ),
312                        };
313                        clauses.push(result(format!("{side}.runs[{r}].{metric}"), value));
314                    }
315                }
316            }
317        }
318        let base_side = Side {
319            cohorts: &baseline_evidence.report.cohorts,
320            split_role: baseline_evidence.report.split_role,
321            held_out: baseline_evidence.report.held_out.as_ref(),
322            opaque: &|m: &str| opaque(baseline_evidence, m),
323            host: false,
324        };
325        let cand_side = Side {
326            cohorts: &c.evidence.report.cohorts,
327            split_role: c.evidence.report.split_role,
328            held_out: c.evidence.report.held_out.as_ref(),
329            opaque: &|m: &str| opaque(&c.evidence, m),
330            host: false,
331        };
332        let (verdict, clauses) = judge(
333            gate,
334            &base_side,
335            &cand_side,
336            precondition_reason.as_deref(),
337            clauses,
338            c.name,
339            &mut ranks,
340            &mut unranked,
341        );
342        decisions.push(CandidateSelection {
343            name: c.name.into(),
344            repeat_report: c
345                .evidence
346                .report
347                .record_digest()
348                .map_err(|e| e.to_string())?,
349            verdict,
350            clauses,
351        });
352    }
353    sort_ranks(gate, &mut ranks);
354    let doc = Selection {
355        schema: SELECTION.into(),
356        gate: gate.id().map_err(|e| e.to_string())?,
357        baseline: baseline.record_digest().map_err(|e| e.to_string())?,
358        candidates: decisions,
359        ranked: ranks.into_iter().map(|(n, _)| n).collect(),
360        unranked,
361    };
362    Selection::parse(&doc.record_canonical().map_err(|e| e.to_string())?).map_err(|e| e.to_string())
363}
364
365/// One side of a selection as its clauses read it (spec 029, 3.12; spec
366/// 061, 3.7): its cohorts, split role, held-out record, which metrics an
367/// opaque scorer produces, and whether it is a host set.
368pub(crate) struct Side<'a> {
369    pub cohorts: &'a [CohortReport],
370    pub split_role: SplitRole,
371    pub held_out: Option<&'a HeldOut>,
372    pub opaque: &'a dyn Fn(&str) -> bool,
373    pub host: bool,
374}
375
376/// A candidate's clause results after its preconditions and coverage
377/// clauses, its verdict, and its rank values when it qualifies (3.12.3 to
378/// 3.12.6).
379#[allow(clippy::too_many_arguments)]
380pub(crate) fn judge(
381    gate: &SelectionGate,
382    base: &Side<'_>,
383    cand: &Side<'_>,
384    precondition_reason: Option<&str>,
385    mut clauses: Vec<ClauseResult>,
386    name: &str,
387    ranks: &mut Vec<(String, Vec<f64>)>,
388    unranked: &mut Vec<Unranked>,
389) -> (Verdict, Vec<ClauseResult>) {
390    for (group, list) in [
391        ("require", gate.require.as_slice()),
392        ("and_any", gate.and_any.as_deref().unwrap_or(&[])),
393    ] {
394        for (i, clause) in list.iter().enumerate() {
395            let label = format!("{group}[{i}]");
396            let opaque_side = clause_metric(clause).and_then(|m| {
397                [("baseline", base), ("candidate", cand)]
398                    .into_iter()
399                    .find(|(_, e)| (e.opaque)(m))
400                    .map(|(side, _)| (side, m))
401            });
402            clauses.push(match (&precondition_reason, opaque_side) {
403                (Some(r), _) => result(label, unknown(format!("precondition: {r}"))),
404                // Spec 041, 3.12.2: an opaque scorer's identity names
405                // code whose change no digest shows.
406                (None, Some((side, m))) => result(
407                    label,
408                    unknown(format!("the {side}'s `{m}` comes from an opaque scorer")),
409                ),
410                (None, None) => evaluate_clause(label, clause, gate, base, cand),
411            });
412        }
413    }
414    if let Some(any) = &gate.and_any {
415        let members: Vec<_> = clauses
416            .iter()
417            .filter(|r| r.clause.starts_with("and_any["))
418            .map(|r| &r.result)
419            .collect();
420        debug_assert_eq!(members.len(), any.len());
421        let v = if members
422            .iter()
423            .any(|r| matches!(r, ClauseOutcome::Pass { .. }))
424        {
425            pass("at least one clause passes")
426        } else if members
427            .iter()
428            .all(|r| matches!(r, ClauseOutcome::Fail { .. }))
429        {
430            fail("every clause fails")
431        } else {
432            unknown("no clause passes and at least one is unknown")
433        };
434        clauses.push(result("and_any".into(), v));
435    }
436    // Individual OR members do not act as required clauses.
437    let relevant: Vec<_> = clauses
438        .iter()
439        .filter(|r| !r.clause.starts_with("and_any["))
440        .collect();
441    let verdict = if let Some(r) = relevant
442        .iter()
443        .find(|r| matches!(r.result, ClauseOutcome::Fail { .. }))
444    {
445        Verdict::Rejected {
446            clause: r.clause.clone(),
447        }
448    } else if let Some(r) = relevant
449        .iter()
450        .find(|r| matches!(r.result, ClauseOutcome::Unknown { .. }))
451    {
452        let clause = if r.clause == "and_any" {
453            clauses
454                .iter()
455                .find(|r| {
456                    r.clause.starts_with("and_any[")
457                        && matches!(r.result, ClauseOutcome::Unknown { .. })
458                })
459                .expect("an unknown disjunction has an unknown member")
460                .clause
461                .clone()
462        } else {
463            r.clause.clone()
464        };
465        Verdict::Unknown { clause }
466    } else {
467        Verdict::Qualifies
468    };
469    if verdict == Verdict::Qualifies {
470        let values: Result<Vec<_>, String> = gate
471            .rank_by
472            .iter()
473            .map(|m| {
474                if (cand.opaque)(m) || (base.opaque)(m) {
475                    return Err(format!("`{m}` comes from an opaque scorer"));
476                }
477                metric(cand, "all", m).and_then(|m| value(m, true, cand.host))
478            })
479            .collect();
480        match values {
481            Ok(values) => ranks.push((name.to_string(), values)),
482            Err(reason) => unranked.push(Unranked {
483                name: name.into(),
484                reason,
485            }),
486        }
487    }
488    (verdict, clauses)
489}
490
491/// Rank qualifying candidates (3.12.6): by `rank_by` lexicographically in
492/// each metric's direction, ties by name in byte order.
493pub(crate) fn sort_ranks(gate: &SelectionGate, ranks: &mut [(String, Vec<f64>)]) {
494    ranks.sort_by(|(an, av), (bn, bv)| {
495        for ((a, b), m) in av.iter().zip(bv).zip(&gate.rank_by) {
496            // Numeric equality includes both signs of zero. All values finite.
497            let order = a.partial_cmp(b).expect("finite rank values");
498            let order = match gate.metrics[m] {
499                Direction::HigherIsBetter => order.reverse(),
500                Direction::LowerIsBetter => order,
501            };
502            if !order.is_eq() {
503                return order;
504            }
505        }
506        an.as_bytes().cmp(bn.as_bytes())
507    });
508}
509
510fn stable(b: &RepeatEvidence<'_>, c: &RepeatEvidence<'_>) -> Result<(), String> {
511    if b.report.stable_cohort != c.report.stable_cohort {
512        return Err("stable cohorts differ".into());
513    }
514    let live = c.report.source == crate::repeat::Source::Live;
515    for (b, c) in b.inputs.runs.iter().zip(c.inputs.runs) {
516        let comparable = |e: &crate::report::Evaluated| -> BTreeSet<String> {
517            e.detail
518                .cases
519                .iter()
520                .filter(|c| {
521                    c.outcome.starts_with("proposal:") || c.outcome.starts_with("abstention:")
522                })
523                .map(|c| c.case.clone())
524                .collect()
525        };
526        if comparable(b.evaluated) != comparable(c.evaluated) {
527            return Err("per-run comparable cohorts differ".into());
528        }
529        if b.evaluated
530            .detail
531            .count("candidate_outcome_change")
532            .is_some()
533            || (!live
534                && c.evaluated
535                    .detail
536                    .count("candidate_outcome_change")
537                    .is_none())
538        {
539            return Err("run baseline and candidate roles do not hold".into());
540        }
541    }
542    Ok(())
543}
544
545fn metric<'a>(r: &Side<'a>, cohort: &str, name: &str) -> Result<&'a MetricReport, String> {
546    let c = r
547        .cohorts
548        .iter()
549        .find(|c| c.name == cohort)
550        .ok_or_else(|| format!("missing cohort {cohort}"))?;
551    if c.status == CohortStatus::BelowMinimum {
552        return Err(format!("{cohort}: below_minimum"));
553    }
554    c.metrics
555        .iter()
556        .find(|m| m.name == name)
557        .ok_or_else(|| format!("metric {name} not reported in {cohort}"))
558}
559fn value(m: &MetricReport, candidate: bool, host: bool) -> Result<f64, String> {
560    // A host set's latency and cost are its own observations (spec 061,
561    // 3.5.3), named under `host/`.
562    let observation = if host {
563        crate::host_repeat::observed(&m.name)
564    } else {
565        observed(&m.name)
566    };
567    if candidate && observation && m.observation != Some(Observation::Candidate) {
568        return Err(format!(
569            "{}: historical or missing candidate observation",
570            m.name
571        ));
572    }
573    match &m.pooled {
574        FigureDoc::Value(v) if v.is_finite() => Ok(*v),
575        FigureDoc::Unknown(r) => Err(format!("{}: {r}", m.name)),
576        _ => Err("non-finite value".into()),
577    }
578}
579fn band(m: &MetricReport) -> Result<f64, String> {
580    match &m.band {
581        BandDoc::Value { value, .. } if value.is_finite() && *value >= 0.0 => Ok(*value),
582        BandDoc::Unknown(r) => Err(format!("{} band: {r}", m.name)),
583        _ => Err("invalid band".into()),
584    }
585}
586/// An exact decimal `(-1)^negative * digits * 10^exponent`, its digits most
587/// significant first.
588struct Exact {
589    negative: bool,
590    digits: Vec<u8>,
591    exponent: i32,
592}
593/// The shortest decimal that round-trips to `v` (what `{}` prints), exactly.
594/// `value` already refuses a non-finite pooled value, so the clause is
595/// unknown (spec 029, 3.12.4: "A clause is unknown when a value ... it needs
596/// is unknown"); NaN and the infinities are refused here too, never read as
597/// a number.
598fn shortest(v: f64) -> Result<Exact, String> {
599    if !v.is_finite() {
600        return Err("non-finite value".into());
601    }
602    let s = format!("{v:e}");
603    let (mantissa, exponent) = s
604        .split_once('e')
605        .ok_or_else(|| format!("no decimal exponent in {s}"))?;
606    let exponent: i32 = exponent
607        .parse()
608        .map_err(|_| format!("invalid decimal exponent in {s}"))?;
609    let (negative, mantissa) = match mantissa.strip_prefix('-') {
610        Some(m) => (true, m),
611        None => (false, mantissa),
612    };
613    let fraction = mantissa.split_once('.').map_or(0, |(_, f)| f.len());
614    let fraction = i32::try_from(fraction).map_err(|_| format!("mantissa too long in {s}"))?;
615    Ok(Exact {
616        negative,
617        digits: mantissa
618            .bytes()
619            .filter(u8::is_ascii_digit)
620            .map(|d| d - b'0')
621            .collect(),
622        exponent: exponent - fraction,
623    })
624}
625fn exact(t: Decimal) -> Exact {
626    Exact {
627        negative: t.is_negative(),
628        digits: t
629            .units()
630            .unsigned_abs()
631            .to_string()
632            .bytes()
633            .map(|d| d - b'0')
634            .collect(),
635        exponent: -i32::try_from(rustev_contract::decimal::SCALE).expect("small scale"),
636    }
637}
638/// Compares `sum(lhs)` with `sum(rhs)` exactly. Binary64 exponents bound the
639/// aligned magnitudes to under 700 digits.
640fn exact_sum_cmp(lhs: &[Exact], rhs: &[Exact]) -> std::cmp::Ordering {
641    let terms = lhs
642        .iter()
643        .map(|t| (t, false))
644        .chain(rhs.iter().map(|t| (t, true)));
645    let low = lhs.iter().chain(rhs).map(|t| t.exponent).min().unwrap_or(0);
646    // Positive and negative contributions, least significant digit first.
647    let (mut pos, mut neg) = (Vec::new(), Vec::new());
648    for (t, subtract) in terms {
649        let shift = usize::try_from(t.exponent - low).expect("aligned to the lowest exponent");
650        let mut m = vec![0u8; shift];
651        m.extend(t.digits.iter().rev());
652        add_digits(
653            if t.negative != subtract {
654                &mut neg
655            } else {
656                &mut pos
657            },
658            &m,
659        );
660    }
661    let trim = |v: &mut Vec<u8>| {
662        while v.last() == Some(&0) {
663            v.pop();
664        }
665    };
666    trim(&mut pos);
667    trim(&mut neg);
668    pos.len()
669        .cmp(&neg.len())
670        .then_with(|| pos.iter().rev().cmp(neg.iter().rev()))
671}
672fn add_digits(acc: &mut Vec<u8>, m: &[u8]) {
673    if acc.len() < m.len() {
674        acc.resize(m.len(), 0);
675    }
676    let mut carry = 0;
677    for (i, a) in acc.iter_mut().enumerate() {
678        let sum = *a + m.get(i).copied().unwrap_or(0) + carry;
679        *a = sum % 10;
680        carry = sum / 10;
681        if carry == 0 && i >= m.len() {
682            break;
683        }
684    }
685    if carry > 0 {
686        acc.push(carry);
687    }
688}
689fn metric_clause(
690    clause: &MetricClause,
691    gate: &SelectionGate,
692    b: &Side<'_>,
693    c: &Side<'_>,
694    cohort: &str,
695) -> ClauseOutcome {
696    let compare = || -> Result<bool, String> {
697        let bm = metric(b, cohort, clause.metric())?;
698        let cm = metric(c, cohort, clause.metric())?;
699        let bv = value(bm, false, b.host)?;
700        let cv = value(cm, true, c.host)?;
701        let higher = gate.metrics[clause.metric()] == Direction::HigherIsBetter;
702        let better = matches!(clause, MetricClause::Better { .. });
703        // Spec 029, 3.12.3: every band, a noise band or an absolute
704        // tolerance, is compared in exact decimal against the shortest
705        // round-trip decimals of `b` and `c` (and of a binary64 band), so a
706        // candidate exactly on the boundary is decided by the rule as
707        // written, never by binary64 rounding (#474).
708        let tolerance = match clause {
709            MetricClause::NotWorse {
710                within: Within::Absolute(t),
711                ..
712            } => exact(*t),
713            _ => shortest(band(bm)?.max(band(cm)?))?,
714        };
715        let (b, c) = (shortest(bv)?, shortest(cv)?);
716        Ok(match (higher, better) {
717            // c > b + band.
718            (true, true) => exact_sum_cmp(&[c], &[b, tolerance]).is_gt(),
719            // c < b - band, that is b > c + band.
720            (false, true) => exact_sum_cmp(&[b], &[c, tolerance]).is_gt(),
721            // c >= b - band, that is c + band >= b.
722            (true, false) => exact_sum_cmp(&[c, tolerance], &[b]).is_ge(),
723            // c <= b + band, that is b + band >= c.
724            (false, false) => exact_sum_cmp(&[b, tolerance], &[c]).is_ge(),
725        })
726    };
727    match compare() {
728        Ok(ok) => outcome(ok, &format!("{} in {cohort}", clause.metric())),
729        Err(r) => unknown(r),
730    }
731}
732fn evaluate_clause(
733    label: String,
734    clause: &Clause,
735    gate: &SelectionGate,
736    b: &Side<'_>,
737    c: &Side<'_>,
738) -> ClauseResult {
739    match clause {
740        Clause::Metric(m) => result(label, metric_clause(m, gate, b, c, "all")),
741        Clause::HeldOut { held_out } => result(
742            label,
743            if c.split_role != SplitRole::HeldOut {
744                pass("nothing is held out")
745            } else {
746                match c.held_out {
747                    Some(HeldOut::Counted(r))
748                        if r.count > 0
749                            && r.plan_count > 0
750                            && r.count <= r.dataset_count
751                            && r.plan_count <= r.dataset_count
752                            && r.dataset_count <= 10_000 =>
753                    {
754                        outcome(
755                            r.count <= held_out.max_evaluations,
756                            "held-out lineage count",
757                        )
758                    }
759                    Some(HeldOut::Unknown { reason }) => unknown(reason),
760                    _ => unknown("held-out count missing or invalid"),
761                }
762            },
763        ),
764        Clause::AllSlices { all_slices } => {
765            let slices: Vec<_> = b
766                .cohorts
767                .iter()
768                .filter(|c| c.name != "all")
769                .map(|s| SliceResult {
770                    slice: s.name.clone(),
771                    result: metric_clause(all_slices, gate, b, c, &s.name),
772                })
773                .collect();
774            let combined = slices
775                .iter()
776                .find(|s| matches!(s.result, ClauseOutcome::Fail { .. }))
777                .or_else(|| {
778                    slices
779                        .iter()
780                        .find(|s| matches!(s.result, ClauseOutcome::Unknown { .. }))
781                })
782                .map(|s| s.result.clone())
783                .unwrap_or_else(|| {
784                    if slices.is_empty() {
785                        unknown("no declared slices")
786                    } else {
787                        pass("every declared slice passes")
788                    }
789                });
790            ClauseResult {
791                clause: label,
792                result: combined,
793                slices,
794            }
795        }
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use std::cmp::Ordering::{Equal, Greater, Less};
803
804    fn cmp(l: &[f64], t: &str, r: &[f64]) -> std::cmp::Ordering {
805        let mut lhs: Vec<_> = l.iter().map(|v| shortest(*v).unwrap()).collect();
806        lhs.push(exact(Decimal::parse(t).unwrap()));
807        let rhs: Vec<_> = r.iter().map(|v| shortest(*v).unwrap()).collect();
808        exact_sum_cmp(&lhs, &rhs)
809    }
810
811    #[test]
812    fn non_finite_values_are_refused_not_read() {
813        for v in [f64::NAN, -f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
814            assert_eq!(shortest(v).err().as_deref(), Some("non-finite value"));
815        }
816    }
817
818    #[test]
819    fn exact_sums_over_signs_and_the_binary64_range() {
820        assert_eq!(cmp(&[0.2], "0.1", &[0.3]), Equal);
821        assert_eq!(cmp(&[0.7], "0.2", &[0.9]), Equal);
822        assert_eq!(cmp(&[-0.3], "0.1", &[-0.2]), Equal);
823        assert_eq!(cmp(&[-0.0], "0", &[0.0]), Equal);
824        assert_eq!(cmp(&[f64::MAX], "1", &[f64::MAX]), Greater);
825        assert_eq!(cmp(&[5e-324], "0", &[1e-323]), Less);
826        assert_eq!(cmp(&[-f64::MAX], "0.000000001", &[f64::MIN_POSITIVE]), Less);
827        assert_eq!(cmp(&[9.999_999_999], "0.000000001", &[10.0]), Equal);
828    }
829}