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, CohortStatus, FigureDoc, HeldOut, MetricReport, Observation, RepeatReport, 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/// Verify all supplied bindings before comparing values. A failed precondition
243/// makes that candidate unknown. Once preconditions hold, any definite clause
244/// failure dominates unknown clauses, including unknown coverage denominators.
245pub fn evaluate_selection(
246    gate: &SelectionGate,
247    baseline: &Baseline,
248    baseline_evidence: &RepeatEvidence<'_>,
249    candidates: &[NamedCandidate<'_>],
250) -> Result<Selection, String> {
251    gate.check()?;
252    SelectionGate::parse(&gate.canonical().map_err(|e| e.to_string())?)
253        .map_err(|e| e.to_string())?;
254    check_names(candidates)?;
255    let base = verify_baseline(baseline, baseline_evidence);
256    let mut decisions = Vec::new();
257    let mut ranks = Vec::new();
258    let mut unranked = Vec::new();
259    for c in candidates {
260        let precondition = base
261            .clone()
262            .and_then(|()| compatible(baseline_evidence, &c.evidence))
263            .and_then(|()| verify(&c.evidence))
264            .and_then(|()| stable(baseline_evidence, &c.evidence));
265        let mut clauses = Vec::new();
266        let precondition_reason = precondition.err();
267        clauses.push(result(
268            "preconditions".into(),
269            match &precondition_reason {
270                Some(r) => unknown(r),
271                None => pass("bindings and cohorts verified"),
272            },
273        ));
274        if precondition_reason.is_none() {
275            for (side, e) in [("baseline", baseline_evidence), ("candidate", &c.evidence)] {
276                for (r, run) in e.inputs.runs.iter().enumerate() {
277                    for (metric, min) in [
278                        ("coverage", gate.min_comparable_coverage),
279                        ("labeled_proposal_coverage", gate.min_labeled_coverage),
280                    ] {
281                        let value = match run
282                            .evaluated
283                            .detail
284                            .count(metric)
285                            .filter(|v| v.denominator > 0)
286                        {
287                            None => unknown("coverage has no count or a zero denominator"),
288                            Some(v) => outcome(
289                                i128::from(v.numerator) * 1_000_000_000
290                                    >= min.units() * i128::from(v.denominator),
291                                "coverage minimum",
292                            ),
293                        };
294                        clauses.push(result(format!("{side}.runs[{r}].{metric}"), value));
295                    }
296                }
297            }
298        }
299        for (group, list) in [
300            ("require", gate.require.as_slice()),
301            ("and_any", gate.and_any.as_deref().unwrap_or(&[])),
302        ] {
303            for (i, clause) in list.iter().enumerate() {
304                let label = format!("{group}[{i}]");
305                clauses.push(match &precondition_reason {
306                    Some(r) => result(label, unknown(format!("precondition: {r}"))),
307                    None => evaluate_clause(
308                        label,
309                        clause,
310                        gate,
311                        baseline_evidence.report,
312                        c.evidence.report,
313                    ),
314                });
315            }
316        }
317        if let Some(any) = &gate.and_any {
318            let members: Vec<_> = clauses
319                .iter()
320                .filter(|r| r.clause.starts_with("and_any["))
321                .map(|r| &r.result)
322                .collect();
323            debug_assert_eq!(members.len(), any.len());
324            let v = if members
325                .iter()
326                .any(|r| matches!(r, ClauseOutcome::Pass { .. }))
327            {
328                pass("at least one clause passes")
329            } else if members
330                .iter()
331                .all(|r| matches!(r, ClauseOutcome::Fail { .. }))
332            {
333                fail("every clause fails")
334            } else {
335                unknown("no clause passes and at least one is unknown")
336            };
337            clauses.push(result("and_any".into(), v));
338        }
339        // Individual OR members do not act as required clauses.
340        let relevant: Vec<_> = clauses
341            .iter()
342            .filter(|r| !r.clause.starts_with("and_any["))
343            .collect();
344        let verdict = if let Some(r) = relevant
345            .iter()
346            .find(|r| matches!(r.result, ClauseOutcome::Fail { .. }))
347        {
348            Verdict::Rejected {
349                clause: r.clause.clone(),
350            }
351        } else if let Some(r) = relevant
352            .iter()
353            .find(|r| matches!(r.result, ClauseOutcome::Unknown { .. }))
354        {
355            let clause = if r.clause == "and_any" {
356                clauses
357                    .iter()
358                    .find(|r| {
359                        r.clause.starts_with("and_any[")
360                            && matches!(r.result, ClauseOutcome::Unknown { .. })
361                    })
362                    .expect("an unknown disjunction has an unknown member")
363                    .clause
364                    .clone()
365            } else {
366                r.clause.clone()
367            };
368            Verdict::Unknown { clause }
369        } else {
370            Verdict::Qualifies
371        };
372        if verdict == Verdict::Qualifies {
373            let values: Result<Vec<_>, String> = gate
374                .rank_by
375                .iter()
376                .map(|m| metric(c.evidence.report, "all", m).and_then(|m| value(m, true)))
377                .collect();
378            match values {
379                Ok(values) => ranks.push((c.name.to_string(), values)),
380                Err(reason) => unranked.push(Unranked {
381                    name: c.name.into(),
382                    reason,
383                }),
384            }
385        }
386        decisions.push(CandidateSelection {
387            name: c.name.into(),
388            repeat_report: c
389                .evidence
390                .report
391                .record_digest()
392                .map_err(|e| e.to_string())?,
393            verdict,
394            clauses,
395        });
396    }
397    ranks.sort_by(|(an, av), (bn, bv)| {
398        for ((a, b), m) in av.iter().zip(bv).zip(&gate.rank_by) {
399            // Numeric equality includes both signs of zero. All values finite.
400            let order = a.partial_cmp(b).expect("finite rank values");
401            let order = match gate.metrics[m] {
402                Direction::HigherIsBetter => order.reverse(),
403                Direction::LowerIsBetter => order,
404            };
405            if !order.is_eq() {
406                return order;
407            }
408        }
409        an.as_bytes().cmp(bn.as_bytes())
410    });
411    let doc = Selection {
412        schema: SELECTION.into(),
413        gate: gate.id().map_err(|e| e.to_string())?,
414        baseline: baseline.record_digest().map_err(|e| e.to_string())?,
415        candidates: decisions,
416        ranked: ranks.into_iter().map(|(n, _)| n).collect(),
417        unranked,
418    };
419    Selection::parse(&doc.record_canonical().map_err(|e| e.to_string())?).map_err(|e| e.to_string())
420}
421
422fn stable(b: &RepeatEvidence<'_>, c: &RepeatEvidence<'_>) -> Result<(), String> {
423    if b.report.stable_cohort != c.report.stable_cohort {
424        return Err("stable cohorts differ".into());
425    }
426    for (b, c) in b.inputs.runs.iter().zip(c.inputs.runs) {
427        let comparable = |e: &crate::report::Evaluated| -> BTreeSet<String> {
428            e.detail
429                .cases
430                .iter()
431                .filter(|c| {
432                    c.outcome.starts_with("proposal:") || c.outcome.starts_with("abstention:")
433                })
434                .map(|c| c.case.clone())
435                .collect()
436        };
437        if comparable(b.evaluated) != comparable(c.evaluated) {
438            return Err("per-run comparable cohorts differ".into());
439        }
440        if b.evaluated
441            .detail
442            .count("candidate_outcome_change")
443            .is_some()
444            || c.evaluated
445                .detail
446                .count("candidate_outcome_change")
447                .is_none()
448        {
449            return Err("run baseline and candidate roles do not hold".into());
450        }
451    }
452    Ok(())
453}
454
455fn metric<'a>(r: &'a RepeatReport, cohort: &str, name: &str) -> Result<&'a MetricReport, String> {
456    let c = r
457        .cohorts
458        .iter()
459        .find(|c| c.name == cohort)
460        .ok_or_else(|| format!("missing cohort {cohort}"))?;
461    if c.status == CohortStatus::BelowMinimum {
462        return Err(format!("{cohort}: below_minimum"));
463    }
464    c.metrics
465        .iter()
466        .find(|m| m.name == name)
467        .ok_or_else(|| format!("metric {name} not reported in {cohort}"))
468}
469fn value(m: &MetricReport, candidate: bool) -> Result<f64, String> {
470    if candidate && observed(&m.name) && m.observation != Some(Observation::Candidate) {
471        return Err(format!(
472            "{}: historical or missing candidate observation",
473            m.name
474        ));
475    }
476    match &m.pooled {
477        FigureDoc::Value(v) if v.is_finite() => Ok(*v),
478        FigureDoc::Unknown(r) => Err(format!("{}: {r}", m.name)),
479        _ => Err("non-finite value".into()),
480    }
481}
482fn band(m: &MetricReport) -> Result<f64, String> {
483    match &m.band {
484        BandDoc::Value { value, .. } if value.is_finite() && *value >= 0.0 => Ok(*value),
485        BandDoc::Unknown(r) => Err(format!("{} band: {r}", m.name)),
486        _ => Err("invalid band".into()),
487    }
488}
489fn metric_clause(
490    clause: &MetricClause,
491    gate: &SelectionGate,
492    b: &RepeatReport,
493    c: &RepeatReport,
494    cohort: &str,
495) -> ClauseOutcome {
496    let compare = || -> Result<bool, String> {
497        let bm = metric(b, cohort, clause.metric())?;
498        let cm = metric(c, cohort, clause.metric())?;
499        let bv = value(bm, false)?;
500        let cv = value(cm, true)?;
501        let tolerance = match clause {
502            MetricClause::NotWorse {
503                within: Within::Absolute(t),
504                ..
505            } => t.to_f64_nearest(),
506            _ => band(bm)?.max(band(cm)?),
507        };
508        let higher = gate.metrics[clause.metric()] == Direction::HigherIsBetter;
509        let better = matches!(clause, MetricClause::Better { .. });
510        let threshold = if higher == better {
511            bv + tolerance
512        } else {
513            bv - tolerance
514        };
515        if !threshold.is_finite() {
516            return Err("non-finite comparison threshold".into());
517        }
518        Ok(match (higher, better) {
519            (true, true) => cv > threshold,
520            (false, true) => cv < threshold,
521            (true, false) => cv >= threshold,
522            (false, false) => cv <= threshold,
523        })
524    };
525    match compare() {
526        Ok(ok) => outcome(ok, &format!("{} in {cohort}", clause.metric())),
527        Err(r) => unknown(r),
528    }
529}
530fn evaluate_clause(
531    label: String,
532    clause: &Clause,
533    gate: &SelectionGate,
534    b: &RepeatReport,
535    c: &RepeatReport,
536) -> ClauseResult {
537    match clause {
538        Clause::Metric(m) => result(label, metric_clause(m, gate, b, c, "all")),
539        Clause::HeldOut { held_out } => result(
540            label,
541            if c.split_role != SplitRole::HeldOut {
542                pass("nothing is held out")
543            } else {
544                match &c.held_out {
545                    Some(HeldOut::Counted(r))
546                        if r.count > 0
547                            && r.plan_count > 0
548                            && r.count <= r.dataset_count
549                            && r.plan_count <= r.dataset_count
550                            && r.dataset_count <= 10_000 =>
551                    {
552                        outcome(
553                            r.count <= held_out.max_evaluations,
554                            "held-out lineage count",
555                        )
556                    }
557                    Some(HeldOut::Unknown { reason }) => unknown(reason),
558                    _ => unknown("held-out count missing or invalid"),
559                }
560            },
561        ),
562        Clause::AllSlices { all_slices } => {
563            let slices: Vec<_> = b
564                .cohorts
565                .iter()
566                .filter(|c| c.name != "all")
567                .map(|s| SliceResult {
568                    slice: s.name.clone(),
569                    result: metric_clause(all_slices, gate, b, c, &s.name),
570                })
571                .collect();
572            let combined = slices
573                .iter()
574                .find(|s| matches!(s.result, ClauseOutcome::Fail { .. }))
575                .or_else(|| {
576                    slices
577                        .iter()
578                        .find(|s| matches!(s.result, ClauseOutcome::Unknown { .. }))
579                })
580                .map(|s| s.result.clone())
581                .unwrap_or_else(|| {
582                    if slices.is_empty() {
583                        unknown("no declared slices")
584                    } else {
585                        pass("every declared slice passes")
586                    }
587                });
588            ClauseResult {
589                clause: label,
590                result: combined,
591                slices,
592            }
593        }
594    }
595}