Skip to main content

rustev_surface/
eval.rs

1//! `eval` and `gate` (spec 006, 3.9), and the dataset and bundle handling
2//! `calibrate fit` shares, for every host.
3
4use 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
36/// A split by its name (spec 006, 3.9).
37pub 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
47/// Read and parse one document of type `T` under `limit`, as
48/// `invalid_input` when it is refused.
49pub 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
59/// Case ids that map to bundle files (spec 006, 3.9.1): 1 to 128 bytes of
60/// `[A-Za-z0-9._-]` starting with an alphanumeric, distinct ignoring ASCII
61/// case.
62pub 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
70/// [`check_case_ids`] over bare ids, as a views document lists them.
71fn 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
102/// The bundles of `cases` from `dir` (spec 015, 3.3): absent files are left
103/// out for the library to judge (`bundle-missing`); a file that exists but
104/// cannot be read, is past the document limit or does not parse is
105/// supplied as that case's load failure, and the command continues. Only
106/// an exhausted read budget stops it, because that depends on read order
107/// rather than on the bundle.
108pub 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
117/// [`read_bundles`] over bare case ids, already checked as file names.
118fn 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
146/// Write every `(name, bytes)` into `dir`, all or none. The bytes move
147/// into the host's list; nothing is copied.
148pub 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/// What `eval` reads and writes.
167#[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
180/// The most `--scores` documents one `eval` reads (spec 041, 3.10.2).
181pub const MAX_SCORES: usize = 16;
182/// The score views `eval` adds to `out` when any scorer is declared.
183pub const VIEWS: &str = "views.json";
184
185/// What `eval` and `eval views` read, checked.
186struct 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    // Spec 041, 3.10.1: a version 3 configuration's measures are declared
245    // by a version 2 adapter.
246    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
287/// An evaluation's refusal: a scores document that does not belong to it
288/// names `--scores` (spec 041, 3.7.5), anything else the dataset.
289fn 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
300/// `eval`: a report, its detail, the configuration and the adapter,
301/// published in `out`.
302pub 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
312/// [`eval`] with the scores documents of a version 3 configuration's
313/// scorers (spec 041, 3.10.2), and its score views added to `out` when any
314/// scorer is declared. With no scores and another version this is [`eval`],
315/// byte for byte.
316pub 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
400/// `eval views` (spec 041, 3.10.3): the score views of the evaluation
401/// `eval` would run over the same inputs, written to the file `out`.
402pub 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/// What `eval judge` reads and writes (spec 041, 3.10.4).
441#[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
453/// `eval judge` (spec 041, 3.10.4): reproduce the judge's bundles for each
454/// view entry and write the judge scorer's `rustev.scores/1` to `out`.
455pub 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
535/// One side of a gate: the four documents `eval` wrote.
536fn 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
571/// `gate`: one declared gate of the candidate's configuration over the two
572/// sides `eval` wrote.
573pub 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
603/// Identity of a document, as text, for output.
604pub 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}