Skip to main content

jev_bridge/
evaluate.rs

1//! Offline calibration evaluation for `--score` predictions.
2//!
3//! `--score` already writes one JSONL line per row with the full probability
4//! vector. This module turns those files (plus a gold file) into the numbers
5//! that say whether the probabilities are usable — not only whether the answers
6//! are right: accuracy, balanced accuracy, NLL, Brier (both flavours), ECE and
7//! the reliability curve.
8//!
9//! The bridge itself performs no calibration and recommends no thresholds; this
10//! module only *measures*. A deployment that wants to act on the probabilities
11//! can validate them on its own workload first.
12//!
13//! # Definitions (frozen)
14//!
15//! * **predicted slot** = `argmax` of the probability vector, ties broken by
16//!   the first slot in the row's declared option order (the same first-max rule
17//!   the wire layer reports its answers with). Rows without a vector fall back
18//!   to their `label`.
19//! * **accuracy** = correct / predicted rows.
20//! * **balanced accuracy** = mean of per-class recall over the classes that
21//!   occur as gold (macro-recall; classes with no gold row are excluded).
22//! * **NLL** = mean of `-ln(max(p_gold, 1e-12))` over scored rows.
23//! * **brier_multiclass** = mean of `Σ_k (p_k - 1[gold == slot_k])²` over
24//!   scored rows; defined for any number of slots.
25//! * **brier_binary** = mean of `(p_positive - 1[gold == positive])²` over the
26//!   scored rows that declare a positive class. `n_binary` says how many rows
27//!   that was. A two-slot `true`/`false` row defaults `positive` to `true`
28//!   (the `noul` contract), so the binarisation is never arbitrary.
29//! * **confidence** = `max(p)`, the probability mass behind the predicted slot.
30//! * **ECE** = `Σ_b (n_b / N) · |acc_b − conf_b|` over `bins` equal-width bins
31//!   in `[0,1]`; bin index = `min(bins-1, floor(conf · bins))`, so `conf == 1.0`
32//!   lands in the last bin rather than overflowing. `N` is the number of scored
33//!   rows.
34//! * **reliability curve** = the per-bin `(lower, upper, n, mean_confidence,
35//!   accuracy)` tuples ECE is summed from. Empty bins are omitted.
36//!
37//! Rows that carry no probability vector are counted in `n_items`/`n_missing`
38//! and contribute to accuracy, but they are excluded from NLL / Brier / ECE —
39//! `n_scored` names exactly how many rows did contribute, so coverage is never
40//! hidden.
41//!
42//! Every metric is computed per stratum (the gold file's `family`, with rows
43//! that declare none reported under `unlabelled`), and the report also carries
44//! the `overall` stratum. Strata are never silently pooled into one number by
45//! this module; a caller that wants an aggregate has to say so itself.
46//!
47//! # Verification
48//!
49//! [`verify`] recomputes a written report from the same inputs and compares it
50//! field by field: the report is a claim, the prediction and gold files are the
51//! evidence. The tolerance belongs to the caller (`--tol`) and is never read
52//! out of the report, so a rewritten number cannot be laundered by declaring a
53//! looser tolerance.
54
55use anyhow::{Context, Result, bail};
56use serde::{Deserialize, Serialize};
57use serde_json::Value;
58use sha2::{Digest, Sha256};
59use std::collections::BTreeMap;
60
61/// Probability floor for NLL: keeps `-ln(0)` from becoming `+inf`.
62pub const NLL_PROBABILITY_FLOOR: f64 = 1e-12;
63
64/// Default number of equal-width confidence bins for ECE and the reliability
65/// curve.
66pub const DEFAULT_BINS: usize = 10;
67
68/// The schema identifier every report carries.
69pub const REPORT_SCHEMA: &str = "jev-bridge-evaluation-v1";
70
71/// Stratification key for gold rows that declare no `family`.
72pub const UNLABELLED_FAMILY: &str = "unlabelled";
73
74/// One prediction line, as `--score` writes it.
75///
76/// Only `id` and `option_ids` are required. A line with `probabilities` is
77/// *scored* and feeds every metric; a line with only a `label` still counts
78/// towards accuracy. Extra fields (logprobs, hashes, probe name) are ignored.
79#[derive(Debug, Clone, Deserialize)]
80pub struct PredictionLine {
81    /// Row id, matched against the gold file.
82    pub id: String,
83    /// Option ids in the order the probabilities follow.
84    pub option_ids: Vec<String>,
85    /// Probability vector aligned to `option_ids`.
86    #[serde(default)]
87    pub probabilities: Option<Vec<f64>>,
88    /// A bare predicted option id, used when no probability vector is present.
89    #[serde(default)]
90    pub label: Option<String>,
91}
92
93/// One gold line: the right answer for one row.
94#[derive(Debug, Clone, Deserialize)]
95pub struct GoldLine {
96    /// Row id, matched against the predictions file.
97    pub id: String,
98    /// The correct option id; must be one of the row's declared options.
99    pub gold: String,
100    /// Stratification key; rows without one are reported under `unlabelled`.
101    #[serde(default)]
102    pub family: Option<String>,
103    /// Slot used as the positive class for `brier_binary`.
104    ///
105    /// Optional: a two-slot `true`/`false` row defaults to `true`; other rows
106    /// without a positive declaration are excluded from `brier_binary` only.
107    #[serde(default)]
108    pub positive: Option<String>,
109}
110
111/// One joined item reduced to exactly the quantities the metrics need.
112#[derive(Debug, Clone)]
113pub struct Sample {
114    /// Row id (diagnostics only; the metrics never read it).
115    pub id: String,
116    /// Stratification key, already resolved (`None` becomes `unlabelled`).
117    pub family: String,
118    /// Gold slot key.
119    pub gold: String,
120    /// The item's declared slot keys, in order.
121    pub slots: Vec<String>,
122    /// `argmax(probs)` when a vector is present, else the row's `label`.
123    pub predicted: Option<String>,
124    /// Probability vector aligned to `slots`. `None` ⇒ row is unscored.
125    pub probs: Option<Vec<f64>>,
126    /// Slot key used as the positive class for `brier_binary`.
127    pub positive: Option<String>,
128}
129
130impl Sample {
131    /// The index of `gold` within the declared slots, when it is present.
132    fn gold_index(&self) -> Option<usize> {
133        self.slots.iter().position(|slot| *slot == self.gold)
134    }
135}
136
137/// One bin of the reliability curve.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct ReliabilityBin {
141    /// Inclusive lower edge (`index / bins`).
142    pub lower: f64,
143    /// Exclusive upper edge (`(index + 1) / bins`).
144    pub upper: f64,
145    /// Scored rows that landed in this bin.
146    pub n: usize,
147    /// Mean `max(p)` of those rows.
148    pub mean_confidence: f64,
149    /// Fraction of those rows whose argmax was the gold slot.
150    pub accuracy: f64,
151}
152
153/// Every number computed for one stratum.
154///
155/// `deny_unknown_fields`: a fabricated extra metric must fail to load rather
156/// than ride along inside a report that [`verify`] then passes.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct Metrics {
160    /// Rows in the stratum.
161    pub n_items: usize,
162    /// Rows with neither a probability vector nor a label.
163    pub n_missing: usize,
164    /// Rows that produced a predicted slot.
165    pub n_predicted: usize,
166    /// Rows that produced a full probability vector.
167    pub n_scored: usize,
168    /// `correct / n_predicted`; `None` when nothing was predicted.
169    pub accuracy: Option<f64>,
170    /// Mean per-class recall over the classes present as gold.
171    pub balanced_accuracy: Option<f64>,
172    /// The classes balanced accuracy averaged over (the gold classes present).
173    pub classes_in_gold: Vec<String>,
174    /// Per-class recall for every gold class present.
175    pub per_class_recall: BTreeMap<String, f64>,
176    /// Mean negative log-likelihood of the gold slot.
177    pub nll: Option<f64>,
178    /// Mean multiclass Brier score.
179    pub brier_multiclass: Option<f64>,
180    /// Scored rows that declared a positive class (the `brier_binary` denominator).
181    pub n_binary: usize,
182    /// Mean binary Brier score over the rows that declared a positive class.
183    pub brier_binary: Option<f64>,
184    /// Expected calibration error over `bins` equal-width confidence bins.
185    pub ece: Option<f64>,
186    /// The per-bin tuples ECE is summed from.
187    pub reliability: Vec<ReliabilityBin>,
188}
189
190/// A written evaluation report.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct Report {
194    /// Always [`REPORT_SCHEMA`]; lets a reader detect an incompatible file.
195    pub schema: String,
196    /// Equal-width bin count used for ECE and the reliability curve.
197    pub bins: usize,
198    /// SHA-256 of the predictions file's bytes.
199    pub predictions_sha256: String,
200    /// SHA-256 of the gold file's bytes.
201    pub gold_sha256: String,
202    /// Metrics over every row.
203    pub overall: Metrics,
204    /// Metrics per `family`, never pooled across strata.
205    pub by_family: BTreeMap<String, Metrics>,
206}
207
208/// One disagreement between a report and a fresh recomputation.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct Diff {
211    /// JSON-ish path of the field, e.g. `overall.ece`.
212    pub path: String,
213    /// What the report claims.
214    pub claimed: String,
215    /// What the evidence recomputes to.
216    pub recomputed: String,
217}
218
219/// The result of a verification run.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct VerifyOutcome {
222    /// How many individual comparisons were made (so "0 diffs" is meaningful).
223    pub checks: usize,
224    /// Every mismatch, in traversal order.
225    pub diffs: Vec<Diff>,
226}
227
228impl VerifyOutcome {
229    /// Whether the report reproduced exactly (within tolerance).
230    pub fn is_ok(&self) -> bool {
231        self.diffs.is_empty()
232    }
233
234    /// One line per mismatch, for printing.
235    pub fn report(&self) -> String {
236        use std::fmt::Write as _;
237        let mut out = String::new();
238        let _ = writeln!(
239            out,
240            "{} checks; {} mismatch{}",
241            self.checks,
242            self.diffs.len(),
243            if self.diffs.len() == 1 { "" } else { "es" }
244        );
245        for diff in &self.diffs {
246            let _ = writeln!(
247                out,
248                "  {}: claimed {} vs recomputed {}",
249                diff.path, diff.claimed, diff.recomputed
250            );
251        }
252        out
253    }
254}
255
256/// Parse a predictions JSONL document (one [`PredictionLine`] per line).
257///
258/// Blank lines are skipped; a line that is not a JSON object, or that carries
259/// no `id`, is an error that names the line number.
260pub fn load_predictions(text: &str) -> Result<Vec<PredictionLine>> {
261    let mut lines = Vec::new();
262    for (index, line) in text.lines().enumerate() {
263        let line = line.trim();
264        if line.is_empty() {
265            continue;
266        }
267        let parsed: PredictionLine = serde_json::from_str(line)
268            .with_context(|| format!("predictions: line {} is not a prediction row", index + 1))?;
269        lines.push(parsed);
270    }
271    Ok(lines)
272}
273
274/// Parse a gold JSONL document (one [`GoldLine`] per line).
275pub fn load_gold(text: &str) -> Result<Vec<GoldLine>> {
276    let mut lines = Vec::new();
277    for (index, line) in text.lines().enumerate() {
278        let line = line.trim();
279        if line.is_empty() {
280            continue;
281        }
282        let parsed: GoldLine = serde_json::from_str(line)
283            .with_context(|| format!("gold: line {} is not a gold row", index + 1))?;
284        lines.push(parsed);
285    }
286    Ok(lines)
287}
288
289/// The first-maximum rule: ties resolve to the earliest slot, so the result is
290/// a pure function of the vector (the same rule the wire layer reports with).
291fn argmax_first_max(values: &[f64]) -> usize {
292    let mut best = 0;
293    for (index, value) in values.iter().enumerate() {
294        if *value > values[best] {
295            best = index;
296        }
297    }
298    best
299}
300
301/// Join predictions with gold by id, validating both sides.
302///
303/// Failures are loud and specific, because every one of them would otherwise
304/// turn into a quietly wrong number:
305///
306/// * a duplicate id on either side;
307/// * a prediction row whose id is not in the gold file (the two files are out
308///   of sync — evaluating the overlap would hide exactly the rows that differ);
309/// * a probability vector whose length disagrees with `option_ids`;
310/// * a non-finite or negative probability;
311/// * a gold slot that is not among the row's declared options.
312pub fn join(predictions: &[PredictionLine], gold: &[GoldLine]) -> Result<Vec<Sample>> {
313    let mut by_id: BTreeMap<&str, &PredictionLine> = BTreeMap::new();
314    for line in predictions {
315        if by_id.insert(line.id.as_str(), line).is_some() {
316            bail!("predictions: duplicate id {:?}", line.id);
317        }
318    }
319
320    let mut seen_gold: BTreeMap<&str, ()> = BTreeMap::new();
321    let mut samples = Vec::with_capacity(gold.len());
322    for line in gold {
323        if seen_gold.insert(line.id.as_str(), ()).is_some() {
324            bail!("gold: duplicate id {:?}", line.id);
325        }
326        let Some(prediction) = by_id.remove(line.id.as_str()) else {
327            // The gold file asked about a row the predictions file never
328            // scored. Counted as missing, never silently dropped.
329            samples.push(Sample {
330                id: line.id.clone(),
331                family: line
332                    .family
333                    .clone()
334                    .unwrap_or_else(|| UNLABELLED_FAMILY.to_string()),
335                gold: line.gold.clone(),
336                slots: Vec::new(),
337                predicted: None,
338                probs: None,
339                positive: line.positive.clone(),
340            });
341            continue;
342        };
343
344        if !prediction.option_ids.contains(&line.gold) {
345            bail!(
346                "gold row {:?}: gold {:?} is not among the row's declared options {:?}",
347                line.id,
348                line.gold,
349                prediction.option_ids
350            );
351        }
352
353        let probs = match &prediction.probabilities {
354            None => None,
355            Some(probs) => {
356                if probs.len() != prediction.option_ids.len() {
357                    bail!(
358                        "predictions row {:?}: {} probabilities for {} options",
359                        line.id,
360                        probs.len(),
361                        prediction.option_ids.len()
362                    );
363                }
364                if probs.iter().any(|value| !value.is_finite() || *value < 0.0) {
365                    bail!(
366                        "predictions row {:?}: non-finite or negative probability",
367                        line.id
368                    );
369                }
370                Some(probs.clone())
371            }
372        };
373
374        let predicted = match &probs {
375            Some(probs) => {
376                let winner = argmax_first_max(probs);
377                Some(prediction.option_ids[winner].clone())
378            }
379            None => prediction.label.clone(),
380        };
381
382        // A two-slot `true`/`false` row is the `noul` contract, whose positive
383        // class is fixed to `true`; anything else has no default.
384        let positive = line
385            .positive
386            .clone()
387            .or_else(|| (prediction.option_ids == ["true", "false"]).then(|| "true".to_string()));
388        if let Some(positive) = &positive
389            && !prediction.option_ids.iter().any(|slot| slot == positive)
390        {
391            bail!(
392                "gold row {:?}: positive {:?} is not among the row's declared options {:?}",
393                line.id,
394                positive,
395                prediction.option_ids
396            );
397        }
398
399        samples.push(Sample {
400            id: line.id.clone(),
401            family: line
402                .family
403                .clone()
404                .unwrap_or_else(|| UNLABELLED_FAMILY.to_string()),
405            gold: line.gold.clone(),
406            slots: prediction.option_ids.clone(),
407            predicted,
408            probs,
409            positive,
410        });
411    }
412
413    if !by_id.is_empty() {
414        let mut extra: Vec<&str> = by_id.keys().copied().collect();
415        extra.sort_unstable();
416        extra.truncate(5);
417        bail!(
418            "predictions: {} row(s) have no gold counterpart (first: {:?})",
419            by_id.len(),
420            extra
421        );
422    }
423
424    Ok(samples)
425}
426
427/// Compute the bin index for a confidence, clamping the top edge into the last
428/// bin and absorbing float error at the bottom edge.
429fn bin_of(confidence: f64, bins: usize) -> usize {
430    let raw = (confidence * bins as f64).floor();
431    if raw.is_nan() || raw < 0.0 {
432        0
433    } else {
434        (raw as usize).min(bins - 1)
435    }
436}
437
438/// Reduce one stratum's samples to [`Metrics`].
439///
440/// Deterministic: same samples in, bit-identical numbers out. That is what lets
441/// [`verify`] re-derive a published report and compare it exactly.
442///
443/// Panics only if `bins == 0`, which [`evaluate`] rejects first.
444pub fn summarize(samples: &[Sample], bins: usize) -> Metrics {
445    assert!(bins > 0, "bin count must be >= 1");
446
447    let n_items = samples.len();
448    let n_missing = samples
449        .iter()
450        .filter(|sample| sample.predicted.is_none() && sample.probs.is_none())
451        .count();
452    let predicted: Vec<&Sample> = samples
453        .iter()
454        .filter(|sample| sample.predicted.is_some())
455        .collect();
456    let n_predicted = predicted.len();
457
458    let accuracy = if n_predicted == 0 {
459        None
460    } else {
461        let hits = predicted
462            .iter()
463            .filter(|sample| sample.predicted.as_deref() == Some(sample.gold.as_str()))
464            .count();
465        Some(hits as f64 / n_predicted as f64)
466    };
467
468    // Per-class recall over the classes that actually occur as gold.
469    let mut tallies: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
470    for sample in &predicted {
471        let entry = tallies.entry(sample.gold.as_str()).or_insert((0, 0));
472        entry.0 += 1;
473        if sample.predicted.as_deref() == Some(sample.gold.as_str()) {
474            entry.1 += 1;
475        }
476    }
477    let per_class_recall: BTreeMap<String, f64> = tallies
478        .iter()
479        .map(|(class, (n, ok))| ((*class).to_string(), *ok as f64 / *n as f64))
480        .collect();
481    let classes_in_gold: Vec<String> = tallies.keys().map(|key| (*key).to_string()).collect();
482    let balanced_accuracy = if per_class_recall.is_empty() {
483        None
484    } else {
485        Some(per_class_recall.values().sum::<f64>() / per_class_recall.len() as f64)
486    };
487
488    let scored: Vec<(&Sample, &[f64])> = samples
489        .iter()
490        .filter_map(|sample| {
491            sample
492                .probs
493                .as_deref()
494                .filter(|probs| probs.len() == sample.slots.len())
495                .map(|probs| (sample, probs))
496        })
497        .collect();
498    let n_scored = scored.len();
499
500    let mut nll_sum = 0.0;
501    let mut brier_multiclass_sum = 0.0;
502    let mut brier_binary_sum = 0.0;
503    let mut n_binary = 0usize;
504    let mut bin_counts = vec![(0.0f64, 0usize, 0usize); bins];
505
506    for (sample, probs) in scored.iter().copied() {
507        // `gold_index` is `Some` for every sample that came through `join`;
508        // a `None` here would mean a slot set that does not contain its own
509        // gold, which the joiner rejects.
510        let Some(gold_index) = sample.gold_index() else {
511            continue;
512        };
513
514        nll_sum += -probs[gold_index].max(NLL_PROBABILITY_FLOOR).ln();
515
516        let mut squared = 0.0;
517        for (index, probability) in probs.iter().enumerate() {
518            let target = if index == gold_index { 1.0 } else { 0.0 };
519            squared += (probability - target) * (probability - target);
520        }
521        brier_multiclass_sum += squared;
522
523        if let Some(positive) = sample
524            .positive
525            .as_ref()
526            .and_then(|key| sample.slots.iter().position(|slot| slot == key))
527        {
528            let target = if positive == gold_index { 1.0 } else { 0.0 };
529            brier_binary_sum += (probs[positive] - target) * (probs[positive] - target);
530            n_binary += 1;
531        }
532
533        let top = argmax_first_max(probs);
534        let confidence = probs[top];
535        let bin = bin_of(confidence, bins);
536        bin_counts[bin].0 += confidence;
537        bin_counts[bin].1 += 1;
538        if top == gold_index {
539            bin_counts[bin].2 += 1;
540        }
541    }
542
543    let mut reliability = Vec::new();
544    let mut ece = 0.0;
545    for (index, (confidence_sum, n, correct)) in bin_counts.iter().enumerate() {
546        if *n == 0 {
547            continue;
548        }
549        let mean_confidence = confidence_sum / *n as f64;
550        let bin_accuracy = *correct as f64 / *n as f64;
551        ece += (*n as f64 / n_scored as f64) * (bin_accuracy - mean_confidence).abs();
552        reliability.push(ReliabilityBin {
553            lower: index as f64 / bins as f64,
554            upper: (index + 1) as f64 / bins as f64,
555            n: *n,
556            mean_confidence,
557            accuracy: bin_accuracy,
558        });
559    }
560
561    let divide = |sum: f64| -> Option<f64> {
562        if n_scored == 0 {
563            None
564        } else {
565            Some(sum / n_scored as f64)
566        }
567    };
568
569    Metrics {
570        n_items,
571        n_missing,
572        n_predicted,
573        n_scored,
574        accuracy,
575        balanced_accuracy,
576        classes_in_gold,
577        per_class_recall,
578        nll: divide(nll_sum),
579        brier_multiclass: divide(brier_multiclass_sum),
580        n_binary,
581        brier_binary: if n_binary == 0 {
582            None
583        } else {
584            Some(brier_binary_sum / n_binary as f64)
585        },
586        // NOTE: `ece` above is already the item-weighted sum
587        // `Σ_bins (n_bin / n_scored) · |acc_bin − conf_bin|` — it is already
588        // normalised and must NOT go through `divide()` again, which would
589        // divide it by the item count a second time.
590        ece: if n_scored == 0 { None } else { Some(ece) },
591        reliability,
592    }
593}
594
595/// SHA-256 of a document's bytes, as lowercase hex.
596fn sha256_hex(text: &str) -> String {
597    let mut hasher = Sha256::new();
598    hasher.update(text.as_bytes());
599    format!("{:x}", hasher.finalize())
600}
601
602/// Evaluate predictions against gold: join, validate, and summarise every
603/// stratum plus the overall one.
604///
605/// Fails when `bins` is zero (there would be no reliability curve to speak of).
606pub fn evaluate(predictions_text: &str, gold_text: &str, bins: usize) -> Result<Report> {
607    if bins == 0 {
608        bail!("bins must be >= 1");
609    }
610    let predictions = load_predictions(predictions_text)?;
611    let gold = load_gold(gold_text)?;
612    let samples = join(&predictions, &gold)?;
613
614    let mut by_family: BTreeMap<String, Vec<Sample>> = BTreeMap::new();
615    for sample in &samples {
616        by_family
617            .entry(sample.family.clone())
618            .or_default()
619            .push(sample.clone());
620    }
621
622    Ok(Report {
623        schema: REPORT_SCHEMA.to_string(),
624        bins,
625        predictions_sha256: sha256_hex(predictions_text),
626        gold_sha256: sha256_hex(gold_text),
627        overall: summarize(&samples, bins),
628        by_family: by_family
629            .iter()
630            .map(|(family, rows)| (family.clone(), summarize(rows, bins)))
631            .collect(),
632    })
633}
634
635/// Recompute nothing: compare a claimed report against a freshly computed one,
636/// field by field.
637///
638/// Numbers match when `|claimed - recomputed| <= tol`; strings, booleans and
639/// nulls must match exactly. `tol` is the caller's audit threshold — it is
640/// never read out of the report itself.
641pub fn verify(claimed: &Report, recomputed: &Report, tol: f64) -> VerifyOutcome {
642    let mut outcome = VerifyOutcome {
643        checks: 0,
644        diffs: Vec::new(),
645    };
646    let claimed_value = serde_json::to_value(claimed).expect("a Report is always serialisable");
647    let recomputed_value =
648        serde_json::to_value(recomputed).expect("a Report is always serialisable");
649    compare_values(
650        "report",
651        &claimed_value,
652        &recomputed_value,
653        tol,
654        &mut outcome,
655    );
656    outcome
657}
658
659/// Recursively compare two JSON documents, counting every leaf comparison.
660fn compare_values(
661    path: &str,
662    claimed: &Value,
663    recomputed: &Value,
664    tol: f64,
665    outcome: &mut VerifyOutcome,
666) {
667    match (claimed, recomputed) {
668        (Value::Number(claimed), Value::Number(recomputed)) => {
669            outcome.checks += 1;
670            let matches = match (claimed.as_f64(), recomputed.as_f64()) {
671                (Some(claimed), Some(recomputed)) => {
672                    (claimed - recomputed).abs() <= tol || claimed == recomputed
673                }
674                _ => false,
675            };
676            if !matches {
677                outcome.diffs.push(Diff {
678                    path: path.to_string(),
679                    claimed: claimed.to_string(),
680                    recomputed: recomputed.to_string(),
681                });
682            }
683        }
684        (Value::Array(claimed), Value::Array(recomputed)) => {
685            if claimed.len() != recomputed.len() {
686                outcome.checks += 1;
687                outcome.diffs.push(Diff {
688                    path: path.to_string(),
689                    claimed: format!("{} entries", claimed.len()),
690                    recomputed: format!("{} entries", recomputed.len()),
691                });
692                return;
693            }
694            for (index, (claimed, recomputed)) in claimed.iter().zip(recomputed).enumerate() {
695                compare_values(
696                    &format!("{path}[{index}]"),
697                    claimed,
698                    recomputed,
699                    tol,
700                    outcome,
701                );
702            }
703        }
704        (Value::Object(claimed), Value::Object(recomputed)) => {
705            // The key sets must agree exactly: a missing stratum is a mismatch
706            // even when every shared field matches.
707            let same_keys = claimed.len() == recomputed.len()
708                && claimed.keys().all(|key| recomputed.contains_key(key));
709            if !same_keys {
710                outcome.checks += 1;
711                outcome.diffs.push(Diff {
712                    path: path.to_string(),
713                    claimed: format!("keys {:?}", claimed.keys().collect::<Vec<_>>()),
714                    recomputed: format!("keys {:?}", recomputed.keys().collect::<Vec<_>>()),
715                });
716                return;
717            }
718            for (key, claimed) in claimed {
719                compare_values(
720                    &format!("{path}.{key}"),
721                    claimed,
722                    &recomputed[key],
723                    tol,
724                    outcome,
725                );
726            }
727        }
728        (claimed, recomputed) => {
729            outcome.checks += 1;
730            if claimed != recomputed {
731                outcome.diffs.push(Diff {
732                    path: path.to_string(),
733                    claimed: claimed.to_string(),
734                    recomputed: recomputed.to_string(),
735                });
736            }
737        }
738    }
739}
740
741/// A short human-readable summary, for the CLI.
742pub fn render_table(report: &Report) -> String {
743    use std::fmt::Write as _;
744    let mut out = String::new();
745    let _ = writeln!(
746        out,
747        "{} rows ({} scored, {} missing)  bins={}",
748        report.overall.n_items, report.overall.n_scored, report.overall.n_missing, report.bins
749    );
750    let _ = write_stratum(&mut out, "overall", &report.overall);
751    if !report.by_family.is_empty() {
752        let _ = writeln!(out, "by family:");
753        for (family, metrics) in &report.by_family {
754            let _ = write_stratum(&mut out, &format!("  {family}"), metrics);
755        }
756    }
757    out
758}
759
760fn write_stratum(out: &mut String, name: &str, metrics: &Metrics) -> std::fmt::Result {
761    use std::fmt::Write as _;
762    let show = |value: Option<f64>| match value {
763        Some(value) => format!("{value:.4}"),
764        None => "-".to_string(),
765    };
766    let _ = writeln!(
767        out,
768        "{name}: acc {}  balanced {}  ece {}  nll {}  brier {}  n={}",
769        show(metrics.accuracy),
770        show(metrics.balanced_accuracy),
771        show(metrics.ece),
772        show(metrics.nll),
773        show(metrics.brier_multiclass),
774        metrics.n_scored,
775    );
776    if metrics.n_binary > 0 {
777        let _ = writeln!(
778            out,
779            "  brier_binary {}  (n_binary {})",
780            show(metrics.brier_binary),
781            metrics.n_binary
782        );
783    }
784    Ok(())
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    fn sample(gold: &str, probs: Option<[f64; 2]>, positive: Option<&str>) -> Sample {
792        let slots = vec!["A".to_string(), "B".to_string()];
793        let vector = probs.map(|probs| vec![probs[0], probs[1]]);
794        let predicted = vector
795            .as_ref()
796            .map(|vector| slots[argmax_first_max(vector)].clone());
797        Sample {
798            id: format!("s-{gold}-{}", vector.is_some()),
799            family: UNLABELLED_FAMILY.to_string(),
800            gold: gold.to_string(),
801            slots,
802            predicted,
803            probs: vector,
804            positive: positive.map(str::to_owned),
805        }
806    }
807
808    /// The four-row example whose metrics are hand-computable, with
809    /// probabilities chosen to be exact in binary floating point.
810    ///
811    /// Row 1: p(top)=0.5, correct  → bin 5
812    /// Row 2: p(top)=0.75, correct → bin 7
813    /// Row 3: p(top)=0.75, wrong   → bin 7
814    /// Row 4: p(top)=1.0, correct  → bin 9
815    fn known_four() -> Vec<Sample> {
816        vec![
817            sample("A", Some([0.5, 0.5]), Some("A")),
818            sample("A", Some([0.25, 0.75]), Some("A")),
819            sample("B", Some([0.75, 0.25]), Some("A")),
820            sample("A", Some([1.0, 0.0]), Some("A")),
821        ]
822    }
823
824    #[test]
825    fn bin_assignment_clamps_the_top_edge() {
826        assert_eq!(bin_of(0.0, 10), 0);
827        assert_eq!(bin_of(0.05, 10), 0);
828        assert_eq!(bin_of(0.75, 10), 7);
829        assert_eq!(bin_of(0.999, 10), 9);
830        assert_eq!(bin_of(1.0, 10), 9);
831        assert_eq!(bin_of(1.0, 7), 6);
832        assert_eq!(bin_of(-0.25, 10), 0);
833        assert_eq!(bin_of(f64::NAN, 10), 0);
834    }
835
836    #[test]
837    fn argmax_breaks_ties_towards_the_earlier_slot() {
838        assert_eq!(argmax_first_max(&[0.5, 0.5]), 0);
839        assert_eq!(argmax_first_max(&[0.2, 0.8]), 1);
840        assert_eq!(argmax_first_max(&[0.3]), 0);
841    }
842
843    #[test]
844    fn empty_stratum_has_no_numbers() {
845        let metrics = summarize(&[], 10);
846        assert_eq!(metrics.n_items, 0);
847        assert_eq!(metrics.n_scored, 0);
848        assert_eq!(metrics.n_predicted, 0);
849        assert_eq!(metrics.n_missing, 0);
850        assert!(metrics.accuracy.is_none());
851        assert!(metrics.balanced_accuracy.is_none());
852        assert!(metrics.ece.is_none());
853        assert!(metrics.nll.is_none());
854        assert!(metrics.brier_multiclass.is_none());
855        assert!(metrics.brier_binary.is_none());
856        assert!(metrics.reliability.is_empty());
857    }
858
859    #[test]
860    fn the_known_four_row_example_reproduces_hand_computed_values() {
861        let metrics = summarize(&known_four(), 10);
862        assert_eq!(metrics.n_items, 4);
863        assert_eq!(metrics.n_predicted, 4);
864        assert_eq!(metrics.n_scored, 4);
865        assert_eq!(metrics.n_missing, 0);
866        assert_eq!(metrics.accuracy, Some(0.5));
867        // Gold `A` appears 3x (rows 1, 2, 4) with 2 hits; gold `B` once with 0.
868        assert_eq!(
869            metrics.classes_in_gold,
870            vec!["A".to_string(), "B".to_string()]
871        );
872        assert_eq!(metrics.per_class_recall["A"], 2.0 / 3.0);
873        assert_eq!(metrics.per_class_recall["B"], 0.0);
874        assert_eq!(metrics.balanced_accuracy, Some(1.0 / 3.0));
875        // Binary Brier (positive = A): 0.25 + 0.5625 + 0.5625 + 0.0 = 1.375 / 4.
876        assert_eq!(metrics.brier_binary, Some(0.34375));
877        assert_eq!(metrics.n_binary, 4);
878        // For two slots the multiclass score is exactly twice the binary one.
879        assert_eq!(metrics.brier_multiclass, Some(0.6875));
880        // ECE = 1/4*|1-0.5| + 2/4*|0-0.75| + 1/4*|1-1| = 0.5, all exact in binary.
881        assert_eq!(metrics.ece, Some(0.5));
882        assert_eq!(metrics.reliability.len(), 3);
883        assert_eq!(
884            metrics.reliability[0],
885            ReliabilityBin {
886                lower: 0.5,
887                upper: 0.6,
888                n: 1,
889                mean_confidence: 0.5,
890                accuracy: 1.0,
891            }
892        );
893        assert_eq!(
894            metrics.reliability[1],
895            ReliabilityBin {
896                lower: 0.7,
897                upper: 0.8,
898                n: 2,
899                mean_confidence: 0.75,
900                accuracy: 0.0,
901            }
902        );
903        assert_eq!(
904            metrics.reliability[2],
905            ReliabilityBin {
906                lower: 0.9,
907                upper: 1.0,
908                n: 1,
909                mean_confidence: 1.0,
910                accuracy: 1.0,
911            }
912        );
913        // NLL = (-ln 0.5 - ln 0.25 - ln 0.25 - ln 1.0) / 4.
914        let expected = (0.5f64.ln().abs() + 0.25f64.ln().abs() * 2.0) / 4.0;
915        assert!((metrics.nll.unwrap() - expected).abs() < 1e-15);
916    }
917
918    #[test]
919    fn a_perfect_forecast_has_zero_nll_and_zero_ece() {
920        let rows = vec![
921            sample("A", Some([1.0, 0.0]), Some("A")),
922            sample("B", Some([0.0, 1.0]), Some("A")),
923        ];
924        let metrics = summarize(&rows, 10);
925        assert_eq!(metrics.accuracy, Some(1.0));
926        assert_eq!(metrics.balanced_accuracy, Some(1.0));
927        assert_eq!(metrics.ece, Some(0.0));
928        assert_eq!(metrics.nll.unwrap(), 0.0);
929        assert_eq!(metrics.brier_multiclass, Some(0.0));
930    }
931
932    #[test]
933    fn a_maximally_wrong_confident_forecast_saturates_ece_at_one() {
934        let rows = vec![
935            sample("A", Some([0.0, 1.0]), Some("A")),
936            sample("B", Some([1.0, 0.0]), Some("A")),
937        ];
938        let metrics = summarize(&rows, 10);
939        assert_eq!(metrics.accuracy, Some(0.0));
940        assert_eq!(metrics.ece, Some(1.0));
941        assert_eq!(metrics.brier_binary, Some(1.0));
942    }
943
944    #[test]
945    fn the_nll_floor_bounds_an_impossible_gold() {
946        // p_gold = 0 must not become +inf.
947        let rows = vec![sample("A", Some([0.0, 1.0]), Some("A"))];
948        let metrics = summarize(&rows, 10);
949        let expected = -NLL_PROBABILITY_FLOOR.ln();
950        assert!((metrics.nll.unwrap() - expected).abs() < 1e-9);
951        assert!(metrics.nll.unwrap().is_finite());
952    }
953
954    #[test]
955    fn rows_without_probabilities_are_excluded_from_nll_brier_and_ece() {
956        let mut rows = known_four();
957        let mut hard = sample("A", None, Some("A"));
958        hard.predicted = Some("A".to_string());
959        rows.push(hard);
960        let metrics = summarize(&rows, 10);
961        // Accuracy sees all five rows...
962        assert_eq!(metrics.n_predicted, 5);
963        assert_eq!(metrics.accuracy, Some(3.0 / 5.0));
964        // ...but the probability metrics still see only the four scored ones.
965        assert_eq!(metrics.n_scored, 4);
966        assert_eq!(metrics.ece, Some(0.5));
967        assert_eq!(metrics.brier_binary, Some(0.34375));
968    }
969
970    #[test]
971    fn missing_rows_are_counted_not_silently_dropped() {
972        let rows = vec![
973            sample("A", Some([0.5, 0.5]), Some("A")),
974            Sample {
975                id: "no-answer".into(),
976                family: UNLABELLED_FAMILY.into(),
977                gold: "A".into(),
978                slots: vec!["A".into(), "B".into()],
979                predicted: None,
980                probs: None,
981                positive: None,
982            },
983        ];
984        let metrics = summarize(&rows, 10);
985        assert_eq!(metrics.n_items, 2);
986        assert_eq!(metrics.n_missing, 1);
987        assert_eq!(metrics.n_predicted, 1);
988        assert_eq!(metrics.n_scored, 1);
989        assert_eq!(metrics.accuracy, Some(1.0));
990    }
991
992    #[test]
993    fn a_bin_count_of_one_is_plain_calibration_gap() {
994        let metrics = summarize(&known_four(), 1);
995        // One bin, so everything is clamped into it: mean confidence
996        // (0.5 + 0.75 + 0.75 + 1.0) / 4 = 0.75, accuracy 0.5.
997        assert_eq!(metrics.reliability.len(), 1);
998        assert_eq!(metrics.reliability[0].lower, 0.0);
999        assert_eq!(metrics.reliability[0].upper, 1.0);
1000        assert_eq!(metrics.reliability[0].n, 4);
1001        assert!((metrics.reliability[0].mean_confidence - 0.75).abs() < 1e-15);
1002        assert_eq!(metrics.reliability[0].accuracy, 0.5);
1003        assert!((metrics.ece.unwrap() - 0.25).abs() < 1e-15);
1004    }
1005
1006    #[test]
1007    fn summarize_is_a_pure_function_of_its_input() {
1008        let rows = known_four();
1009        assert_eq!(summarize(&rows, 10), summarize(&rows, 10));
1010        let finer = summarize(&rows, 25);
1011        assert_ne!(finer, summarize(&rows, 10));
1012    }
1013
1014    // ----------------------------------------------------------------------
1015    // joining
1016    // ----------------------------------------------------------------------
1017
1018    fn prediction(
1019        id: &str,
1020        probabilities: Option<Vec<f64>>,
1021        label: Option<&str>,
1022    ) -> PredictionLine {
1023        PredictionLine {
1024            id: id.to_string(),
1025            option_ids: vec!["yes".into(), "no".into()],
1026            probabilities,
1027            label: label.map(str::to_owned),
1028        }
1029    }
1030
1031    fn gold(id: &str, gold: &str) -> GoldLine {
1032        GoldLine {
1033            id: id.to_string(),
1034            gold: gold.to_string(),
1035            family: None,
1036            positive: None,
1037        }
1038    }
1039
1040    #[test]
1041    fn join_pairs_by_id_and_counts_gold_rows_without_predictions() {
1042        let predictions = vec![
1043            prediction("a", Some(vec![0.9, 0.1]), None),
1044            prediction("b", None, Some("no")),
1045        ];
1046        let gold = vec![gold("a", "yes"), gold("b", "no"), gold("c", "yes")];
1047        let samples = join(&predictions, &gold).unwrap();
1048
1049        assert_eq!(samples.len(), 3);
1050        assert_eq!(samples[0].predicted.as_deref(), Some("yes"));
1051        assert_eq!(samples[1].predicted.as_deref(), Some("no"));
1052        assert_eq!(samples[1].probs, None);
1053        assert_eq!(samples[2].predicted, None);
1054        assert_eq!(samples[2].probs, None);
1055        assert_eq!(samples[2].family, UNLABELLED_FAMILY);
1056    }
1057
1058    #[test]
1059    fn join_derives_the_winner_with_the_first_max_rule() {
1060        let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1061        let samples = join(&predictions, &[gold("a", "yes")]).unwrap();
1062        assert_eq!(samples[0].predicted.as_deref(), Some("yes"));
1063    }
1064
1065    #[test]
1066    fn join_defaults_noul_positive_to_true() {
1067        let predictions = vec![PredictionLine {
1068            id: "a".into(),
1069            option_ids: vec!["true".into(), "false".into()],
1070            probabilities: Some(vec![0.8, 0.2]),
1071            label: None,
1072        }];
1073        let samples = join(&predictions, &[gold("a", "true")]).unwrap();
1074        assert_eq!(samples[0].positive.as_deref(), Some("true"));
1075
1076        // A non-boolean option set gets no default.
1077        let predictions = vec![prediction("a", Some(vec![0.8, 0.2]), None)];
1078        let samples = join(&predictions, &[gold("a", "yes")]).unwrap();
1079        assert_eq!(samples[0].positive, None);
1080    }
1081
1082    #[test]
1083    fn join_rejects_an_unmatched_prediction() {
1084        let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1085        let error = join(&predictions, &[gold("b", "yes")]).unwrap_err();
1086        assert!(error.to_string().contains("no gold counterpart"), "{error}");
1087    }
1088
1089    #[test]
1090    fn join_rejects_duplicate_ids_on_either_side() {
1091        let predictions = vec![
1092            prediction("a", Some(vec![0.5, 0.5]), None),
1093            prediction("a", Some(vec![0.5, 0.5]), None),
1094        ];
1095        assert!(join(&predictions, &[gold("a", "yes")]).is_err());
1096
1097        let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1098        let error = join(&predictions, &[gold("a", "yes"), gold("a", "no")]).unwrap_err();
1099        assert!(error.to_string().contains("duplicate id"), "{error}");
1100    }
1101
1102    #[test]
1103    fn join_rejects_a_malformed_vector_or_an_unknown_gold() {
1104        // Length disagrees with the option count.
1105        let predictions = vec![prediction("a", Some(vec![0.5, 0.3, 0.2]), None)];
1106        assert!(join(&predictions, &[gold("a", "yes")]).is_err());
1107
1108        // Negative probability.
1109        let predictions = vec![prediction("a", Some(vec![1.2, -0.2]), None)];
1110        assert!(join(&predictions, &[gold("a", "yes")]).is_err());
1111
1112        // Gold is not one of the row's declared options.
1113        let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1114        let error = join(&predictions, &[gold("a", "maybe")]).unwrap_err();
1115        assert!(
1116            error
1117                .to_string()
1118                .contains("not among the row's declared options"),
1119            "{error}"
1120        );
1121    }
1122
1123    #[test]
1124    fn join_rejects_a_positive_that_is_not_a_declared_option() {
1125        let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1126        let mut line = gold("a", "yes");
1127        line.positive = Some("maybe".into());
1128        let error = join(&predictions, &[line]).unwrap_err();
1129        assert!(error.to_string().contains("positive"), "{error}");
1130    }
1131
1132    // ----------------------------------------------------------------------
1133    // end to end: evaluate + verify
1134    // ----------------------------------------------------------------------
1135
1136    fn predictions_document() -> String {
1137        [
1138            r#"{"id":"a","option_ids":["yes","no"],"probabilities":[0.9,0.1],"label":null}"#,
1139            r#"{"id":"b","option_ids":["yes","no"],"probabilities":[0.4,0.6]}"#,
1140        ]
1141        .join("\n")
1142    }
1143
1144    fn gold_document() -> String {
1145        [
1146            r#"{"id":"a","gold":"yes","family":"one"}"#,
1147            r#"{"id":"b","gold":"no","family":"two"}"#,
1148        ]
1149        .join("\n")
1150    }
1151
1152    #[test]
1153    fn evaluate_stratifies_by_family_and_hashes_its_inputs() {
1154        let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1155
1156        assert_eq!(report.schema, REPORT_SCHEMA);
1157        assert_eq!(report.bins, 10);
1158        assert_eq!(
1159            report.predictions_sha256,
1160            sha256_hex(&predictions_document())
1161        );
1162        assert_eq!(report.gold_sha256, sha256_hex(&gold_document()));
1163        assert_eq!(report.overall.n_items, 2);
1164        assert_eq!(report.overall.accuracy, Some(1.0));
1165        assert_eq!(report.by_family.len(), 2);
1166        assert_eq!(report.by_family["one"].n_items, 1);
1167        assert_eq!(report.by_family["two"].n_items, 1);
1168    }
1169
1170    #[test]
1171    fn evaluate_rejects_zero_bins() {
1172        assert!(evaluate(&predictions_document(), &gold_document(), 0).is_err());
1173    }
1174
1175    #[test]
1176    fn evaluate_accepts_blank_lines_and_ignores_extra_fields() {
1177        let predictions = format!(
1178            "\n{}\n\n{{\"id\":\"b\",\"option_ids\":[\"yes\",\"no\"],\"probabilities\":[0.4,0.6],\
1179             \"option_logprobs\":[-0.9,-0.5],\"prompt_sha256\":\"deadbeef\",\"probe\":\"x\"}}\n",
1180            r#"{"id":"a","option_ids":["yes","no"],"probabilities":[0.9,0.1]}"#
1181        );
1182        let report = evaluate(&predictions, &gold_document(), 10).unwrap();
1183        assert_eq!(report.overall.n_items, 2);
1184    }
1185
1186    #[test]
1187    fn verify_accepts_an_identical_recomputation() {
1188        let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1189        let outcome = verify(&report, &report, 1e-12);
1190        assert!(outcome.is_ok(), "{}", outcome.report());
1191        assert!(
1192            outcome.checks > 0,
1193            "a passing verify must still count checks"
1194        );
1195    }
1196
1197    #[test]
1198    fn verify_detects_a_tampered_number() {
1199        let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1200        let mut tampered = report.clone();
1201        tampered.overall.accuracy = Some(0.25);
1202        let outcome = verify(&tampered, &report, 1e-12);
1203        assert!(!outcome.is_ok());
1204        assert_eq!(outcome.diffs.len(), 1);
1205        assert_eq!(outcome.diffs[0].path, "report.overall.accuracy");
1206        assert!(outcome.report().contains("mismatch"));
1207    }
1208
1209    #[test]
1210    fn verify_detects_a_missing_stratum_and_a_tampered_hash() {
1211        let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1212
1213        // A dropped stratum must not pass just because the shared fields match.
1214        let mut dropped = report.clone();
1215        dropped.by_family.remove("two");
1216        assert!(!verify(&dropped, &report, 1e-12).is_ok());
1217
1218        // A rewritten input hash is a claim about the evidence, and the
1219        // evidence is what verify recomputes.
1220        let mut rewritten = report.clone();
1221        rewritten.gold_sha256 = "0".repeat(64);
1222        let outcome = verify(&rewritten, &report, 1e-12);
1223        assert!(!outcome.is_ok());
1224        assert_eq!(outcome.diffs[0].path, "report.gold_sha256");
1225    }
1226
1227    #[test]
1228    fn verify_tolerance_belongs_to_the_caller() {
1229        let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1230        let mut slightly_off = report.clone();
1231        // Nudge one number beyond 1e-12 but inside 1e-3.
1232        slightly_off.overall.nll = slightly_off.overall.nll.map(|value| value + 1e-6);
1233        assert!(!verify(&slightly_off, &report, 1e-12).is_ok());
1234        assert!(verify(&slightly_off, &report, 1e-3).is_ok());
1235    }
1236
1237    #[test]
1238    fn reports_round_trip_through_json() {
1239        let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1240        let text = serde_json::to_string_pretty(&report).unwrap();
1241        let parsed: Report = serde_json::from_str(&text).unwrap();
1242        assert_eq!(parsed, report);
1243        assert!(verify(&parsed, &report, 1e-12).is_ok());
1244    }
1245
1246    #[test]
1247    fn a_report_with_an_unknown_field_is_rejected() {
1248        let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1249        let mut value = serde_json::to_value(&report).unwrap();
1250        value["overall"]["total_accuracy"] = serde_json::json!(0.99);
1251        assert!(
1252            serde_json::from_value::<Report>(value).is_err(),
1253            "a fabricated metric must fail to load rather than ride along"
1254        );
1255    }
1256
1257    #[test]
1258    fn the_table_names_the_overall_stratum_and_every_family() {
1259        let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1260        let table = render_table(&report);
1261        assert!(table.contains("overall"), "{table}");
1262        assert!(table.contains("one"), "{table}");
1263        assert!(table.contains("two"), "{table}");
1264    }
1265}