1use 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";
21pub 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 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 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 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
242fn 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
250fn 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
261pub 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
365pub(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#[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 (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 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
491pub(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 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 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}
586struct Exact {
589 negative: bool,
590 digits: Vec<u8>,
591 exponent: i32,
592}
593fn 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}
638fn 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 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 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 (true, true) => exact_sum_cmp(&[c], &[b, tolerance]).is_gt(),
719 (false, true) => exact_sum_cmp(&[b], &[c, tolerance]).is_gt(),
721 (true, false) => exact_sum_cmp(&[c, tolerance], &[b]).is_ge(),
723 (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}