Skip to main content

rustev_eval/
measured.rs

1//! Evaluation under `rustev.evaluator-config/3` (spec 041, 3.2 and 3.4):
2//! ranking, `short@k`, `backfill@p<p>` and constraint metrics over the
3//! comparable proposal cases of a split, with their detail counts and the
4//! per-case measures spec 029 reconciles (3.11).
5//!
6//! Every metric is computed over the split's cases in manifest order. An
7//! incomparable, diverged or abstained case is outside every metric and
8//! counted by the reason the coverage counts use. A case a metric cannot
9//! measure is excluded and counted by reason, never scored 0 (I-2).
10
11use 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/// Version 3 evaluation inputs beside spec 004's `Inputs` (3.4.1): the
34/// adapter that declares the measures whose digest the configuration
35/// binds.
36#[derive(Clone, Copy)]
37pub struct Measured<'a> {
38    pub adapter: &'a dyn MeasuredAdapter,
39    /// One scores document per declared scorer that has one (3.7.5).
40    pub scores: &'a [Scores],
41}
42
43/// Constraint exclusion reasons (3.4.6).
44pub 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
52/// One case of the split as the version 3 metrics see it.
53pub enum Scope<'a> {
54    /// Outside every new metric, with the reason coverage or acceptance
55    /// counts it by (`incomparable:<code>`, `diverged`, `abstained`).
56    Out(String),
57    Proposal {
58        case: &'a DatasetCase,
59        params: &'a BTreeMap<String, OutValue>,
60    },
61}
62
63/// One new metric: its report entry, its detail count, and each case's
64/// measure (none outside its denominator), in manifest order.
65pub 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    /// A mean metric (3.11.1); otherwise a count ratio.
74    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    /// The report value from the case measures, by the functions spec 029
106    /// reconciles with (3.11.2): integers exactly, binary64 left to right,
107    /// one division last.
108    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        // A count ratio's count is its sums (spec 029 reconciles them); a
124        // mean's is the measured cases of the split's cases.
125        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
173/// The version 3 deterministic metrics of 3.4, in declaration order: per
174/// ranking its metric specs, then `short@k` per declared k, then
175/// `backfill@p<p>`; then every constraint.
176pub 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
269/// A reference value: a gold value, or a proposal parameter.
270enum Reference<'a> {
271    Gold(&'a GoldValue),
272    Param(&'a OutValue),
273}
274
275/// The path a reference or attribute holds, when it holds one: a gold path,
276/// or a proposal `List` of `Text` (3.3.3).
277fn 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
291/// One item's test (3.4.5, 3.4.6): satisfied, failed, or unknown with its
292/// reason.
293fn 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                    // A path shorter than the level does not match.
324                    Ok(a.len() >= l && b.len() >= l && a[..l] == b[..l])
325                }
326                _ => Err(TYPE_MISMATCH),
327            }
328        }
329    }
330}
331
332/// The order in which an `all_at_k` case's unknown items name its
333/// exclusion (3.4.6).
334const 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                // Unknown items are excluded and counted, as items.
401                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
430/// Why a rubric metric has no value for its scorer (3.7.5).
431pub const SCORES_MISSING: &str = "scores-missing";
432
433/// The rubric metrics of 3.6.3 over checked scores documents: the
434/// deterministic ones (`rubric/<name>`) and the model ones
435/// (`model/rubric/<name>`), each in declaration order, reported apart
436/// (3.2.4, I-3). A declared scorer with no document makes its metrics
437/// unknown (`scores-missing`); an unscored case is excluded and counted
438/// `unscored:<reason>`; a score outside the scorer's output is unscored
439/// `invalid_score`, never clamped.
440pub 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                // A level the scorer does not declare: the configuration
503                // check refuses it (3.6.3); never read as a mean.
504                (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
520/// The four agreement statistics of 3.8.4.
521const AGREEMENT_STATS: [&str; 4] = ["exact", "adjacent", "mean_abs_diff", "qwk"];
522
523/// One agreement side's level index for a case, or why it has none.
524fn 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        // Checked against the scale before any case (3.6.4).
543        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
570/// A side's level list: a declared human scale's, or a declared ordinal
571/// scorer's; none for anything else.
572fn 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
586/// Whether a side is a model scorer.
587fn 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
594/// The agreement metrics of 3.8.4 over cases scored on both sides:
595/// `exact` and `adjacent` (count ratios), `mean_abs_diff` (a mean) and
596/// `qwk` (not case-decomposable: no per-case measure). Deterministic
597/// agreements first, then those with a model side, named under `model/`.
598/// A side with no scores document makes every statistic unknown
599/// (`scores-missing`).
600pub 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        // Both sides on one level list (3.8.4); the configuration check
611        // refuses any other pairing, and an unchecked one is never indexed.
612        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        // Observed pair counts, rows side a, columns side b.
627        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                    // `qwk` is not case-decomposable; its case contributes
658                    // to the observed matrix only.
659                    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        // With no case scored on both sides, keep the exclusion reasons
666        // (`scores-missing`, `scale-mismatch`, ...) the other statistics
667        // report.
668        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
681/// Quadratic weighted kappa (3.8.4): `1 - sum(w * O) / sum(w * E)`, with
682/// `w = (i - j)^2`, `E` the outer product of the marginal counts divided
683/// by the case count, sums in row-major order; unknown when
684/// `sum(w * E) = 0`.
685pub 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            // The marginal product is exact in u128 for any u64 counts.
703            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}