Skip to main content

rustev_eval/
scores.rs

1//! Scorers, `rustev.score-views/1` and `rustev.scores/1` (spec 041, 3.6
2//! and 3.7).
3//!
4//! Scores enter evaluation only as a scores document bound to the views of
5//! the same evaluation (R-1): eval computes the views, a host's scorer
6//! scores each entry outside eval, and evaluation refuses a document over
7//! other views. Eval stays synchronous and I/O-free; a scorer is a Rust
8//! value the host composes in code (D-06).
9
10use std::collections::BTreeMap;
11
12use rustev_contract::Document;
13use rustev_contract::canonical::{CanonicalError, record_canonical_bytes, tagged_digest};
14use rustev_contract::eval_report::Split;
15use rustev_contract::ids::{ContentDigest, DatasetId, EvaluatorConfigId, PlanId};
16use rustev_contract::judgment::{Derivation, Notice, OutValue};
17use rustev_contract::limits::REPLAY_V1;
18use serde::{Deserialize, Serialize};
19
20use crate::dataset::Label;
21use crate::gold::Gold;
22use crate::scoring::{ScorerDescriptor, ScorerOutput};
23
24pub const SCORE_VIEWS: &str = "rustev.score-views/1";
25pub const SCORES: &str = "rustev.scores/1";
26/// The tag of a view entry's subject digest (3.7.1).
27pub const PROPOSAL_VIEW: &str = "rustev.proposal-view/1";
28
29/// Unscored reasons eval reserves (3.6.1, 3.7, 3.8, 3.9).
30pub mod reserved {
31    pub const INVALID_SCORE: &str = "invalid_score";
32    pub const JUDGE_BUNDLE_MISSING: &str = "judge_bundle_missing";
33    pub const JUDGE_NOT_REPRODUCED: &str = "judge_not_reproduced";
34    pub const SUBJECT_MISMATCH: &str = "subject_mismatch";
35    pub const JUDGE_UNRESOLVED: &str = "judge_unresolved";
36    pub const SCORER_FAILED: &str = "scorer_failed";
37    pub const SCORER_OVERLOADED: &str = "scorer_overloaded";
38    pub const SCORER_DEADLINE: &str = "scorer_deadline";
39    pub const ALL: [&str; 8] = [
40        INVALID_SCORE,
41        JUDGE_BUNDLE_MISSING,
42        JUDGE_NOT_REPRODUCED,
43        SUBJECT_MISMATCH,
44        JUDGE_UNRESOLVED,
45        SCORER_FAILED,
46        SCORER_OVERLOADED,
47        SCORER_DEADLINE,
48    ];
49}
50
51/// The views a scorer scores: one entry per comparable proposal case of
52/// one evaluation, in manifest order (3.7.1).
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct ScoreViews {
56    pub schema: String,
57    pub dataset: DatasetId,
58    pub split: Split,
59    pub config: EvaluatorConfigId,
60    pub plan: PlanId,
61    pub cases: Vec<ViewEntry>,
62}
63
64crate::doc::eval_document!(ScoreViews, SCORE_VIEWS, REPLAY_V1);
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct ViewEntry {
69    pub case: String,
70    pub case_view: CaseView,
71    pub proposal_view: ProposalView,
72    /// `tagged_digest("rustev.proposal-view/1", proposal view)`.
73    pub subject: ContentDigest,
74}
75
76/// What a scorer knows of a case. `gold` is null for a scorer that does
77/// not declare `reads_gold` (3.7.4).
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct CaseView {
81    pub id: String,
82    pub label: Label,
83    pub gold: Option<Gold>,
84    pub subgroups: Vec<String>,
85}
86
87/// What a scorer knows of a proposal: the judgment's action, parameters,
88/// derivation and notices.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct ProposalView {
92    pub action: String,
93    pub params: BTreeMap<String, OutValue>,
94    pub derivation: Derivation,
95    pub notices: Vec<Notice>,
96}
97
98impl ProposalView {
99    /// The subject a score binds (3.7.1), over the record canonical bytes,
100    /// since parameters may hold binary64 values.
101    pub fn subject(&self) -> Result<ContentDigest, CanonicalError> {
102        let digest = tagged_digest(PROPOSAL_VIEW, &record_canonical_bytes(self)?);
103        Ok(ContentDigest::parse(&digest).expect("a tagged digest is a content digest"))
104    }
105}
106
107/// One scorer's scores over one views document (3.7.2).
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct Scores {
111    pub schema: String,
112    pub scorer: ScorerDescriptor,
113    /// The views document's record digest.
114    pub views: ContentDigest,
115    pub entries: Vec<ScoreEntry>,
116}
117
118crate::doc::eval_document!(Scores, SCORES, REPLAY_V1);
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct ScoreEntry {
123    pub case: String,
124    pub subject: ContentDigest,
125    pub outcome: ScoreOutcome,
126    /// The judge's run record digest for a model scorer; null otherwise.
127    pub judge_run: Option<ContentDigest>,
128}
129
130/// A score, or why there is none (3.6.1).
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum ScoreOutcome {
134    Score(f64),
135    Unscored(String),
136}
137
138/// A scorer the host composes in code (3.6, D-06).
139pub trait Scorer: Send + Sync {
140    fn descriptor(&self) -> &ScorerDescriptor;
141    fn score(&self, case: &CaseView, proposal: &ProposalView) -> ScoreOutcome;
142}
143
144/// 1 to 64 bytes of `[a-z0-9_]` (3.6.1).
145pub fn reason_ok(r: &str) -> bool {
146    (1..=64).contains(&r.len())
147        && r.bytes()
148            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
149}
150
151/// Whether a score lies inside the scorer's output (3.7.5): a level index
152/// of an ordinal scale, or a finite value in a bounded range. Never
153/// clamped.
154pub fn score_ok(output: &ScorerOutput, value: f64) -> bool {
155    match output {
156        ScorerOutput::Ordinal { levels } => {
157            value.is_finite() && value.fract() == 0.0 && value >= 0.0 && value < levels.len() as f64
158        }
159        ScorerOutput::Bounded { min, max } => {
160            value.is_finite() && value >= min.to_f64_nearest() && value <= max.to_f64_nearest()
161        }
162    }
163}
164
165/// The case view a scorer is handed (3.7.4): the entry's, with `gold`
166/// null unless the scorer declares `reads_gold`.
167pub fn scorer_case(d: &ScorerDescriptor, case: &CaseView) -> CaseView {
168    CaseView {
169        gold: if d.reads_gold {
170            case.gold.clone()
171        } else {
172            None
173        },
174        ..case.clone()
175    }
176}
177
178/// A scorer's answer as it is recorded (3.7.5): a score outside the
179/// output, or an unscored reason that is malformed or reserved for eval,
180/// becomes unscored `invalid_score`, never clamped or passed through.
181pub fn checked(d: &ScorerDescriptor, outcome: ScoreOutcome) -> ScoreOutcome {
182    match outcome {
183        ScoreOutcome::Score(v) if score_ok(&d.output, v) => ScoreOutcome::Score(v),
184        ScoreOutcome::Unscored(r) if reason_ok(&r) && !reserved::ALL.contains(&r.as_str()) => {
185            ScoreOutcome::Unscored(r)
186        }
187        _ => ScoreOutcome::Unscored(reserved::INVALID_SCORE.into()),
188    }
189}
190
191impl ScoreEntry {
192    /// The entry recording `outcome` for one view entry, with no judge run.
193    pub fn of(entry: &ViewEntry, outcome: ScoreOutcome) -> ScoreEntry {
194        ScoreEntry {
195            case: entry.case.clone(),
196            subject: entry.subject.clone(),
197            outcome,
198            judge_run: None,
199        }
200    }
201}
202
203impl Scores {
204    /// One scorer's document over `views`, its entries as given (3.7.2).
205    /// Evaluation checks them against the views it computes, so entries
206    /// that do not match the views are refused there, never read.
207    pub fn over(
208        d: &ScorerDescriptor,
209        views: &ScoreViews,
210        entries: Vec<ScoreEntry>,
211    ) -> Result<Scores, CanonicalError> {
212        Ok(Scores {
213            schema: SCORES.into(),
214            scorer: d.clone(),
215            views: views.record_digest()?,
216            entries,
217        })
218    }
219}
220
221/// Calls the scorer once per entry, in order, synchronously (3.7.3), with
222/// the case view of [`scorer_case`], and records each answer as
223/// [`checked`] states.
224pub fn score_views(scorer: &dyn Scorer, views: &ScoreViews) -> Result<Scores, CanonicalError> {
225    let d = scorer.descriptor();
226    let entries = views
227        .cases
228        .iter()
229        .map(|e| {
230            let outcome = scorer.score(&scorer_case(d, &e.case_view), &e.proposal_view);
231            ScoreEntry::of(e, checked(d, outcome))
232        })
233        .collect();
234    Scores::over(d, views, entries)
235}