rustev_eval/
repeat_config.rs1use std::collections::{BTreeMap, BTreeSet};
11
12use rustev_contract::decimal::Decimal;
13use rustev_contract::ids::ContentDigest;
14use rustev_contract::limits::REPLAY_V1;
15use serde::{Deserialize, Serialize};
16
17use crate::config::ConfigError;
18use crate::generator::GENERATOR;
19
20pub const REPEAT_CONFIG: &str = "rustev.repeat-config/1";
21pub const METHOD: &str = "percentile-cluster/1";
23pub const MIN_RESAMPLES: u64 = 1_000;
25pub const MAX_RESAMPLES: u64 = 100_000;
26pub const MAX_TOP_K: u64 = 100;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct RepeatConfig {
32 pub schema: String,
33 pub bootstrap: BootstrapSpec,
34 pub noise: Noise,
35 pub slices: Vec<SliceSpec>,
36 pub stability_metrics: Vec<String>,
37 pub top_k: Option<TopK>,
38}
39
40crate::doc::eval_document!(RepeatConfig, REPEAT_CONFIG, REPLAY_V1, ContentDigest);
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct BootstrapSpec {
45 pub method: String,
46 pub prng: String,
47 pub level: Decimal,
49 pub resamples: u64,
51 pub seed: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Noise {
58 pub rule: BandRule,
59 pub floor: Decimal,
60 pub overrides: BTreeMap<String, NoiseOverride>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct NoiseOverride {
66 pub rule: BandRule,
67 pub floor: Decimal,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum BandRule {
74 KSd(KSd),
75 IntervalWidth,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct KSd {
81 pub k: Decimal,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct SliceSpec {
88 pub name: String,
89 pub min_cases: u64,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct TopK {
96 pub param: String,
97 pub k: u64,
99}
100
101pub fn parse_seed(s: &str) -> Option<u64> {
104 let canonical =
105 !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) && (s == "0" || !s.starts_with('0'));
106 if canonical { s.parse().ok() } else { None }
107}
108
109impl RepeatConfig {
110 pub fn band_for(&self, metric: &str) -> (&BandRule, Decimal) {
112 match self.noise.overrides.get(metric) {
113 Some(o) => (&o.rule, o.floor),
114 None => (&self.noise.rule, self.noise.floor),
115 }
116 }
117
118 pub fn level_units(&self) -> i128 {
121 self.bootstrap.level.units()
122 }
123
124 pub fn check(&self) -> Result<(), ConfigError> {
126 let bad = |m: &str| Err(ConfigError(m.into()));
127 let b = &self.bootstrap;
128 if b.method != METHOD {
129 return bad("the bootstrap method is `percentile-cluster/1`");
130 }
131 if b.prng != GENERATOR {
132 return bad("the generator is `xoshiro256starstar-splitmix64/1`");
133 }
134 if b.level < Decimal::parse("0.5").expect("literal")
135 || b.level > Decimal::parse("0.999").expect("literal")
136 {
137 return bad("the level is in [0.5, 0.999]");
138 }
139 if !(MIN_RESAMPLES..=MAX_RESAMPLES).contains(&b.resamples) {
140 return bad("resamples are 1000 to 100000");
141 }
142 if parse_seed(&b.seed).is_none() {
143 return bad("the seed is the decimal digits of a u64, without leading zeros");
144 }
145 let ten = Decimal::from_i64(10);
146 let rule_ok = |r: &BandRule| match r {
147 BandRule::KSd(KSd { k }) => *k > Decimal::ZERO && *k <= ten,
148 BandRule::IntervalWidth => true,
149 };
150 if !rule_ok(&self.noise.rule) || self.noise.overrides.values().any(|o| !rule_ok(&o.rule)) {
151 return bad("a k_sd multiplier is in (0, 10]");
152 }
153 if self.noise.floor.is_negative()
154 || self.noise.overrides.values().any(|o| o.floor.is_negative())
155 {
156 return bad("noise floors are non-negative");
157 }
158 let mut names = BTreeSet::new();
159 for s in &self.slices {
160 if s.name.is_empty() || s.name == "all" || !names.insert(s.name.as_str()) {
161 return bad("slice names are non-empty, unique, and none is `all`");
162 }
163 if s.min_cases < 1 {
164 return bad("a slice's minimum is at least 1 case");
165 }
166 }
167 let stability: BTreeSet<&str> = self.stability_metrics.iter().map(String::as_str).collect();
168 if stability.is_empty() || stability.len() != self.stability_metrics.len() {
169 return bad("stability metrics are non-empty and unique");
170 }
171 if let Some(t) = &self.top_k
172 && (t.param.is_empty() || !(1..=MAX_TOP_K).contains(&t.k))
173 {
174 return bad("top_k names a parameter and k is 1 to 100");
175 }
176 Ok(())
177 }
178
179 pub fn check_metrics(&self, reported: &BTreeSet<String>) -> Result<(), ConfigError> {
183 for m in self
184 .stability_metrics
185 .iter()
186 .chain(self.noise.overrides.keys())
187 {
188 if !reported.contains(m) {
189 return Err(ConfigError(format!("metric `{m}` is not reported")));
190 }
191 }
192 Ok(())
193 }
194}