1use 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";
26pub const PROPOSAL_VIEW: &str = "rustev.proposal-view/1";
28
29pub 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#[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 pub subject: ContentDigest,
74}
75
76#[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#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct Scores {
111 pub schema: String,
112 pub scorer: ScorerDescriptor,
113 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 pub judge_run: Option<ContentDigest>,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum ScoreOutcome {
134 Score(f64),
135 Unscored(String),
136}
137
138pub trait Scorer: Send + Sync {
140 fn descriptor(&self) -> &ScorerDescriptor;
141 fn score(&self, case: &CaseView, proposal: &ProposalView) -> ScoreOutcome;
142}
143
144pub 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
151pub 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
165pub fn score_views(scorer: &dyn Scorer, views: &ScoreViews) -> Result<Scores, CanonicalError> {
171 let d = scorer.descriptor();
172 let entries = views
173 .cases
174 .iter()
175 .map(|e| {
176 let withheld;
177 let case = if d.reads_gold {
178 &e.case_view
179 } else {
180 withheld = CaseView {
181 gold: None,
182 ..e.case_view.clone()
183 };
184 &withheld
185 };
186 let outcome = match scorer.score(case, &e.proposal_view) {
187 ScoreOutcome::Score(v) if score_ok(&d.output, v) => ScoreOutcome::Score(v),
188 ScoreOutcome::Unscored(r)
189 if reason_ok(&r) && !reserved::ALL.contains(&r.as_str()) =>
190 {
191 ScoreOutcome::Unscored(r)
192 }
193 _ => ScoreOutcome::Unscored(reserved::INVALID_SCORE.into()),
194 };
195 ScoreEntry {
196 case: e.case.clone(),
197 subject: e.subject.clone(),
198 outcome,
199 judge_run: None,
200 }
201 })
202 .collect();
203 Ok(Scores {
204 schema: SCORES.into(),
205 scorer: d.clone(),
206 views: views.record_digest()?,
207 entries,
208 })
209}