1use std::collections::BTreeSet;
23use std::fmt;
24
25use rustev_contract::bounded::ParseLimits;
26use rustev_contract::canonical::tagged_digest;
27use rustev_contract::decimal::Decimal;
28use rustev_contract::ids::{ContentDigest, EvaluatorConfigId};
29use rustev_contract::limits::REPLAY_V1;
30use rustev_contract::{Document, DocumentError, Identified};
31use serde::{Deserialize, Serialize};
32
33use crate::dataset::AdapterRef;
34pub const EVALUATOR_CONFIG: &str = "rustev.evaluator-config/1";
35pub const EVALUATOR_CONFIG_V2: &str = "rustev.evaluator-config/2";
37pub const MAX_GRID: usize = 4096;
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct EvaluatorConfig {
43 pub schema: String,
44 pub adapter: ConfigAdapter,
45 pub label_interpretation: String,
47 pub agreement: Agreement,
48 pub probability_step: Option<String>,
51 pub reliability_bins: Vec<Decimal>,
53 pub subgroups: Vec<String>,
55 pub latency_quantiles: Vec<Decimal>,
57 pub cost_units_comparable: bool,
59 pub metrics: Vec<MetricFormula>,
61 pub gates: Vec<Gate>,
62 pub temperature_grid: Vec<Decimal>,
65}
66
67impl Document for EvaluatorConfig {
71 const SCHEMA: &'static str = EVALUATOR_CONFIG;
72 const LIMITS: ParseLimits = REPLAY_V1;
73 fn schema(&self) -> &str {
74 &self.schema
75 }
76
77 fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
78 let doc: Self = rustev_contract::bounded::parse_bounded(bytes, &Self::LIMITS)
79 .map_err(DocumentError::Parse)?;
80 if doc.schema != EVALUATOR_CONFIG && doc.schema != EVALUATOR_CONFIG_V2 {
81 return Err(DocumentError::Schema {
82 expected: EVALUATOR_CONFIG_V2,
83 found: doc.schema.chars().take(80).collect(),
84 });
85 }
86 Ok(doc)
87 }
88
89 fn record_digest(&self) -> Result<ContentDigest, rustev_contract::canonical::CanonicalError> {
90 let raw = self.record_canonical()?;
91 Ok(ContentDigest::parse(&tagged_digest(&self.schema, &raw))
92 .expect("a tagged digest is a content digest"))
93 }
94}
95
96impl Identified for EvaluatorConfig {
97 type Id = EvaluatorConfigId;
98 fn wrap(digest: String) -> EvaluatorConfigId {
99 EvaluatorConfigId::parse(&digest).expect("a tagged digest is an identity")
100 }
101
102 fn id(&self) -> Result<EvaluatorConfigId, rustev_contract::canonical::CanonicalError> {
103 Ok(Self::wrap(tagged_digest(&self.schema, &self.canonical()?)))
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct ConfigAdapter {
112 pub name: String,
113 pub version: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub rules: Option<AdapterRules>,
117}
118
119impl ConfigAdapter {
120 pub fn v1(r: AdapterRef) -> ConfigAdapter {
122 ConfigAdapter {
123 name: r.name,
124 version: r.version,
125 rules: None,
126 }
127 }
128
129 pub fn v2(r: AdapterRef, rules: AdapterRules) -> ConfigAdapter {
131 ConfigAdapter {
132 name: r.name,
133 version: r.version,
134 rules: Some(rules),
135 }
136 }
137
138 pub fn reference(&self) -> AdapterRef {
139 AdapterRef {
140 name: self.name.clone(),
141 version: self.version.clone(),
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum AdapterRules {
152 Bound(ContentDigest),
153 Opaque,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct Agreement {
161 pub params: Vec<String>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct MetricFormula {
167 pub name: String,
168 pub formula: String,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum Direction {
174 LowerIsBetter,
177 HigherIsBetter,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct Gate {
185 pub name: String,
186 pub metric: String,
187 pub direction: Direction,
188 pub tolerance: Decimal,
190 pub min_comparable_coverage: Decimal,
192 pub min_labeled_coverage: Decimal,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct ConfigError(pub String);
199
200impl fmt::Display for ConfigError {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 f.write_str(&self.0)
203 }
204}
205
206impl std::error::Error for ConfigError {}
207
208fn increasing(v: &[Decimal]) -> bool {
209 v.windows(2).all(|w| w[0] < w[1])
210}
211
212impl EvaluatorConfig {
213 pub fn bound_rules(&self) -> Option<&ContentDigest> {
216 match (&self.adapter.rules, self.schema.as_str()) {
217 (Some(AdapterRules::Bound(d)), EVALUATOR_CONFIG_V2) => Some(d),
218 _ => None,
219 }
220 }
221
222 pub fn check(&self) -> Result<(), ConfigError> {
223 let bad = |m: &str| Err(ConfigError(m.into()));
224 match (self.schema.as_str(), &self.adapter.rules) {
225 (EVALUATOR_CONFIG, None) | (EVALUATOR_CONFIG_V2, Some(_)) => {}
226 (EVALUATOR_CONFIG, Some(_)) => return bad("version 1 names no adapter rules"),
227 (EVALUATOR_CONFIG_V2, None) => return bad("version 2 names the adapter rules"),
228 _ => return bad("schema"),
229 }
230 let one = Decimal::from_i64(1);
231 let b = &self.reliability_bins;
232 if b.len() < 2 || b[0] != Decimal::ZERO || b[b.len() - 1] != one || !increasing(b) {
233 return bad("reliability bins run strictly upward from 0 to 1");
234 }
235 let q = &self.latency_quantiles;
236 if q.iter().any(|x| *x <= Decimal::ZERO || *x > one) || !increasing(q) {
237 return bad("latency quantiles are strictly increasing in (0, 1]");
238 }
239 let names: BTreeSet<&str> = self.metrics.iter().map(|m| m.name.as_str()).collect();
240 if names.len() != self.metrics.len() {
241 return bad("metric names are unique");
242 }
243 let groups: BTreeSet<&str> = self.subgroups.iter().map(String::as_str).collect();
244 if groups.len() != self.subgroups.len() || groups.contains("all") {
245 return bad("subgroups are unique and none is named `all`");
246 }
247 let mut gate_names = BTreeSet::new();
248 for g in &self.gates {
249 if !gate_names.insert(&g.name) {
250 return bad("gate names are unique");
251 }
252 let base = g.metric.split('/').next().unwrap_or_default();
254 if !names.contains(base) {
255 return bad("a gate names a metric the configuration does not declare");
256 }
257 if g.tolerance.is_negative() {
258 return bad("a gate tolerance is non-negative");
259 }
260 for c in [g.min_comparable_coverage, g.min_labeled_coverage] {
261 if c.is_negative() || c > one {
262 return bad("a minimum coverage is in [0, 1]");
263 }
264 }
265 }
266 let t = &self.temperature_grid;
267 let max = Decimal::from_i64(1000);
268 if t.len() > MAX_GRID || !increasing(t) || t.iter().any(|x| *x <= Decimal::ZERO || *x > max)
269 {
270 return bad("the temperature grid is strictly increasing in (0, 1000], at most 4096");
271 }
272 Ok(())
273 }
274}