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 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
178pub 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 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 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
221pub 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}