1use std::collections::BTreeSet;
28use std::fmt;
29
30use rustev_contract::bounded::ParseLimits;
31use rustev_contract::canonical::tagged_digest;
32use rustev_contract::decimal::Decimal;
33use rustev_contract::ids::{ContentDigest, EvaluatorConfigId};
34use rustev_contract::limits::REPLAY_V1;
35use rustev_contract::{Document, DocumentError, Identified};
36use serde::{Deserialize, Serialize};
37
38use crate::dataset::AdapterRef;
39use crate::scoring::{
40 AgreementDecl, Declarations, HumanScale, OriginFamily, RubricMetric, ScorerDescriptor,
41 check_prefix,
42};
43pub const EVALUATOR_CONFIG: &str = "rustev.evaluator-config/1";
44pub const EVALUATOR_CONFIG_V2: &str = "rustev.evaluator-config/2";
46pub const EVALUATOR_CONFIG_V3: &str = "rustev.evaluator-config/3";
49pub const MAX_GRID: usize = 4096;
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct EvaluatorConfig {
55 pub schema: String,
56 pub adapter: ConfigAdapter,
57 pub label_interpretation: String,
59 pub agreement: Agreement,
60 pub probability_step: Option<String>,
63 pub reliability_bins: Vec<Decimal>,
65 pub subgroups: Vec<String>,
67 pub latency_quantiles: Vec<Decimal>,
69 pub cost_units_comparable: bool,
71 pub metrics: Vec<MetricFormula>,
73 pub gates: Vec<Gate>,
74 pub temperature_grid: Vec<Decimal>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub scorers: Option<Vec<ScorerDescriptor>>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub rubric_metrics: Option<Vec<RubricMetric>>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub human_scales: Option<Vec<HumanScale>>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub agreements: Option<Vec<AgreementDecl>>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub origin: Option<Vec<OriginFamily>>,
89}
90
91impl Document for EvaluatorConfig {
95 const SCHEMA: &'static str = EVALUATOR_CONFIG;
96 const LIMITS: ParseLimits = REPLAY_V1;
97 fn schema(&self) -> &str {
98 &self.schema
99 }
100
101 fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
102 let doc: Self = rustev_contract::bounded::parse_bounded(bytes, &Self::LIMITS)
103 .map_err(DocumentError::Parse)?;
104 if ![EVALUATOR_CONFIG, EVALUATOR_CONFIG_V2, EVALUATOR_CONFIG_V3]
105 .contains(&doc.schema.as_str())
106 {
107 return Err(DocumentError::Schema {
108 expected: EVALUATOR_CONFIG_V3,
109 found: doc.schema.chars().take(80).collect(),
110 });
111 }
112 Ok(doc)
113 }
114
115 fn record_digest(&self) -> Result<ContentDigest, rustev_contract::canonical::CanonicalError> {
116 let raw = self.record_canonical()?;
117 Ok(ContentDigest::parse(&tagged_digest(&self.schema, &raw))
118 .expect("a tagged digest is a content digest"))
119 }
120}
121
122impl Identified for EvaluatorConfig {
123 type Id = EvaluatorConfigId;
124 fn wrap(digest: String) -> EvaluatorConfigId {
125 EvaluatorConfigId::parse(&digest).expect("a tagged digest is an identity")
126 }
127
128 fn id(&self) -> Result<EvaluatorConfigId, rustev_contract::canonical::CanonicalError> {
129 Ok(Self::wrap(tagged_digest(&self.schema, &self.canonical()?)))
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ConfigAdapter {
138 pub name: String,
139 pub version: String,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub rules: Option<AdapterRules>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub measures: Option<ContentDigest>,
147}
148
149impl ConfigAdapter {
150 pub fn v1(r: AdapterRef) -> ConfigAdapter {
152 ConfigAdapter {
153 name: r.name,
154 version: r.version,
155 rules: None,
156 measures: None,
157 }
158 }
159
160 pub fn v2(r: AdapterRef, rules: AdapterRules) -> ConfigAdapter {
162 ConfigAdapter {
163 name: r.name,
164 version: r.version,
165 rules: Some(rules),
166 measures: None,
167 }
168 }
169
170 pub fn v3(r: AdapterRef, rules: AdapterRules, measures: ContentDigest) -> ConfigAdapter {
172 ConfigAdapter {
173 name: r.name,
174 version: r.version,
175 rules: Some(rules),
176 measures: Some(measures),
177 }
178 }
179
180 pub fn reference(&self) -> AdapterRef {
181 AdapterRef {
182 name: self.name.clone(),
183 version: self.version.clone(),
184 }
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum AdapterRules {
194 Bound(ContentDigest),
195 Opaque,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct Agreement {
203 pub params: Vec<String>,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct MetricFormula {
209 pub name: String,
210 pub formula: String,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum Direction {
216 LowerIsBetter,
219 HigherIsBetter,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct Gate {
227 pub name: String,
228 pub metric: String,
229 pub direction: Direction,
230 pub tolerance: Decimal,
232 pub min_comparable_coverage: Decimal,
234 pub min_labeled_coverage: Decimal,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct ConfigError(pub String);
241
242impl fmt::Display for ConfigError {
243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 f.write_str(&self.0)
245 }
246}
247
248impl std::error::Error for ConfigError {}
249
250fn increasing(v: &[Decimal]) -> bool {
251 v.windows(2).all(|w| w[0] < w[1])
252}
253
254impl EvaluatorConfig {
255 pub fn bound_rules(&self) -> Option<&ContentDigest> {
258 match (&self.adapter.rules, self.schema.as_str()) {
259 (Some(AdapterRules::Bound(d)), EVALUATOR_CONFIG_V2 | EVALUATOR_CONFIG_V3) => Some(d),
260 _ => None,
261 }
262 }
263
264 pub fn declarations(&self) -> Option<Declarations<'_>> {
266 if self.schema != EVALUATOR_CONFIG_V3 {
267 return None;
268 }
269 Some(Declarations {
270 scorers: self.scorers.as_deref()?,
271 rubric_metrics: self.rubric_metrics.as_deref()?,
272 human_scales: self.human_scales.as_deref()?,
273 agreements: self.agreements.as_deref()?,
274 origin: self.origin.as_deref()?,
275 })
276 }
277
278 pub fn check(&self) -> Result<(), ConfigError> {
279 let bad = |m: &str| Err(ConfigError(m.into()));
280 let v3_members = [
281 self.scorers.is_some(),
282 self.rubric_metrics.is_some(),
283 self.human_scales.is_some(),
284 self.agreements.is_some(),
285 self.origin.is_some(),
286 ];
287 let measures = self.adapter.measures.is_some();
288 match (self.schema.as_str(), &self.adapter.rules) {
289 (EVALUATOR_CONFIG, None) | (EVALUATOR_CONFIG_V2 | EVALUATOR_CONFIG_V3, Some(_)) => {}
290 (EVALUATOR_CONFIG, Some(_)) => return bad("version 1 names no adapter rules"),
291 (EVALUATOR_CONFIG_V2, None) => return bad("version 2 names the adapter rules"),
292 (EVALUATOR_CONFIG_V3, None) => return bad("version 3 names the adapter rules"),
293 _ => return bad("schema"),
294 }
295 if self.schema == EVALUATOR_CONFIG_V3 {
296 if !measures || v3_members.contains(&false) {
297 return bad(
298 "version 3 names the adapter measures, scorers, rubric metrics, human scales, agreements and origin",
299 );
300 }
301 } else if measures || v3_members.contains(&true) {
302 return bad(
303 "only version 3 names measures, scorers, rubric metrics, agreements or origin",
304 );
305 }
306 let one = Decimal::from_i64(1);
307 let b = &self.reliability_bins;
308 if b.len() < 2 || b[0] != Decimal::ZERO || b[b.len() - 1] != one || !increasing(b) {
309 return bad("reliability bins run strictly upward from 0 to 1");
310 }
311 let q = &self.latency_quantiles;
312 if q.iter().any(|x| *x <= Decimal::ZERO || *x > one) || !increasing(q) {
313 return bad("latency quantiles are strictly increasing in (0, 1]");
314 }
315 let names: BTreeSet<&str> = self.metrics.iter().map(|m| m.name.as_str()).collect();
316 if names.len() != self.metrics.len() {
317 return bad("metric names are unique");
318 }
319 let groups: BTreeSet<&str> = self.subgroups.iter().map(String::as_str).collect();
320 if groups.len() != self.subgroups.len() || groups.contains("all") {
321 return bad("subgroups are unique and none is named `all`");
322 }
323 let mut gate_names = BTreeSet::new();
324 for g in &self.gates {
325 if !gate_names.insert(&g.name) {
326 return bad("gate names are unique");
327 }
328 let base = g.metric.split('/').next().unwrap_or_default();
330 if !names.contains(base) {
331 return bad("a gate names a metric the configuration does not declare");
332 }
333 if g.tolerance.is_negative() {
334 return bad("a gate tolerance is non-negative");
335 }
336 for c in [g.min_comparable_coverage, g.min_labeled_coverage] {
337 if c.is_negative() || c > one {
338 return bad("a minimum coverage is in [0, 1]");
339 }
340 }
341 }
342 if let Some(d) = self.declarations() {
343 d.check()?;
344 let reported = d.metric_names();
345 for name in reported.keys() {
348 let base = name.split('/').next().unwrap_or_default();
349 if !names.contains(base) {
350 return bad(
351 "a reported metric names a base the configuration does not declare",
352 );
353 }
354 }
355 for g in &self.gates {
356 check_prefix(&g.metric, &reported)?;
357 }
358 }
359 let t = &self.temperature_grid;
360 let max = Decimal::from_i64(1000);
361 if t.len() > MAX_GRID || !increasing(t) || t.iter().any(|x| *x <= Decimal::ZERO || *x > max)
362 {
363 return bad("the temperature grid is strictly increasing in (0, 1000], at most 4096");
364 }
365 Ok(())
366 }
367}