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, 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";
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
242pub 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 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 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}