1use std::collections::{BTreeMap, BTreeSet};
5
6use rustev_contract::Document;
7use rustev_contract::Identified;
8use rustev_contract::calibration::CalibrationArtifact;
9use rustev_contract::canonical::record_canonical_bytes;
10use rustev_contract::eval_report::{EvalReport, Split};
11use rustev_contract::limits::{DESCRIPTOR_V1, RECORD_V1, REPLAY_V1};
12use rustev_contract::replay::ReplayBundle;
13use rustev_core::Compiled;
14use rustev_eval::bundle::{BundleInput, BundleLoad, LoadFailure};
15use rustev_eval::config::EvaluatorConfig;
16use rustev_eval::dataset::{DatasetCase, DatasetManifest};
17use rustev_eval::judge::{JudgeError, judge_scores};
18use rustev_eval::measured::Measured;
19use rustev_eval::measures::TASK_ADAPTER_V2;
20use rustev_eval::metrics::TaskAdapter;
21use rustev_eval::report::{
22 Candidate, EvalDetail, EvalError, Evaluated, GateResult, Inputs, evaluate, evaluate_gate,
23 evaluate_measured_with_views, evaluate_views,
24};
25use rustev_eval::scores::{ScoreOutcome, ScoreViews, Scores};
26
27use rustev_eval::replay::ReplayConfig;
28use rustev_eval::resolve::Resolver;
29
30use crate::adapter::TaskAdapterDoc;
31use crate::deps::{self, DepArgs, RulesHook};
32use crate::host::{Host, IoFail, ReadFail};
33use crate::out::{Done, Out, Res, code};
34use crate::plan::{dep_failed, load_plan, read_plan};
35
36pub fn split_named(name: &str) -> Option<Split> {
38 match name {
39 "training" => Some(Split::Training),
40 "model_selection" => Some(Split::ModelSelection),
41 "calibration" => Some(Split::Calibration),
42 "final_test" => Some(Split::FinalTest),
43 _ => None,
44 }
45}
46
47pub fn read_doc<T: Document>(
50 command: &str,
51 host: &dyn Host,
52 path: &str,
53 limit: usize,
54) -> Result<T, Done> {
55 let bytes = host.read(path, limit).map_err(|e| Done::io(command, e))?;
56 T::parse(&bytes).map_err(|e| Done::invalid(command, path, e.to_string()))
57}
58
59pub fn check_case_ids<'a>(
63 command: &str,
64 dataset: &str,
65 cases: impl Iterator<Item = &'a DatasetCase>,
66) -> Result<(), Done> {
67 check_ids(command, dataset, cases.map(|c| c.id.as_str()))
68}
69
70fn check_ids<'a>(
72 command: &str,
73 input: &str,
74 ids: impl Iterator<Item = &'a str>,
75) -> Result<(), Done> {
76 let mut seen = BTreeSet::new();
77 for id in ids {
78 let ok = !id.is_empty()
79 && id.len() <= 128
80 && id.as_bytes()[0].is_ascii_alphanumeric()
81 && id
82 .bytes()
83 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
84 if !ok {
85 return Err(Done::invalid(
86 command,
87 input,
88 format!("case id {id:?} is not a bundle file name"),
89 ));
90 }
91 if !seen.insert(id.to_ascii_lowercase()) {
92 return Err(Done::invalid(
93 command,
94 input,
95 format!("case id {id:?} differs from another only by case"),
96 ));
97 }
98 }
99 Ok(())
100}
101
102pub fn read_bundles<'a>(
109 command: &str,
110 host: &dyn Host,
111 dir: &str,
112 cases: impl Iterator<Item = &'a DatasetCase>,
113) -> Result<BTreeMap<String, BundleInput>, Done> {
114 read_bundles_of(command, host, dir, cases.map(|c| c.id.as_str()))
115}
116
117fn read_bundles_of<'a>(
119 command: &str,
120 host: &dyn Host,
121 dir: &str,
122 ids: impl Iterator<Item = &'a str>,
123) -> Result<BTreeMap<String, BundleInput>, Done> {
124 let mut bundles = BTreeMap::new();
125 for id in ids {
126 let path = host.join(dir, &format!("{id}.json"));
127 let input = match host.read_classified(&path, REPLAY_V1.max_bytes.saturating_add(1)) {
128 Ok(None) => continue,
129 Err(ReadFail::Budget(e)) => return Err(Done::io(command, e)),
130 Err(ReadFail::File(e)) => LoadFailure::new(BundleLoad::Inaccessible, &e.detail).into(),
131 Ok(Some(bytes)) if bytes.len() > REPLAY_V1.max_bytes => LoadFailure::new(
132 BundleLoad::Oversized,
133 &format!("more than {} bytes", REPLAY_V1.max_bytes),
134 )
135 .into(),
136 Ok(Some(bytes)) => match ReplayBundle::parse(&bytes) {
137 Ok(b) => b.into(),
138 Err(e) => LoadFailure::new(BundleLoad::Corrupt, &e.to_string()).into(),
139 },
140 };
141 bundles.insert(id.to_string(), input);
142 }
143 Ok(bundles)
144}
145
146pub fn write_all(
149 command: &str,
150 host: &dyn Host,
151 dir: &str,
152 files: Vec<(&str, Vec<u8>)>,
153) -> Result<(), Done> {
154 let files: Vec<(String, Vec<u8>)> = files
155 .into_iter()
156 .map(|(n, b)| (host.join(dir, n), b))
157 .collect();
158 for (p, _) in &files {
159 host.ensure_absent(p).map_err(|e| Done::io(command, e))?;
160 }
161 host.write_all(&files).map_err(|e| Done::io(command, e))
162}
163
164pub const OUTPUTS: [&str; 4] = ["report.json", "detail.json", "config.json", "adapter.json"];
165
166#[derive(Debug, Clone, Copy)]
168pub struct EvalArgs<'a> {
169 pub split: Split,
170 pub replay: &'a ReplayConfig,
171 pub out: &'a str,
172 pub dataset: &'a str,
173 pub config: &'a str,
174 pub adapter: &'a str,
175 pub bundles: &'a str,
176 pub candidate_plan: Option<&'a str>,
177 pub deps: DepArgs<'a>,
178}
179
180pub const MAX_SCORES: usize = 16;
182pub const VIEWS: &str = "views.json";
184
185struct Loaded {
187 manifest: DatasetManifest,
188 config: EvaluatorConfig,
189 adapter: TaskAdapterDoc,
190 bundles: BTreeMap<String, BundleInput>,
191 candidate: Option<(Compiled, Vec<CalibrationArtifact>)>,
192}
193
194impl Loaded {
195 fn inputs<'a>(&'a self, a: &EvalArgs<'a>, resolver: &'a dyn Resolver) -> Inputs<'a> {
196 Inputs {
197 manifest: &self.manifest,
198 split: a.split,
199 config: &self.config,
200 adapter: &self.adapter,
201 bundles: &self.bundles,
202 resolver,
203 replay: a.replay,
204 candidate: self
205 .candidate
206 .as_ref()
207 .map(|(plan, calibrations)| Candidate { plan, calibrations }),
208 }
209 }
210
211 fn measured<'a>(&'a self, scores: &'a [Scores]) -> Measured<'a> {
212 Measured {
213 adapter: &self.adapter,
214 scores,
215 }
216 }
217}
218
219fn load(
220 command: &str,
221 host: &dyn Host,
222 a: &EvalArgs<'_>,
223 rules: &mut RulesHook<'_>,
224) -> Result<Loaded, Done> {
225 let dataset_path = a.dataset;
226 let manifest: DatasetManifest = read_doc(command, host, dataset_path, REPLAY_V1.max_bytes)?;
227 let config_path = a.config;
228 let config: EvaluatorConfig = read_doc(command, host, config_path, REPLAY_V1.max_bytes)?;
229 config
230 .check()
231 .map_err(|e| Done::invalid(command, config_path, e.to_string()))?;
232 let adapter_path = a.adapter;
233 let adapter: TaskAdapterDoc = read_doc(command, host, adapter_path, DESCRIPTOR_V1.max_bytes)?;
234 adapter
235 .check()
236 .map_err(|e| Done::invalid(command, adapter_path, e))?;
237 if config.adapter.reference() != adapter.adapter() {
238 return Err(Done::invalid(
239 command,
240 adapter_path,
241 "the configuration names another adapter",
242 ));
243 }
244 if config.declarations().is_some() && adapter.schema != TASK_ADAPTER_V2 {
247 return Err(Done::invalid(
248 command,
249 adapter_path,
250 format!("a version 3 configuration needs a {TASK_ADAPTER_V2} adapter"),
251 ));
252 }
253 if config
254 .adapter
255 .rules
256 .as_ref()
257 .is_some_and(|r| *r != adapter.rules())
258 {
259 return Err(Done::invalid(
260 command,
261 adapter_path,
262 "the configuration binds other adapter rules",
263 ));
264 }
265 manifest
266 .check(&adapter.adapter(), |l| adapter.check_label(l))
267 .map_err(|e| Done::invalid(command, dataset_path, e.to_string()))?;
268 check_case_ids(command, dataset_path, manifest.cases.iter())?;
269 let bundles = read_bundles(command, host, a.bundles, manifest.split(a.split))?;
270 let candidate = match a.candidate_plan {
271 None => None,
272 Some(path) => {
273 let deps = deps::load(host, a.deps, rules).map_err(|e| dep_failed(command, e))?;
274 let plan = read_plan(command, host, path)?;
275 Some((load_plan(command, &plan, &deps)?, deps.calibrations))
276 }
277 };
278 Ok(Loaded {
279 manifest,
280 config,
281 adapter,
282 bundles,
283 candidate,
284 })
285}
286
287fn eval_failed(command: &str, dataset: &str, e: EvalError) -> Done {
290 match e {
291 EvalError::Scores(code) => Done::invalid(command, "--scores", code),
292 e => Done::invalid(command, dataset, e.to_string()),
293 }
294}
295
296fn declares_scorers(config: &EvaluatorConfig) -> bool {
297 config.scorers.as_ref().is_some_and(|s| !s.is_empty())
298}
299
300pub fn eval(
303 command: &str,
304 host: &dyn Host,
305 a: EvalArgs<'_>,
306 resolver: &dyn Resolver,
307 rules: &mut RulesHook<'_>,
308) -> Res {
309 eval_scored(command, host, a, &[], resolver, rules)
310}
311
312pub fn eval_scored(
317 command: &str,
318 host: &dyn Host,
319 a: EvalArgs<'_>,
320 scores: &[String],
321 resolver: &dyn Resolver,
322 rules: &mut RulesHook<'_>,
323) -> Res {
324 let split = a.split;
325 (|| {
326 let out_dir = a.out;
327 for name in OUTPUTS {
328 host.ensure_absent(&host.join(out_dir, name))
329 .map_err(|e| Done::io(command, e))?;
330 }
331 let l = load(command, host, &a, rules)?;
332 let measured = l.config.declarations().is_some();
333 if !measured && !scores.is_empty() {
334 return Err(Done::invalid(
335 command,
336 a.config,
337 "--scores needs a version 3 configuration",
338 ));
339 }
340 if scores.len() > MAX_SCORES {
341 return Err(Done::invalid(
342 command,
343 "--scores",
344 format!("at most {MAX_SCORES} scores documents"),
345 ));
346 }
347 let with_views = declares_scorers(&l.config);
348 if with_views {
349 host.ensure_absent(&host.join(out_dir, VIEWS))
350 .map_err(|e| Done::io(command, e))?;
351 }
352 let scores = scores
353 .iter()
354 .map(|path| read_doc::<Scores>(command, host, path, REPLAY_V1.max_bytes))
355 .collect::<Result<Vec<_>, _>>()?;
356 let inputs = l.inputs(&a, resolver);
357 let (evaluated, views) = if measured {
358 evaluate_measured_with_views(&inputs, l.measured(&scores))
359 } else {
360 evaluate(&inputs).map(|e| (e, None))
361 }
362 .map_err(|e| eval_failed(command, a.dataset, e))?;
363 let bytes = |r: Result<Vec<u8>, rustev_contract::canonical::CanonicalError>| {
364 r.map_err(|e| Done::invalid(command, out_dir, e.to_string()))
365 };
366 let report_bytes = bytes(evaluated.report.record_canonical())?;
367 let detail_bytes = bytes(evaluated.detail.record_canonical())?;
368 let config_bytes = bytes(evaluated.config.canonical())?;
369 let adapter_bytes = bytes(l.adapter.canonical())?;
370 let metrics = bytes(record_canonical_bytes(&evaluated.report.metrics))?;
371 let mut files = vec![
372 (OUTPUTS[0], report_bytes),
373 (OUTPUTS[1], detail_bytes),
374 (OUTPUTS[2], config_bytes),
375 (OUTPUTS[3], adapter_bytes),
376 ];
377 if with_views {
378 let views = views
379 .ok_or_else(|| Done::invalid(command, a.config, "score views need version 3"))?;
380 files.push((VIEWS, bytes(views.record_canonical())?));
381 }
382 write_all(command, host, out_dir, files)?;
383 let digest = |r: Result<rustev_contract::ids::ContentDigest, _>| {
384 r.map(|d| d.to_string()).unwrap_or_default()
385 };
386 Ok(Done::ok(
387 Out::new(command, "reported")
388 .set("report_digest", &digest(evaluated.report.record_digest()))
389 .set("dataset", &evaluated.report.dataset.id)
390 .set("split", rustev_eval::report::split_name(split))
391 .set("config", &evaluated.report.evaluator_config)
392 .set("adapter_digest", &digest(l.adapter.record_digest()))
393 .set("candidate", &l.candidate.is_some())
394 .set("out", out_dir)
395 .raw("metrics", metrics),
396 ))
397 })()
398}
399
400pub fn views(
403 command: &str,
404 host: &dyn Host,
405 a: EvalArgs<'_>,
406 resolver: &dyn Resolver,
407 rules: &mut RulesHook<'_>,
408) -> Res {
409 (|| {
410 host.ensure_absent(a.out)
411 .map_err(|e| Done::io(command, e))?;
412 let l = load(command, host, &a, rules)?;
413 if l.config.declarations().is_none() {
414 return Err(Done::invalid(
415 command,
416 a.config,
417 "score views need a version 3 configuration",
418 ));
419 }
420 let views = evaluate_views(&l.inputs(&a, resolver), l.measured(&[]))
421 .map_err(|e| eval_failed(command, a.dataset, e))?;
422 let bytes = views
423 .record_canonical()
424 .map_err(|e| Done::invalid(command, a.out, e.to_string()))?;
425 host.write_all(&[(a.out.to_string(), bytes)])
426 .map_err(|e| Done::io(command, e))?;
427 let digest = views
428 .record_digest()
429 .map(|d| d.to_string())
430 .unwrap_or_default();
431 Ok(Done::ok(
432 Out::new(command, "viewed")
433 .set("views_digest", &digest)
434 .set("cases", &views.cases.len())
435 .set("out", a.out),
436 ))
437 })()
438}
439
440#[derive(Debug, Clone, Copy)]
442pub struct JudgeArgs<'a> {
443 pub config: &'a str,
444 pub views: &'a str,
445 pub judge_plan: &'a str,
446 pub deps: DepArgs<'a>,
447 pub bundles: &'a str,
448 pub scorer: &'a str,
449 pub replay: &'a ReplayConfig,
450 pub out: &'a str,
451}
452
453pub fn judge(
456 command: &str,
457 host: &dyn Host,
458 a: JudgeArgs<'_>,
459 resolver: &dyn Resolver,
460 rules: &mut RulesHook<'_>,
461) -> Res {
462 (|| {
463 host.ensure_absent(a.out)
464 .map_err(|e| Done::io(command, e))?;
465 let config: EvaluatorConfig = read_doc(command, host, a.config, REPLAY_V1.max_bytes)?;
466 config
467 .check()
468 .map_err(|e| Done::invalid(command, a.config, e.to_string()))?;
469 if config.declarations().is_none() {
470 return Err(Done::invalid(
471 command,
472 a.config,
473 "eval judge needs a version 3 configuration",
474 ));
475 }
476 let Some(descriptor) = config.scorers.iter().flatten().find(|s| s.id == a.scorer) else {
477 return Err(Done::invalid(
478 command,
479 a.config,
480 format!("the configuration declares no scorer {:?}", a.scorer),
481 ));
482 };
483 let views: ScoreViews = read_doc(command, host, a.views, REPLAY_V1.max_bytes)?;
484 let config_id = config
485 .id()
486 .map_err(|e| Done::invalid(command, a.config, e.to_string()))?;
487 if views.config != config_id {
488 return Err(Done::invalid(
489 command,
490 a.views,
491 "the views were computed under another configuration",
492 ));
493 }
494 let deps = deps::load(host, a.deps, rules).map_err(|e| dep_failed(command, e))?;
495 let plan = read_plan(command, host, a.judge_plan)?;
496 let plan = load_plan(command, &plan, &deps)?;
497 let ids = views.cases.iter().map(|e| e.case.as_str());
498 check_ids(command, a.views, ids.clone())?;
499 let bundles = read_bundles_of(command, host, a.bundles, ids)?;
500 let scores = judge_scores(&views, descriptor, &plan.id, &bundles, resolver, a.replay)
501 .map_err(|e| {
502 let input = match e {
503 JudgeError::NotAJudge(_) => a.config,
504 _ => a.bundles,
505 };
506 Done::invalid(command, input, e.to_string())
507 })?;
508 let bytes = scores
509 .record_canonical()
510 .map_err(|e| Done::invalid(command, a.out, e.to_string()))?;
511 host.write_all(&[(a.out.to_string(), bytes)])
512 .map_err(|e| Done::io(command, e))?;
513 let mut outcomes: BTreeMap<&str, u64> = BTreeMap::new();
514 for e in &scores.entries {
515 let key = match &e.outcome {
516 ScoreOutcome::Score(_) => "scored",
517 ScoreOutcome::Unscored(reason) => reason.as_str(),
518 };
519 *outcomes.entry(key).or_default() += 1;
520 }
521 let digest = scores
522 .record_digest()
523 .map(|d| d.to_string())
524 .unwrap_or_default();
525 Ok(Done::ok(
526 Out::new(command, "scored")
527 .set("scores_digest", &digest)
528 .set("scorer", a.scorer)
529 .set("outcomes", &outcomes)
530 .set("out", a.out),
531 ))
532 })()
533}
534
535fn read_side(command: &str, host: &dyn Host, dir: &str) -> Result<(Evaluated, Vec<u8>), Done> {
537 let report: EvalReport = read_doc(
538 command,
539 host,
540 &host.join(dir, OUTPUTS[0]),
541 RECORD_V1.max_bytes,
542 )?;
543 let detail: EvalDetail = read_doc(
544 command,
545 host,
546 &host.join(dir, OUTPUTS[1]),
547 REPLAY_V1.max_bytes,
548 )?;
549 let config: EvaluatorConfig = read_doc(
550 command,
551 host,
552 &host.join(dir, OUTPUTS[2]),
553 REPLAY_V1.max_bytes,
554 )?;
555 let adapter_path = host.join(dir, OUTPUTS[3]);
556 let adapter = host
557 .read(&adapter_path, DESCRIPTOR_V1.max_bytes)
558 .map_err(|e: IoFail| Done::io(command, e))?;
559 TaskAdapterDoc::parse(&adapter)
560 .map_err(|e| Done::invalid(command, &adapter_path, e.to_string()))?;
561 Ok((
562 Evaluated {
563 report,
564 detail,
565 config,
566 },
567 adapter,
568 ))
569}
570
571pub fn gate(command: &str, host: &dyn Host, baseline: &str, candidate: &str, name: &str) -> Res {
574 (|| {
575 let (baseline, badapter) = read_side(command, host, baseline)?;
576 let (candidate, cadapter) = read_side(command, host, candidate)?;
577 let result = if badapter != cadapter {
578 GateResult::Unknown {
579 reason: "the two sides were evaluated with different task adapters".into(),
580 }
581 } else {
582 match candidate.config.gates.iter().find(|g| g.name == name) {
583 None => GateResult::Unknown {
584 reason: format!("the configuration declares no gate {name:?}"),
585 },
586 Some(g) => evaluate_gate(g, &baseline, &candidate),
587 }
588 };
589 let out = Out::new(command, "").set("gate", name);
590 Ok(match result {
591 GateResult::Pass => Done::ok(out.set("status", "pass")),
592 GateResult::Fail { reason } => {
593 Done::new(code::FAIL, out.set("status", "fail").set("reason", &reason))
594 }
595 GateResult::Unknown { reason } => Done::new(
596 code::UNKNOWN,
597 out.set("status", "unknown").set("reason", &reason),
598 ),
599 })
600 })()
601}
602
603pub fn id_of<T: Identified>(doc: &T) -> String
605where
606 T::Id: std::fmt::Display,
607{
608 doc.id().map(|i| i.to_string()).unwrap_or_default()
609}