1use std::collections::BTreeMap;
12
13use rustev_contract::eval_report::{Metric, MetricValue};
14use rustev_contract::judgment::OutValue;
15
16use crate::dataset::DatasetCase;
17use crate::eval_cases::CaseMeasure;
18use crate::gold::{Gold, GoldValue, Match, match_value};
19use crate::measures::{
20 Against, ConstraintAggregate, ConstraintDecl, ConstraintTest, MeasuredAdapter, Measures,
21 RankingDecl,
22};
23use crate::ranking_metrics::{
24 RankedList, RankingMetric, Relevance, backfill_case, ranked_list, ranking_case, reason,
25 short_case,
26};
27use crate::report::Count;
28use crate::scores::{ScoreOutcome, Scores, reserved, score_ok};
29use crate::scoring::{
30 Declarations, HUMAN_SIDE, MODEL_PREFIX, RubricAggregate, ScorerKind, ScorerOutput, code,
31};
32
33#[derive(Clone, Copy)]
37pub struct Measured<'a> {
38 pub adapter: &'a dyn MeasuredAdapter,
39 pub scores: &'a [Scores],
41}
42
43pub mod constraint_reason {
45 pub const UNKNOWN_ATTRIBUTE: &str = "unknown_attribute";
46 pub const NO_REFERENCE: &str = "no_reference";
47 pub const TYPE_MISMATCH: &str = "type_mismatch";
48 pub const NO_CHECKED_ITEMS: &str = "no_checked_items";
49 pub const EMPTY_RANKING: &str = "empty_ranking";
50}
51
52pub enum Scope<'a> {
54 Out(String),
57 Proposal {
58 case: &'a DatasetCase,
59 params: &'a BTreeMap<String, OutValue>,
60 },
61}
62
63pub struct NewMetric {
66 pub metric: Metric,
67 pub count: Count,
68 pub cases: Vec<Option<CaseMeasure>>,
69}
70
71pub(crate) struct Builder {
72 name: String,
73 mean: bool,
75 cases: Vec<Option<CaseMeasure>>,
76 breakdown: BTreeMap<String, u64>,
77}
78
79impl Builder {
80 pub(crate) fn new(name: String, mean: bool, n: usize) -> Builder {
81 Builder {
82 name,
83 mean,
84 cases: Vec::with_capacity(n),
85 breakdown: BTreeMap::new(),
86 }
87 }
88
89 pub(crate) fn count(&mut self, reason: &str, by: u64) {
90 if by > 0 {
91 *self.breakdown.entry(reason.into()).or_default() += by;
92 }
93 }
94
95 pub(crate) fn push(&mut self, outcome: Result<CaseMeasure, &str>) {
96 match outcome {
97 Ok(m) => self.cases.push(Some(m)),
98 Err(r) => {
99 self.count(r, 1);
100 self.cases.push(None);
101 }
102 }
103 }
104
105 pub(crate) fn finish(self, total: u64) -> NewMetric {
109 let (mut cnum, mut cden, mut mnum, mut mden) = (0u64, 0u64, 0.0f64, 0u64);
110 for m in self.cases.iter().flatten() {
111 match m {
112 CaseMeasure::Count { num, den } => {
113 cnum += num;
114 cden += den;
115 }
116 CaseMeasure::Mean { num, den } => {
117 mnum += num;
118 mden += den;
119 }
120 CaseMeasure::Sample { .. } | CaseMeasure::Unknown { .. } => {}
121 }
122 }
123 let (value, numerator, denominator) = if self.mean {
126 let measured = self.cases.iter().flatten().count() as u64;
127 (ratio(mnum, mden), measured, total)
128 } else {
129 (ratio(cnum as f64, cden), cnum, cden)
130 };
131 let reasons = if self.breakdown.is_empty() {
132 "no case in the denominator".to_string()
133 } else {
134 self.breakdown
135 .iter()
136 .map(|(k, v)| format!("{k}: {v}"))
137 .collect::<Vec<_>>()
138 .join(", ")
139 };
140 let value = value.unwrap_or_else(|| MetricValue::Unknown {
141 reason: format!("zero denominator; excluded {reasons}"),
142 });
143 NewMetric {
144 metric: Metric {
145 name: self.name.clone(),
146 value,
147 },
148 count: Count {
149 metric: self.name,
150 numerator,
151 denominator,
152 breakdown: self.breakdown,
153 },
154 cases: self.cases,
155 }
156 }
157}
158
159fn ratio(num: f64, den: u64) -> Option<MetricValue> {
160 if den == 0 {
161 return None;
162 }
163 let value = num / den as f64;
164 Some(if value.is_finite() {
165 MetricValue::Measured { value, n: den }
166 } else {
167 MetricValue::Unknown {
168 reason: "non-finite value".into(),
169 }
170 })
171}
172
173pub fn metrics(measures: &Measures, scope: &[Scope<'_>]) -> Vec<NewMetric> {
177 let total = scope.len() as u64;
178 let lists: Vec<Vec<Option<Result<RankedList, &'static str>>>> = measures
179 .rankings
180 .iter()
181 .map(|r| {
182 scope
183 .iter()
184 .map(|s| match s {
185 Scope::Out(_) => None,
186 Scope::Proposal { params, .. } => Some(ranked_list(params.get(&r.param))),
187 })
188 .collect()
189 })
190 .collect();
191 let mut out = vec![];
192 for (r, lists) in measures.rankings.iter().zip(&lists) {
193 out.extend(ranking_metrics(r, scope, lists, total));
194 }
195 for c in &measures.constraints {
196 let i = measures
197 .rankings
198 .iter()
199 .position(|r| r.name == c.ranking)
200 .expect("a checked constraint names a declared ranking");
201 out.push(constraint_metric(c, scope, &lists[i], total));
202 }
203 out
204}
205
206fn ranking_metrics(
207 r: &RankingDecl,
208 scope: &[Scope<'_>],
209 lists: &[Option<Result<RankedList, &'static str>>],
210 total: u64,
211) -> Vec<NewMetric> {
212 let rel = Relevance {
213 relevant_at_least: r.relevant_at_least,
214 gain: r.gain,
215 };
216 let n = scope.len();
217 let mut out = vec![];
218 for m in &r.metrics {
219 let mean = !matches!(m, RankingMetric::Hit(_));
220 let mut b = Builder::new(format!("ranking/{}/{}", r.name, m.name()), mean, n);
221 for (s, list) in scope.iter().zip(lists) {
222 match (s, list) {
223 (Scope::Out(why), _) => b.push(Err(why)),
224 (_, Some(Err(e))) => b.push(Err(e)),
225 (Scope::Proposal { case, .. }, Some(Ok(list))) => {
226 let gold = case.gold.as_ref().and_then(|g| g.relevance.as_ref());
227 let c = ranking_case(*m, list, gold, rel);
228 b.count(reason::UNJUDGED_ITEMS, c.unjudged_items);
229 b.push(c.outcome);
230 }
231 (Scope::Proposal { .. }, None) => unreachable!("a proposal has a list result"),
232 }
233 }
234 out.push(b.finish(total));
235 }
236 let mut ks: Vec<u64> = vec![];
237 for k in r.metrics.iter().filter_map(|m| m.k()) {
238 if !ks.contains(&k) {
239 ks.push(k);
240 }
241 }
242 for k in ks {
243 let mut b = Builder::new(format!("ranking/{}/short@{k}", r.name), false, n);
244 for (s, list) in scope.iter().zip(lists) {
245 match (s, list) {
246 (Scope::Out(why), _) => b.push(Err(why)),
247 (_, Some(Err(e))) => b.push(Err(e)),
248 (_, Some(Ok(list))) => b.push(Ok(short_case(list, k))),
249 (_, None) => unreachable!("a proposal has a list result"),
250 }
251 }
252 out.push(b.finish(total));
253 }
254 for p in 1..=r.backfill_positions {
255 let mut b = Builder::new(format!("ranking/{}/backfill@p{p}", r.name), false, n);
256 for (s, list) in scope.iter().zip(lists) {
257 match (s, list) {
258 (Scope::Out(why), _) => b.push(Err(why)),
259 (_, Some(Err(e))) => b.push(Err(e)),
260 (_, Some(Ok(list))) => b.push(backfill_case(list, p)),
261 (_, None) => unreachable!("a proposal has a list result"),
262 }
263 }
264 out.push(b.finish(total));
265 }
266 out
267}
268
269enum Reference<'a> {
271 Gold(&'a GoldValue),
272 Param(&'a OutValue),
273}
274
275fn path_of<'a>(r: &'a Reference<'_>) -> Option<Vec<&'a str>> {
278 match r {
279 Reference::Gold(GoldValue::Path(p)) => Some(p.iter().map(String::as_str).collect()),
280 Reference::Param(OutValue::List(v)) => v
281 .iter()
282 .map(|e| match e {
283 OutValue::Text(t) => Some(t.as_str()),
284 _ => None,
285 })
286 .collect(),
287 _ => None,
288 }
289}
290
291fn test_item(
294 test: ConstraintTest,
295 attr: &GoldValue,
296 reference: &Reference<'_>,
297) -> Result<bool, &'static str> {
298 use constraint_reason::TYPE_MISMATCH;
299 match test {
300 ConstraintTest::Equals => match reference {
301 Reference::Param(v) => match match_value(v, attr) {
302 Match::Equal => Ok(true),
303 Match::Unequal => Ok(false),
304 Match::TypeMismatch => Err(TYPE_MISMATCH),
305 },
306 Reference::Gold(g) => {
307 if std::mem::discriminant(*g) == std::mem::discriminant(attr) {
308 Ok(*g == attr)
309 } else {
310 Err(TYPE_MISMATCH)
311 }
312 }
313 },
314 ConstraintTest::MemberOf => match (attr, path_of(reference)) {
315 (GoldValue::Text(a), Some(allowed)) => Ok(allowed.contains(&a.as_str())),
316 _ => Err(TYPE_MISMATCH),
317 },
318 ConstraintTest::PathPrefix(level) => {
319 let attr = Reference::Gold(attr);
320 match (path_of(&attr), path_of(reference)) {
321 (Some(a), Some(b)) => {
322 let l = usize::from(level);
323 Ok(a.len() >= l && b.len() >= l && a[..l] == b[..l])
325 }
326 _ => Err(TYPE_MISMATCH),
327 }
328 }
329 }
330}
331
332const UNKNOWN_ORDER: [&str; 3] = [
335 constraint_reason::NO_REFERENCE,
336 constraint_reason::TYPE_MISMATCH,
337 constraint_reason::UNKNOWN_ATTRIBUTE,
338];
339
340fn constraint_metric(
341 c: &ConstraintDecl,
342 scope: &[Scope<'_>],
343 lists: &[Option<Result<RankedList, &'static str>>],
344 total: u64,
345) -> NewMetric {
346 use constraint_reason::*;
347 let mean = c.aggregate == ConstraintAggregate::ShareAtK;
348 let mut b = Builder::new(format!("constraint/{}", c.name), mean, scope.len());
349 for (s, list) in scope.iter().zip(lists) {
350 let (case, params, list) = match (s, list) {
351 (Scope::Out(why), _) => {
352 b.push(Err(why));
353 continue;
354 }
355 (_, Some(Err(e))) => {
356 b.push(Err(e));
357 continue;
358 }
359 (Scope::Proposal { case, params }, Some(Ok(list))) => (*case, *params, list),
360 (Scope::Proposal { .. }, None) => unreachable!("a proposal has a list result"),
361 };
362 let Some((gold, items)) = case
363 .gold
364 .as_ref()
365 .and_then(|g: &Gold| Some((g, g.items.as_ref()?)))
366 else {
367 b.push(Err(reason::NO_GOLD));
368 continue;
369 };
370 let reference = match &c.against {
371 Against::Requested(n) => match &gold.requested {
372 None => {
373 b.push(Err(reason::NO_GOLD));
374 continue;
375 }
376 Some(r) => r.get(n).map(Reference::Gold),
377 },
378 Against::Param(n) => params.get(n).map(Reference::Param),
379 Against::Value(v) => Some(Reference::Gold(v)),
380 };
381 let k = usize::try_from(c.k).unwrap_or(usize::MAX);
382 let top = &list.entries[..list.entries.len().min(k)];
383 let (mut passed, mut failed) = (0u64, 0u64);
384 let mut unknown: BTreeMap<&str, u64> = BTreeMap::new();
385 for item in top {
386 let attr = items.get(&item.id).and_then(|a| a.get(&c.attribute));
387 let outcome = match (attr, &reference) {
388 (_, None) => Err(NO_REFERENCE),
389 (None, _) => Err(UNKNOWN_ATTRIBUTE),
390 (Some(a), Some(r)) => test_item(c.test, a, r),
391 };
392 match outcome {
393 Ok(true) => passed += 1,
394 Ok(false) => failed += 1,
395 Err(r) => *unknown.entry(r).or_default() += 1,
396 }
397 }
398 match c.aggregate {
399 ConstraintAggregate::ShareAtK => {
400 for (r, n) in &unknown {
402 b.count(r, *n);
403 }
404 let checked = passed + failed;
405 if checked == 0 {
406 b.push(Err(NO_CHECKED_ITEMS));
407 } else {
408 b.push(Ok(CaseMeasure::Mean {
409 num: passed as f64 / checked as f64,
410 den: 1,
411 }));
412 }
413 }
414 ConstraintAggregate::AllAtK => {
415 if top.is_empty() {
416 b.push(Err(EMPTY_RANKING));
417 } else if failed > 0 {
418 b.push(Ok(CaseMeasure::Count { num: 0, den: 1 }));
419 } else if let Some(r) = UNKNOWN_ORDER.iter().find(|r| unknown.contains_key(*r)) {
420 b.push(Err(r));
421 } else {
422 b.push(Ok(CaseMeasure::Count { num: 1, den: 1 }));
423 }
424 }
425 }
426 }
427 b.finish(total)
428}
429
430pub const SCORES_MISSING: &str = "scores-missing";
432
433pub fn rubric_metrics(
441 declared: &Declarations<'_>,
442 scores: &[Scores],
443 scope: &[Scope<'_>],
444) -> (Vec<NewMetric>, Vec<NewMetric>) {
445 let total = scope.len() as u64;
446 let (mut deterministic, mut model) = (vec![], vec![]);
447 for r in declared.rubric_metrics {
448 let Some(s) = declared.scorers.iter().find(|s| s.id == r.scorer) else {
449 continue;
450 };
451 let is_model = s.kind == ScorerKind::Model;
452 let name = format!(
453 "{}rubric/{}",
454 if is_model { MODEL_PREFIX } else { "" },
455 r.name
456 );
457 let mean = !matches!(r.aggregate, RubricAggregate::ShareAtLeast(_));
458 let mut b = Builder::new(name, mean, scope.len());
459 let doc = scores.iter().find(|d| d.scorer.id == s.id);
460 let by_case: Option<BTreeMap<&str, &ScoreOutcome>> = doc.map(|d| {
461 d.entries
462 .iter()
463 .map(|e| (e.case.as_str(), &e.outcome))
464 .collect()
465 });
466 let at_least = match (&r.aggregate, &s.output) {
467 (RubricAggregate::ShareAtLeast(l), ScorerOutput::Ordinal { levels }) => {
468 levels.iter().position(|x| x == l)
469 }
470 _ => None,
471 };
472 for sc in scope {
473 let case = match sc {
474 Scope::Out(why) => {
475 b.push(Err(why));
476 continue;
477 }
478 Scope::Proposal { case, .. } => case,
479 };
480 let Some(by_case) = &by_case else {
481 b.push(Err(SCORES_MISSING));
482 continue;
483 };
484 let outcome = by_case.get(case.id.as_str());
485 let value = match outcome {
486 Some(ScoreOutcome::Score(v)) if score_ok(&s.output, *v) => *v,
487 Some(ScoreOutcome::Score(_)) => {
488 b.push(Err(&format!("unscored:{}", reserved::INVALID_SCORE)));
489 continue;
490 }
491 Some(ScoreOutcome::Unscored(why)) => {
492 b.push(Err(&format!("unscored:{why}")));
493 continue;
494 }
495 None => unreachable!("checked scores cover every view entry"),
496 };
497 b.push(match (&r.aggregate, at_least) {
498 (RubricAggregate::ShareAtLeast(_), Some(l)) => Ok(CaseMeasure::Count {
499 num: u64::from(value >= l as f64),
500 den: 1,
501 }),
502 (RubricAggregate::ShareAtLeast(_), None) => Err(code::SCALE_MISMATCH),
505 (RubricAggregate::MeanLevelIndex | RubricAggregate::Mean, _) => {
506 Ok(CaseMeasure::Mean { num: value, den: 1 })
507 }
508 });
509 }
510 let m = b.finish(total);
511 if is_model {
512 model.push(m);
513 } else {
514 deterministic.push(m);
515 }
516 }
517 (deterministic, model)
518}
519
520const AGREEMENT_STATS: [&str; 4] = ["exact", "adjacent", "mean_abs_diff", "qwk"];
522
523fn side_level(
525 side: &str,
526 declared: &Declarations<'_>,
527 scores: &[Scores],
528 case: &DatasetCase,
529) -> Result<usize, String> {
530 if let Some(scale) = side.strip_prefix(HUMAN_SIDE) {
531 let levels = &declared
532 .human_scales
533 .iter()
534 .find(|h| h.name == scale)
535 .expect("a checked agreement names a declared scale")
536 .levels;
537 let level = case
538 .gold
539 .as_ref()
540 .and_then(|g| g.rubric.as_ref()?.get(scale))
541 .ok_or_else(|| reason::NO_GOLD.to_string())?;
542 return levels
544 .iter()
545 .position(|l| l == level)
546 .ok_or_else(|| reason::NO_GOLD.to_string());
547 }
548 let s = declared
549 .scorers
550 .iter()
551 .find(|s| s.id == side)
552 .expect("a checked agreement names a declared scorer");
553 let doc = scores
554 .iter()
555 .find(|d| d.scorer.id == side)
556 .ok_or_else(|| SCORES_MISSING.to_string())?;
557 match doc
558 .entries
559 .iter()
560 .find(|e| e.case == case.id)
561 .map(|e| &e.outcome)
562 {
563 Some(ScoreOutcome::Score(v)) if score_ok(&s.output, *v) => Ok(*v as usize),
564 Some(ScoreOutcome::Score(_)) => Err(format!("unscored:{}", reserved::INVALID_SCORE)),
565 Some(ScoreOutcome::Unscored(why)) => Err(format!("unscored:{why}")),
566 None => unreachable!("checked scores cover every view entry"),
567 }
568}
569
570fn side_levels<'d>(side: &str, declared: &Declarations<'d>) -> Option<&'d [String]> {
573 if let Some(scale) = side.strip_prefix(HUMAN_SIDE) {
574 return declared
575 .human_scales
576 .iter()
577 .find(|h| h.name == scale)
578 .map(|h| h.levels.as_slice());
579 }
580 match &declared.scorers.iter().find(|s| s.id == side)?.output {
581 ScorerOutput::Ordinal { levels } => Some(levels.as_slice()),
582 ScorerOutput::Bounded { .. } => None,
583 }
584}
585
586fn model_side(side: &str, declared: &Declarations<'_>) -> bool {
588 declared
589 .scorers
590 .iter()
591 .any(|s| s.id == side && s.kind == ScorerKind::Model)
592}
593
594pub fn agreement_metrics(
601 declared: &Declarations<'_>,
602 scores: &[Scores],
603 scope: &[Scope<'_>],
604) -> (Vec<NewMetric>, Vec<NewMetric>) {
605 let total = scope.len() as u64;
606 let (mut deterministic, mut model) = (vec![], vec![]);
607 for a in declared.agreements {
608 let is_model = model_side(&a.a, declared) || model_side(&a.b, declared);
609 let prefix = if is_model { MODEL_PREFIX } else { "" };
610 let levels = match (side_levels(&a.a, declared), side_levels(&a.b, declared)) {
613 (Some(x), Some(y)) if x == y => Some(x.len()),
614 _ => None,
615 };
616 let mut builders: Vec<Builder> = AGREEMENT_STATS
617 .iter()
618 .map(|stat| {
619 Builder::new(
620 format!("{prefix}agreement/{}/{stat}", a.name),
621 *stat != "exact" && *stat != "adjacent",
622 scope.len(),
623 )
624 })
625 .collect();
626 let size = levels.unwrap_or(0);
628 let mut observed = vec![vec![0u64; size]; size];
629 for sc in scope {
630 let pair = match (sc, levels) {
631 (Scope::Out(why), _) => Err(why.clone()),
632 (Scope::Proposal { .. }, None) => Err(code::SCALE_MISMATCH.to_string()),
633 (Scope::Proposal { case, .. }, Some(_)) => side_level(&a.a, declared, scores, case)
634 .and_then(|x| Ok((x, side_level(&a.b, declared, scores, case)?))),
635 };
636 match pair {
637 Err(why) => {
638 for b in &mut builders {
639 b.push(Err(&why));
640 }
641 }
642 Ok((x, y)) => {
643 observed[x][y] += 1;
644 let d = x.abs_diff(y) as u64;
645 builders[0].push(Ok(CaseMeasure::Count {
646 num: u64::from(d == 0),
647 den: 1,
648 }));
649 builders[1].push(Ok(CaseMeasure::Count {
650 num: u64::from(d <= 1),
651 den: 1,
652 }));
653 builders[2].push(Ok(CaseMeasure::Mean {
654 num: d as f64,
655 den: 1,
656 }));
657 builders[3].push(Ok(CaseMeasure::Mean { num: 0.0, den: 1 }));
660 }
661 }
662 }
663 let mut out: Vec<NewMetric> = builders.into_iter().map(|b| b.finish(total)).collect();
664 let qwk = &mut out[3];
665 if matches!(qwk.metric.value, MetricValue::Measured { .. }) {
669 qwk.metric.value = quadratic_weighted_kappa(&observed);
670 }
671 qwk.cases.iter_mut().for_each(|c| *c = None);
672 if is_model {
673 model.extend(out);
674 } else {
675 deterministic.extend(out);
676 }
677 }
678 (deterministic, model)
679}
680
681pub fn quadratic_weighted_kappa(observed: &[Vec<u64>]) -> MetricValue {
686 let k = observed.len();
687 let n: u64 = observed.iter().flatten().sum();
688 let rows: Vec<u64> = observed.iter().map(|r| r.iter().sum()).collect();
689 let cols: Vec<u64> = (0..k)
690 .map(|j| observed.iter().map(|r| r[j]).sum())
691 .collect();
692 if n == 0 {
693 return MetricValue::Unknown {
694 reason: "zero denominator: no case scored on both sides".into(),
695 };
696 }
697 let (mut num, mut den) = (0.0f64, 0.0f64);
698 for i in 0..k {
699 for j in 0..k {
700 let w = (i.abs_diff(j) * i.abs_diff(j)) as f64;
701 num += w * observed[i][j] as f64;
702 let product = u128::from(rows[i]) * u128::from(cols[j]);
704 den += w * (product as f64 / n as f64);
705 }
706 }
707 if den == 0.0 {
708 return MetricValue::Unknown {
709 reason: "zero denominator: sum(w * E) is 0".into(),
710 };
711 }
712 MetricValue::Measured {
713 value: 1.0 - num / den,
714 n,
715 }
716}