Skip to main content

rto_graph/
review_score.rs

1//! Scoring a candidate reviewer against the adjudicated corpus (Stage 35).
2//!
3//! A review tool is otherwise unmeasurable: its output is prose, its mistakes are
4//! plausible, and nobody remembers last week's false positives well enough to
5//! count them. [`crate::review_corpus`] fixes a set of comments with known
6//! verdicts; this module turns a candidate's findings into numbers over that set,
7//! so a different model, a changed prompt or a graph-grounded arm are comparable
8//! across attempts rather than argued about.
9//!
10//! # Per class, never averaged
11//!
12//! [`Score::per_class`] is the headline, and an aggregate deliberately is not.
13//! An average hides the only thing an implementer needs — *which* kinds of defect
14//! a reviewer can see, so they know which to target and which to leave to
15//! something else. The corpus's largest class, `contract-drift`, is also the one a
16//! diff-only reviewer is least equipped for, since the doc making a claim and the
17//! code breaking it need not be adjacent; that is a fact about classes, invisible
18//! in a mean.
19//!
20//! Read [`Score::per_class`] with [`ClassRecall::real`] in view: most classes hold
21//! a single row, so their recall is one bit, not a rate. [`Score::caveats`] says so
22//! in the report rather than leaving a reader to infer it.
23//!
24//! # Recall is computable here. Precision is not — and the difference matters
25//!
26//! **Recall is well defined.** For each row the corpus marks `real`, either the
27//! candidate found that defect or it did not.
28//!
29//! **Precision is not**, and assuming otherwise is the most inviting error in this
30//! module. The corpus is a complete record of *what one reviewer said* about those
31//! trees — **not** a complete inventory of the defects in them. So a candidate
32//! finding that matches no row is **unadjudicated**, not false: it may be a
33//! genuine defect that the original reviewer never mentioned. Counting it as a
34//! false positive would understate a better reviewer precisely for being better.
35//!
36//! What this module therefore reports is three separate numbers, named so they
37//! cannot be blurred together:
38//!
39//! - [`ClassRecall`] — per class, over the real rows.
40//! - [`Score::known_false_reproduced`] — of the rows known to be false, how many
41//!   the candidate repeated. This is the *measured* precision signal, and the only
42//!   one the corpus licenses.
43//! - [`Score::unadjudicated`] — findings the corpus cannot judge. Not precision;
44//!   it is the human-cost proxy, because each one costs somebody a real
45//!   investigation, and it becomes precision only once a human adjudicates it and
46//!   the rows are added to the corpus.
47//!
48//! [`Score::corpus_precision`] is offered over the adjudicated findings alone and
49//! returns `None` when there are none, rather than a flattering `1.0`.
50//!
51//! # Scoring at the wrong commit reports zero, quietly
52//!
53//! Every row carries the commit the comment was made against. The merged PR head
54//! contains the *fix* commits, so a candidate run against it is asked to find
55//! defects that are no longer there and will appear to have missed all of them —
56//! a silent zero, not an error. [`CandidateRun`] therefore records which shas were
57//! *attempted*, [`score`] refuses a run naming a sha the corpus does not know
58//! (overwhelmingly a PR head), and rows outside the attempted set are excluded
59//! from the denominator rather than counted as misses.
60
61use std::collections::{BTreeMap, BTreeSet};
62
63use serde::{Deserialize, Serialize};
64
65use crate::review_corpus::{CLASSES, Corpus, CorpusRow, DefectClass, Verdict};
66
67/// How far from a row's line a candidate finding may be anchored and still count
68/// as the same defect.
69///
70/// Not zero, because a reviewer's cited line can be wrong while its point is
71/// right — `docs/REVIEW_CHECKLIST.md` has a rule for exactly that case — so an
72/// exact-line match would score a correct finding as a miss. Not wide, because a
73/// window long enough to span unrelated code turns "commented on the file" into
74/// "found the defect". Ten lines keeps the three `vacuous-test` rows of #299
75/// distinct (they sit 50 lines apart), which
76/// `the_window_bounds_which_row_a_distant_finding_can_claim` holds it to.
77pub const LINE_WINDOW: u32 = 10;
78
79/// A finding a candidate reviewer offered, in the corpus's coordinate system.
80///
81/// Not [`crate::findings::Finding`]: that models a persisted *analyzer* result
82/// owned by an `AnalysisRun` with a runner, an isolation mode and an advisory-db
83/// digest (ADR-0012). A candidate finding is an ephemeral opinion about one line
84/// of one commit, and scoring it must stay a pure function.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct CandidateFinding {
88    /// The commit the candidate was looking at — a corpus `reviewed_sha`.
89    pub reviewed_sha: String,
90    /// Repository-relative path the finding is anchored to.
91    pub path: String,
92    /// Line in that file.
93    pub line: u32,
94    /// What the candidate said, for a human reading the score's misses and
95    /// unadjudicated findings.
96    pub description: String,
97    /// Whether the finding asserts the code will not build. Declared by the
98    /// candidate rather than guessed from the prose here, so that the suppression
99    /// rule in [`crate::compile_claim`] and the score agree about which findings
100    /// are compile claims.
101    #[serde(default)]
102    pub claims_compile_failure: bool,
103    /// The class the candidate assigned, if it assigned one. Recall does **not**
104    /// require agreement: a reviewer that finds the defect and mislabels it has
105    /// still found it, and [`ClassRecall::misclassified`] records the disagreement
106    /// separately.
107    #[serde(default)]
108    pub defect_class: Option<DefectClass>,
109}
110
111/// Where a whole-change verdict comes down: nothing to push back on, or something
112/// to.
113///
114/// Two values and no third, and deliberately not `#[non_exhaustive]`.
115///
116/// A scale would invite a threshold, and a threshold on a generation is the
117/// probabilistic gate this issue exists to keep out of `review`. The only
118/// distinction the corpus can adjudicate is whether a verdict **claimed the
119/// change was clean** — see [`Score::verdicts_contradicted`] — and that is a bit.
120///
121/// The set is closed for a second reason that outlives the first: this is a
122/// **wire type**. It is deserialised from a `roteiro.review-run/v1` document, so
123/// a third variant is a breaking change for every reader of that schema whatever
124/// this attribute says. Marking it open would let a match arm compile while the
125/// document it came from could not be read — a compile-time promise the format
126/// cannot keep. A new stance belongs at a `/v2` bump.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "kebab-case")]
129pub enum VerdictStance {
130    /// The reviewer found nothing it would push back on.
131    Clean,
132    /// The reviewer has something it would push back on.
133    Concerns,
134}
135
136impl VerdictStance {
137    /// The stable token used in the run document and in the model's own output
138    /// contract, so the prompt, the parser and the JSON cannot disagree.
139    #[must_use]
140    pub fn as_str(self) -> &'static str {
141        match self {
142            Self::Clean => "clean",
143            Self::Concerns => "concerns",
144        }
145    }
146
147    /// Read a stance token, or `None` if it is not one.
148    #[must_use]
149    pub fn from_token(token: &str) -> Option<Self> {
150        match token {
151            "clean" => Some(Self::Clean),
152            "concerns" => Some(Self::Concerns),
153            _ => None,
154        }
155    }
156}
157
158/// **A model's judgement over a whole change**, as opposed to its per-file
159/// findings (issue #649, part 2).
160///
161/// # It is an opinion, and it is carried here so it can be measured
162///
163/// The verdict is never wired to an exit status —
164/// [`crate::review_score`] is a scorer, and `ReviewReport::has_drift` remains the
165/// only thing `review` gates on. It lives in the run document for the reason the
166/// findings do: a summary nobody has scored is an opinion with a confident tone,
167/// and shipping one in the single shape `--score` cannot read would have made it
168/// permanently unmeasurable.
169///
170/// # It is deliberately not a `CandidateFinding`
171///
172/// A finding is anchored to a line and is scored against a corpus row. A verdict
173/// is anchored to nothing and answers a different question. Modelling it as a
174/// finding with `line: 0` would have put it into the recall denominator, where it
175/// would be counted as a defect the reviewer claimed to detect and missed.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct CandidateVerdict {
179    /// The commit the verdict is about — a corpus `reviewed_sha`.
180    pub reviewed_sha: String,
181    /// Whether it claims the change is clean.
182    pub stance: VerdictStance,
183    /// What it said, for a human reading the score. Kept verbatim: the prose is
184    /// the whole of what a verdict adds over a finding count.
185    pub summary: String,
186}
187
188/// One candidate reviewer's whole run.
189///
190/// `attempted_shas` is not derivable from the findings, and the difference is the
191/// point: a commit that was reviewed and yielded nothing is a miss, while a commit
192/// that was never reviewed is outside the measurement. Conflating them turns a
193/// partial run into a bad score.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct CandidateRun {
197    /// Stable schema tag, so a document says what it is and a mistyped one fails
198    /// with that as the message rather than as a missing field.
199    #[serde(default = "run_schema")]
200    pub schema: String,
201    /// Which commits the candidate was actually run against.
202    pub attempted_shas: BTreeSet<String>,
203    /// Every finding it offered.
204    pub findings: Vec<CandidateFinding>,
205    /// Findings the candidate withheld under [`crate::compile_claim`], reported so
206    /// that a suppression that discarded a *true* finding shows up as a miss with
207    /// an explanation instead of an unexplained one.
208    #[serde(default)]
209    pub suppressed: Vec<CandidateFinding>,
210    /// **The whole-change judgement**, at most one per attempted commit (issue
211    /// #649, part 2).
212    ///
213    /// Carried here rather than left to the text output because the alternative
214    /// was shipping the verdict in the one shape `--score` cannot read, which
215    /// would have made it the only part of the reviewer nobody could ever
216    /// measure. [`Score::verdicts_contradicted`] is what the corpus can say about
217    /// it.
218    ///
219    /// Optional on exactly the terms [`CandidateRun::arm`] is: every `v1`
220    /// document written before this field existed still parses, and a build older
221    /// than this one refuses a document carrying it rather than silently dropping
222    /// the judgement.
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub verdicts: Vec<CandidateVerdict>,
225    /// **Which arm produced this run**, when the producer knows — the context it
226    /// was given and the model that generated it.
227    ///
228    /// Stage 35b PR 2 is a comparison of two runs that must differ in exactly one
229    /// variable, and a reader has to be able to check that claim from the
230    /// artifacts rather than from their filenames. A run document that cannot say
231    /// which arm it is makes the comparison unauditable, and mixing two up would
232    /// produce a clean, meaningless number of exactly the kind this stage is
233    /// arranged against.
234    ///
235    /// Optional, so every `v1` document written before this field existed still
236    /// parses unchanged. The reverse does not hold: `deny_unknown_fields` means a
237    /// build older than this one rejects a document carrying it. That is the
238    /// deliberate trade — a run whose provenance an old binary silently dropped
239    /// would be worse than one it refuses to read.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub arm: Option<RunArm>,
242}
243
244/// What produced a [`CandidateRun`]: the context arm and the model.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(deny_unknown_fields)]
247pub struct RunArm {
248    /// The context the reviewer was given — `diff-only` or `graph`.
249    pub context: String,
250    /// The model that generated it, so a comparison across arms can be shown to
251    /// have held it fixed.
252    pub model: String,
253}
254
255/// Schema tag for a [`CandidateRun`] document.
256pub const RUN_SCHEMA: &str = "roteiro.review-run/v1";
257
258/// `serde` default for [`CandidateRun::schema`].
259fn run_schema() -> String {
260    RUN_SCHEMA.to_owned()
261}
262
263/// Written out rather than derived so that a default-constructed run carries the
264/// real schema tag: `#[derive(Default)]` would give it the empty string, and a
265/// value that cannot round-trip through [`CandidateRun::parse`] is a trap.
266impl Default for CandidateRun {
267    fn default() -> Self {
268        Self {
269            schema: run_schema(),
270            attempted_shas: BTreeSet::new(),
271            findings: Vec::new(),
272            suppressed: Vec::new(),
273            verdicts: Vec::new(),
274            arm: None,
275        }
276    }
277}
278
279impl CandidateRun {
280    /// Parse a run document, checking its schema tag.
281    ///
282    /// # Errors
283    /// [`ScoreError::Unreadable`] when the JSON does not match, and
284    /// [`ScoreError::WrongSchema`] when it declares a different contract.
285    pub fn parse(text: &str) -> Result<Self, ScoreError> {
286        let run: Self = serde_json::from_str(text).map_err(|e| ScoreError::Unreadable {
287            message: e.to_string(),
288        })?;
289        if run.schema != RUN_SCHEMA {
290            return Err(ScoreError::WrongSchema { got: run.schema });
291        }
292        Ok(run)
293    }
294}
295
296/// Why a run could not be scored.
297#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
298pub enum ScoreError {
299    /// A finding, or an attempted sha, names a commit the corpus does not know.
300    ///
301    /// Almost always the merged PR head rather than the comment's
302    /// `original_commit_id` — the mistake that measures recall on already-fixed
303    /// code and reports zero without complaining. Refused rather than ignored.
304    #[error(
305        "{what} names commit {sha}, which is in no corpus row. The corpus is keyed \
306         by each comment's `reviewed_sha` (its `original_commit_id`); a merged PR \
307         head contains the fix commits, so scoring against one measures recall on \
308         code that is already repaired and silently reports zero"
309    )]
310    UnknownSha {
311        /// Which input named it (`a finding`, `attempted_shas`).
312        what: &'static str,
313        /// The offending commit.
314        sha: String,
315    },
316    /// A finding names a commit that the run did not declare as attempted.
317    #[error(
318        "a finding names commit {sha}, which is not in `attempted_shas` — the \
319         attempted set decides the denominator, so it must list every commit the \
320         candidate reviewed"
321    )]
322    UndeclaredSha {
323        /// The offending commit.
324        sha: String,
325    },
326    /// Two whole-change verdicts name the same commit.
327    ///
328    /// A verdict is a judgement of one change, so a second one for the same
329    /// commit is the candidate contradicting itself — and counting both would
330    /// double whatever [`Score::verdicts_contradicted`] says about that commit,
331    /// which is a number a reader acts on. Refused rather than de-duplicated,
332    /// because picking one of two contradictory judgements is a decision this
333    /// scorer has no basis for.
334    #[error(
335        "two verdicts name commit {sha}. A verdict is a judgement of one change, \
336         so a run may carry at most one per commit; two is the candidate \
337         contradicting itself, and there is no basis here for choosing between them"
338    )]
339    DuplicateVerdict {
340        /// The commit judged twice.
341        sha: String,
342    },
343    /// The run attempted no commit the corpus knows, so there is nothing to score.
344    #[error("the run attempted no commit, so there is nothing to score")]
345    NothingAttempted,
346    /// The run document is not a [`CandidateRun`].
347    #[error("not a `{RUN_SCHEMA}` document: {message}")]
348    Unreadable {
349        /// The `serde_json` message, which names the offending field.
350        message: String,
351    },
352    /// The document declares a schema this build does not implement.
353    #[error("run document declares schema {got:?}, but this build scores `{RUN_SCHEMA}`")]
354    WrongSchema {
355        /// The tag as declared.
356        got: String,
357    },
358}
359
360/// A real defect the candidate did not find, with enough to go and look at it.
361///
362/// The comment id alone would not do that: a score document is read by a person
363/// deciding what to improve, and "you missed 3789173576" sends them to grep the
364/// corpus. Carrying the anchor duplicates three fields out of the corpus, which is
365/// the right trade — a report that needs a second file opened to be actionable is
366/// a worse contract than a slightly larger one.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
368pub struct Missed {
369    /// Comment id — the corpus primary key.
370    pub id: u64,
371    /// Path the missed defect is anchored to.
372    pub path: String,
373    /// Line in that file.
374    pub line: u32,
375    /// One line stating what the defect was.
376    pub description: String,
377    /// Permalink to the original comment.
378    pub comment_url: String,
379}
380
381/// Recall over one defect class, and the class-level detail an implementer needs.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
383pub struct ClassRecall {
384    /// The class.
385    pub class: DefectClass,
386    /// Real rows of this class **within the attempted commits** — the denominator.
387    /// Read every rate against it: a `1` here is one bit of evidence, not a rate.
388    pub real: usize,
389    /// How many of those the candidate found.
390    pub found: usize,
391    /// Of the found ones, how many the candidate labelled as a different class.
392    /// Finding it still counts; the label is reported so a reviewer whose classes
393    /// are systematically wrong is visible.
394    pub misclassified: usize,
395    /// The real defects of this class the candidate missed — what to read next.
396    pub missed: Vec<Missed>,
397}
398
399impl ClassRecall {
400    /// Recall as a fraction, or `None` when the class has no real row within the
401    /// attempted commits (a class with an empty denominator has no recall, and
402    /// reporting `0.0` for it would read as a failure).
403    #[must_use]
404    #[expect(
405        clippy::cast_precision_loss,
406        reason = "counts here are corpus rows — 26 today, and a corpus large \
407                  enough to lose f64 precision would have other problems"
408    )]
409    pub fn recall(&self) -> Option<f64> {
410        (self.real > 0).then(|| self.found as f64 / self.real as f64)
411    }
412}
413
414/// A candidate's score against the corpus.
415// `Eq` is not derived: `expected_by_position` is an `f64` estimate, and a score
416// that could be compared for exact equality would invite a test that pins a
417// float. `PartialEq` is enough for the equality the tests actually want.
418#[derive(Debug, Clone, PartialEq, Serialize)]
419pub struct Score {
420    /// Stable schema tag for `--json` consumers.
421    pub schema: &'static str,
422    /// How many corpus commits the run attempted.
423    pub attempted_shas: usize,
424    /// How many commits the corpus holds in total — so a partial run reads as
425    /// partial rather than as a poor one.
426    pub corpus_shas: usize,
427    /// Per class, in [`CLASSES`] order. Classes with no real row in the attempted
428    /// commits are present with `real: 0`, so the shape of the report does not
429    /// change with the run.
430    pub per_class: Vec<ClassRecall>,
431    /// Real rows found, across every class. A total, **not** a rate: the per-class
432    /// table is the result.
433    pub found: usize,
434    /// Real rows in scope.
435    pub real_in_scope: usize,
436    /// Of the corpus's known-false rows in scope, how many the candidate repeated.
437    /// The only measured precision signal the corpus licenses.
438    pub known_false_reproduced: usize,
439    /// Known-false rows in scope.
440    pub known_false_in_scope: usize,
441    /// Findings matching no row. **Not false positives** — unadjudicated. See the
442    /// module docs.
443    pub unadjudicated: usize,
444    /// Findings the candidate withheld that would have matched a **real** row —
445    /// the cost of the suppression filter, which must be reported rather than
446    /// hidden, since a filter that discards true findings is worse than none.
447    pub suppressed_real: usize,
448    /// Findings the candidate withheld that would have matched a **known-false**
449    /// row — the filter earning its keep.
450    pub suppressed_known_false: usize,
451    /// Findings withheld that match no row at all.
452    pub suppressed_unadjudicated: usize,
453    /// **How many real rows a candidate of this shape would match by position
454    /// alone** — the chance baseline [`Score::found`] has to beat.
455    ///
456    /// [`match_findings`] credits a finding to a row on `(sha, path, line within
457    /// LINE_WINDOW)` and **nothing else**: not the defect class, not a word of the
458    /// description. That is the right rule for a scorer that must not reward
459    /// eloquence, but it has a consequence nobody had measured. A reviewer that
460    /// emits enough findings per file blankets the diff, and then a "hit" is
461    /// explained by density rather than by insight — the recall figure looks like
462    /// a measurement and is arithmetic.
463    ///
464    /// Measured on this repository's own reviewer, that is not a hypothetical: at
465    /// **10.9 findings per file** the diff-only arm scored **4 of 22**, against a
466    /// permutation null — every row relocated to a random line its diff actually
467    /// shows, the findings left exactly as emitted — of **4.19**. It scored
468    /// *below* chance, and P(≥ observed) was 0.72.
469    ///
470    /// So this is reported beside the recall, always, in the same way
471    /// `reasoning_truncated` is reported beside a zero: a number whose null is not
472    /// stated is not yet a result.
473    ///
474    /// # An approximation, and it says so rather than implying precision
475    ///
476    /// **The exact null needs the diff** — which lines the reviewer was shown —
477    /// and scoring is pure by design, with no git and no network, so that any
478    /// machine can recompute a published score. So this uses the candidate's
479    /// **own findings** as the proxy for where it looked: on each file carrying a
480    /// row, the fraction of the line range those findings span that their merged
481    /// `LINE_WINDOW` neighbourhoods cover.
482    ///
483    /// That proxy is wrong in both directions at once. The findings' own span is
484    /// narrower than the diff's, which pushes the estimate up; ignoring the
485    /// one-to-one competition between two rows on a file also pushes it up; and
486    /// the reviewer's silence at the edges of a diff pushes it down. Calibrated
487    /// against an exact permutation on this repository's diff-only run — every row
488    /// relocated to a random line its diff actually shows — this reads **3.0**
489    /// where the permutation reads **4.19** against an observed **4**.
490    ///
491    /// So it is an order-of-magnitude guide, not a null, and
492    /// [`Score::caveats`] fires on a **margin** rather than a strict comparison
493    /// for exactly that reason. Anyone comparing two candidates seriously should
494    /// run the permutation, which needs the diff and therefore does not belong in
495    /// a pure scorer.
496    ///
497    /// `None` when the run offered no findings on any file carrying a row, since
498    /// there is then nothing whose density to describe.
499    pub expected_by_position: Option<f64>,
500    /// Whole-change verdicts the run offered on commits in scope (issue #649).
501    pub verdicts: usize,
502    /// **Verdicts that declared a change clean which the corpus knows carries a
503    /// real defect** — the one thing the corpus can adjudicate about a
504    /// whole-change judgement, and the failure that matters.
505    ///
506    /// A confident *"nothing to push back on"* over a change with an adjudicated
507    /// defect in it is worse than a missed finding: a missed finding is silence,
508    /// while this is a positive claim a reader may act on. It is computed purely,
509    /// from data already in the corpus, so it recomputes on any machine like every
510    /// other number here.
511    ///
512    /// A `concerns` verdict is **not** scored against anything. The corpus records
513    /// what one reviewer said about these trees, not every defect in them, so a
514    /// `concerns` verdict on a commit with no adjudicated row is unadjudicated in
515    /// exactly the sense [`Score::unadjudicated`] is — see
516    /// [`Score::verdicts_unadjudicated`].
517    pub verdicts_contradicted: usize,
518    /// Verdicts the corpus cannot judge: every `concerns` verdict, and every
519    /// `clean` verdict on a commit the corpus holds no **real** row for.
520    ///
521    /// Named rather than folded into a rate, for the reason the module's
522    /// precision discussion gives: the corpus is not an inventory of the defects
523    /// in these trees, so "clean, and the corpus knows of nothing" is not evidence
524    /// that the change was clean.
525    pub verdicts_unadjudicated: usize,
526}
527
528/// Schema tag for a serialised [`Score`].
529pub const SCORE_SCHEMA: &str = "roteiro.review-score/v1";
530
531impl Score {
532    /// Precision over the **adjudicated** findings only: matched-real ÷
533    /// (matched-real + reproduced-known-false).
534    ///
535    /// `None` when the candidate produced no adjudicated finding, rather than a
536    /// flattering `1.0` computed from nothing. This is not precision over the
537    /// candidate's output — see [`Score::unadjudicated`], which is the rest of it
538    /// and is not counted here because the corpus cannot judge it.
539    #[must_use]
540    #[expect(
541        clippy::cast_precision_loss,
542        reason = "counts here are corpus rows; see ClassRecall::recall"
543    )]
544    pub fn corpus_precision(&self) -> Option<f64> {
545        let adjudicated = self.found + self.known_false_reproduced;
546        (adjudicated > 0).then(|| self.found as f64 / adjudicated as f64)
547    }
548
549    /// The caveats that must accompany these numbers, as sentences a report
550    /// prints.
551    ///
552    /// Emitted with the score rather than left to a reader's memory, because every
553    /// one of them is a way the numbers can be honestly stated and dishonestly
554    /// read. A run that covered three commits out of thirteen says so; a class
555    /// whose denominator is one says so.
556    #[must_use]
557    pub fn caveats(&self) -> Vec<String> {
558        let mut out = Vec::new();
559        if self.attempted_shas < self.corpus_shas {
560            out.push(format!(
561                "partial run: {} of {} corpus commits attempted, so rows on the \
562                 other {} are excluded from every denominator rather than counted \
563                 as misses",
564                self.attempted_shas,
565                self.corpus_shas,
566                self.corpus_shas - self.attempted_shas
567            ));
568        }
569        let thin: Vec<&str> = self
570            .per_class
571            .iter()
572            .filter(|c| c.real == 1)
573            .map(|c| c.class.as_str())
574            .collect();
575        if !thin.is_empty() {
576            out.push(format!(
577                "{} class(es) have a single real row ({}), so their recall is one \
578                 bit rather than a rate and should not be compared as a percentage",
579                thin.len(),
580                thin.join(", ")
581            ));
582        }
583        // A margin rather than a strict comparison, because the baseline is an
584        // approximation that misses in both directions — see the field's docs. A
585        // result only a little above an uncertain null is exactly the case a
586        // reader needs warning about, so the guard is deliberately loose.
587        #[expect(
588            clippy::cast_precision_loss,
589            reason = "a count of corpus rows found; see ClassRecall::recall"
590        )]
591        let found = self.found as f64;
592        if let Some(expected) = self.expected_by_position
593            && found <= expected * 2.0
594        {
595            out.push(format!(
596                "RECALL IS NOT CLEARLY ABOVE CHANCE AT THIS FINDING DENSITY: a \
597                 candidate emitting these findings in these places would match \
598                 ~{expected:.1} real row(s) by position alone, and this one matched \
599                 {}. Scoring credits a finding to a row on (commit, path, line \
600                 \u{b1}{}) and NEVER on what the finding says, so a reviewer dense \
601                 enough to blanket a diff scores recall it did not earn. That \
602                 baseline is approximate; confirm with a permutation null before \
603                 comparing two candidates, and lower the finding rate first",
604                self.found, LINE_WINDOW
605            ));
606        }
607        if self.unadjudicated > 0 {
608            out.push(format!(
609                "{} finding(s) match no corpus row. These are UNADJUDICATED, not \
610                 false positives — the corpus records what one reviewer said about \
611                 these trees, not every defect in them. They become a precision \
612                 figure only once a human adjudicates them and the rows are added",
613                self.unadjudicated
614            ));
615        }
616        if self.suppressed_real > 0 {
617            out.push(format!(
618                "the suppression filter withheld {} finding(s) that match a REAL \
619                 row — it is discarding true findings and its licence (zero cost \
620                 on this corpus) no longer holds",
621                self.suppressed_real
622            ));
623        }
624        // Loud, and phrased as the positive claim it is. A missed finding is
625        // silence; a `clean` verdict over a commit with an adjudicated defect is
626        // an assertion a reader may act on, and it is the reason the verdict is
627        // carried into the run document at all.
628        if self.verdicts_contradicted > 0 {
629            out.push(format!(
630                "{} WHOLE-CHANGE VERDICT(S) DECLARED A CHANGE CLEAN THAT THE CORPUS \
631                 KNOWS CARRIES A REAL DEFECT. A verdict is a model's opinion and \
632                 gates nothing, but this one is a positive claim contradicted by \
633                 adjudicated evidence, which is worse than a missed finding: a miss \
634                 is silence, and this is a reader being told there is nothing to \
635                 look at",
636                self.verdicts_contradicted
637            ));
638        }
639        if self.verdicts_unadjudicated > 0 {
640            out.push(format!(
641                "{} whole-change verdict(s) the corpus cannot judge — every \
642                 `concerns` verdict, and every `clean` one on a commit with no real \
643                 row. The corpus records what one reviewer said about these trees, \
644                 not every defect in them, so `clean` here is NOT evidence the \
645                 change was clean",
646                self.verdicts_unadjudicated
647            ));
648        }
649        out
650    }
651}
652
653/// Refuse a run whose commits the corpus does not know, or that the run did not
654/// declare as attempted.
655///
656/// Applied to findings, suppressed findings **and** verdicts alike: a judgement
657/// against a merged PR head is a judgement of already-repaired code, and one
658/// outside the attempted set is a judgement of a commit this run never looked at.
659/// Either would be scored as though it meant something.
660fn check_shas(known: &BTreeSet<&str>, run: &CandidateRun) -> Result<(), ScoreError> {
661    if run.attempted_shas.is_empty() {
662        return Err(ScoreError::NothingAttempted);
663    }
664    for sha in &run.attempted_shas {
665        if !known.contains(sha.as_str()) {
666            return Err(ScoreError::UnknownSha {
667                what: "attempted_shas",
668                sha: sha.clone(),
669            });
670        }
671    }
672    let claimed = run
673        .findings
674        .iter()
675        .chain(&run.suppressed)
676        .map(|f| ("a finding", &f.reviewed_sha))
677        .chain(run.verdicts.iter().map(|v| ("a verdict", &v.reviewed_sha)));
678    for (what, sha) in claimed {
679        if !known.contains(sha.as_str()) {
680            return Err(ScoreError::UnknownSha {
681                what,
682                sha: sha.clone(),
683            });
684        }
685        if !run.attempted_shas.contains(sha) {
686            return Err(ScoreError::UndeclaredSha { sha: sha.clone() });
687        }
688    }
689    // At most one verdict per commit — the shape `docs/JSON_SCHEMA.md` states, now
690    // enforced rather than assumed. Findings are deliberately *not* held to this:
691    // a reviewer may say several things about one commit, and each is scored
692    // against its own row. A verdict is one judgement of one change.
693    let mut judged: BTreeSet<&str> = BTreeSet::new();
694    for verdict in &run.verdicts {
695        if !judged.insert(verdict.reviewed_sha.as_str()) {
696            return Err(ScoreError::DuplicateVerdict {
697                sha: verdict.reviewed_sha.clone(),
698            });
699        }
700    }
701    Ok(())
702}
703
704/// How many `clean` verdicts the corpus contradicts: those over a commit it holds
705/// a **real** row for.
706///
707/// The only thing this scorer can say about a whole-change judgement without a
708/// human, and deliberately the *only* thing it says — a `concerns` verdict is
709/// matched against nothing, because the corpus records what one reviewer said
710/// about these trees rather than every defect in them. See
711/// [`Score::verdicts_contradicted`].
712fn contradicted_verdicts(in_scope: &[&CorpusRow], verdicts: &[CandidateVerdict]) -> usize {
713    let with_a_real_row: BTreeSet<&str> = in_scope
714        .iter()
715        .filter(|r| r.verdict == Verdict::Real)
716        .map(|r| r.reviewed_sha.as_str())
717        .collect();
718    verdicts
719        .iter()
720        .filter(|v| {
721            v.stance == VerdictStance::Clean && with_a_real_row.contains(v.reviewed_sha.as_str())
722        })
723        .count()
724}
725
726/// Score `run` against `corpus`.
727///
728/// Matching is **one to one**: each row is credited to at most one finding and
729/// each finding to at most one row, resolved by nearest line and then by lowest
730/// comment id, so the result does not depend on the order the candidate emitted
731/// its findings in.
732///
733/// Whole-change verdicts are scored separately and never enter the recall
734/// figures — see [`Score::verdicts_contradicted`].
735///
736/// # Errors
737/// [`ScoreError::UnknownSha`] when a sha is in no corpus row (the PR-head
738/// mistake), [`ScoreError::UndeclaredSha`] when a finding or verdict is outside
739/// the attempted set, and [`ScoreError::NothingAttempted`] for an empty run.
740pub fn score(corpus: &Corpus, run: &CandidateRun) -> Result<Score, ScoreError> {
741    let known: BTreeSet<&str> = corpus.reviewed_shas();
742    check_shas(&known, run)?;
743
744    let in_scope: Vec<&CorpusRow> = corpus
745        .rows()
746        .iter()
747        .filter(|r| run.attempted_shas.contains(&r.reviewed_sha))
748        .collect();
749
750    let emitted = match_findings(&in_scope, &run.findings);
751    let withheld = match_findings(&in_scope, &run.suppressed);
752
753    let mut per_class: Vec<ClassRecall> = Vec::with_capacity(CLASSES.len());
754    for class in CLASSES {
755        let rows: Vec<&&CorpusRow> = in_scope
756            .iter()
757            .filter(|r| r.defect_class == class && r.verdict == Verdict::Real)
758            .collect();
759        let mut found = 0;
760        let mut misclassified = 0;
761        let mut missed = Vec::new();
762        for row in &rows {
763            match emitted.by_row.get(&row.id) {
764                Some(finding) => {
765                    found += 1;
766                    if finding.defect_class.is_some_and(|c| c != class) {
767                        misclassified += 1;
768                    }
769                }
770                None => missed.push(Missed {
771                    id: row.id,
772                    path: row.path.clone(),
773                    line: row.line,
774                    description: row.description.clone(),
775                    comment_url: row.comment_url.clone(),
776                }),
777            }
778        }
779        per_class.push(ClassRecall {
780            class,
781            real: rows.len(),
782            found,
783            misclassified,
784            missed,
785        });
786    }
787
788    let real_in_scope = in_scope
789        .iter()
790        .filter(|r| r.verdict == Verdict::Real)
791        .count();
792    let known_false_in_scope = in_scope
793        .iter()
794        .filter(|r| r.verdict == Verdict::False)
795        .count();
796    // Verdict by row id, so counting matches is a lookup rather than a scan — and
797    // so a match against an id somehow outside scope is simply not counted rather
798    // than a panic. Matching only ever draws from `in_scope`, so the two agree;
799    // this shape means a future change that broke that would produce a low count
800    // instead of a crash in a scorer.
801    let verdicts: BTreeMap<u64, Verdict> = in_scope.iter().map(|r| (r.id, r.verdict)).collect();
802    let count_by_verdict = |m: &Matched, want: Verdict| {
803        m.by_row
804            .keys()
805            .filter(|id| verdicts.get(id) == Some(&want))
806            .count()
807    };
808
809    let verdicts_contradicted = contradicted_verdicts(&in_scope, &run.verdicts);
810
811    Ok(Score {
812        schema: SCORE_SCHEMA,
813        verdicts: run.verdicts.len(),
814        verdicts_contradicted,
815        verdicts_unadjudicated: run.verdicts.len() - verdicts_contradicted,
816        attempted_shas: run.attempted_shas.len(),
817        corpus_shas: known.len(),
818        per_class,
819        found: count_by_verdict(&emitted, Verdict::Real),
820        real_in_scope,
821        known_false_reproduced: count_by_verdict(&emitted, Verdict::False),
822        known_false_in_scope,
823        unadjudicated: run.findings.len() - emitted.by_row.len(),
824        suppressed_real: count_by_verdict(&withheld, Verdict::Real),
825        suppressed_known_false: count_by_verdict(&withheld, Verdict::False),
826        suppressed_unadjudicated: run.suppressed.len() - withheld.by_row.len(),
827        expected_by_position: expected_by_position(&in_scope, &run.findings),
828    })
829}
830
831/// The chance baseline described on [`Score::expected_by_position`].
832///
833/// For each **real** row, the probability that a row dropped uniformly across the
834/// span its file's findings cover would land within [`LINE_WINDOW`] of at least
835/// one of them, capped at 1. Summed, that is how many rows a candidate of this
836/// shape matches without knowing anything.
837#[expect(
838    clippy::cast_precision_loss,
839    reason = "line numbers and finding counts on one file; a file long enough to               lose f64 precision is not reviewable at all"
840)]
841fn expected_by_position(rows: &[&CorpusRow], findings: &[CandidateFinding]) -> Option<f64> {
842    let mut by_file: BTreeMap<(&str, &str), Vec<u32>> = BTreeMap::new();
843    for f in findings {
844        by_file
845            .entry((f.reviewed_sha.as_str(), f.path.as_str()))
846            .or_default()
847            .push(f.line);
848    }
849    let mut total = 0.0;
850    let mut any = false;
851    for row in rows.iter().filter(|r| r.verdict == Verdict::Real) {
852        let Some(lines) = by_file.get(&(row.reviewed_sha.as_str(), row.path.as_str())) else {
853            continue;
854        };
855        any = true;
856        let (lo, hi) = (
857            lines.iter().copied().min().unwrap_or(0),
858            lines.iter().copied().max().unwrap_or(0),
859        );
860        // The span the candidate's own attention covered. A single finding spans
861        // one line, so the window itself is the whole space and the row is certain
862        // to match — which is correct: a file the candidate commented on once, at
863        // one point, offers a row nowhere else to be.
864        let span = f64::from(hi - lo + 1);
865        // **Merged, not summed.** At ten findings a file the ±10 windows overlap
866        // heavily, and counting each one whole inflates the baseline by about half
867        // — measured on this repository, 6.2 against a permutation's 4.19. The
868        // union is what a row can actually land in.
869        let mut sorted = lines.clone();
870        sorted.sort_unstable();
871        let mut covered = 0u64;
872        let mut open: Option<(u32, u32)> = None;
873        for line in sorted {
874            let (start, end) = (line.saturating_sub(LINE_WINDOW), line + LINE_WINDOW);
875            match open {
876                Some((s, e)) if start <= e + 1 => open = Some((s, e.max(end))),
877                Some((s, e)) => {
878                    covered += u64::from(e - s + 1);
879                    open = Some((start, end));
880                }
881                None => open = Some((start, end)),
882            }
883        }
884        if let Some((s, e)) = open {
885            covered += u64::from(e - s + 1);
886        }
887        let reach = covered as f64;
888        total += (reach / span).min(1.0);
889    }
890    any.then_some(total)
891}
892
893/// The outcome of matching: row id → the finding credited to it.
894struct Matched<'a> {
895    by_row: BTreeMap<u64, &'a CandidateFinding>,
896}
897
898/// Credit findings to rows, one to one.
899///
900/// Candidate pairs are ranked by line distance, then row id, then the finding's
901/// own line — a total order over the pairs, so the greedy pass is deterministic
902/// and independent of input order.
903fn match_findings<'a>(rows: &[&'a CorpusRow], findings: &'a [CandidateFinding]) -> Matched<'a> {
904    let mut pairs: Vec<(u32, u64, u32, usize)> = Vec::new();
905    for (idx, finding) in findings.iter().enumerate() {
906        for row in rows {
907            if row.reviewed_sha != finding.reviewed_sha || row.path != finding.path {
908                continue;
909            }
910            let distance = row.line.abs_diff(finding.line);
911            if distance <= LINE_WINDOW {
912                pairs.push((distance, row.id, finding.line, idx));
913            }
914        }
915    }
916    pairs.sort_unstable();
917
918    let mut by_row: BTreeMap<u64, &CandidateFinding> = BTreeMap::new();
919    let mut used: BTreeSet<usize> = BTreeSet::new();
920    for (_, row_id, _, idx) in pairs {
921        if by_row.contains_key(&row_id) || used.contains(&idx) {
922            continue;
923        }
924        by_row.insert(row_id, &findings[idx]);
925        used.insert(idx);
926    }
927    Matched { by_row }
928}
929
930#[cfg(test)]
931mod tests {
932    use super::{
933        CandidateFinding, CandidateRun, CandidateVerdict, LINE_WINDOW, RUN_SCHEMA, SCORE_SCHEMA,
934        ScoreError, VerdictStance, score,
935    };
936    use crate::review_corpus::{Corpus, DefectClass, Verdict};
937
938    const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
939    const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
940
941    /// A corpus row as JSONL.
942    fn row(id: u64, sha: &str, path: &str, line: u32, verdict: &str, class: &str) -> String {
943        format!(
944            "{{\"id\": {id}, \"pr\": 300, \"reviewer\": \"github-copilot\", \
945             \"reviewed_sha\": {sha:?}, \"path\": {path:?}, \"line\": {line}, \
946             \"verdict\": {verdict:?}, \"defect_class\": {class:?}, \
947             \"fix_commit\": \"\", \"description\": \"d\", \
948             \"comment_url\": \"https://example.invalid/{id}\"}}"
949        )
950    }
951
952    /// Two commits, five rows: three real of two classes on `SHA_A`, one real and
953    /// one known-false on `SHA_B`.
954    fn corpus() -> Corpus {
955        let text = [
956            row(1, SHA_A, "src/a.rs", 100, "real", "contract-drift"),
957            row(2, SHA_A, "src/a.rs", 200, "real", "contract-drift"),
958            row(3, SHA_A, "src/b.rs", 10, "real", "vacuous-test"),
959            row(4, SHA_B, "src/c.rs", 50, "real", "ordering-bug"),
960            row(5, SHA_B, "src/c.rs", 300, "false", "false-compile-claim"),
961        ]
962        .join("\n");
963        Corpus::parse(&text).expect("the test corpus parses")
964    }
965
966    fn finding(sha: &str, path: &str, line: u32) -> CandidateFinding {
967        CandidateFinding {
968            reviewed_sha: sha.to_owned(),
969            path: path.to_owned(),
970            line,
971            description: "a finding".to_owned(),
972            claims_compile_failure: false,
973            defect_class: None,
974        }
975    }
976
977    /// **A blanketing reviewer must not be credited with recall it did not earn.**
978    ///
979    /// Matching consults `(sha, path, line ±LINE_WINDOW)` and never the text, so a
980    /// candidate that comments densely enough across a file matches rows by
981    /// position. Row 1 sits at line 100; these findings never mention it and are
982    /// spread every few lines around it, so every one of them is "right" by
983    /// arithmetic alone. The chance baseline has to see that.
984    #[test]
985    fn a_blanketing_candidate_is_flagged_as_not_clearly_above_chance() {
986        let dense: Vec<CandidateFinding> = (0..12)
987            .map(|i| finding(SHA_A, "src/a.rs", 90 + i * 3))
988            .collect();
989        let scored = score(&corpus(), &run(&[SHA_A, SHA_B], dense)).expect("scores");
990        let expected = scored
991            .expected_by_position
992            .expect("findings landed on a file carrying a row");
993        assert!(
994            expected > 0.5,
995            "a candidate blanketing a row's file scored a chance baseline of only \
996             {expected}"
997        );
998        assert!(
999            scored
1000                .caveats()
1001                .iter()
1002                .any(|c| c.contains("NOT CLEARLY ABOVE CHANCE")),
1003            "the density caveat did not fire: {:?}",
1004            scored.caveats()
1005        );
1006    }
1007
1008    /// The mirror image: one precise finding on a file, nowhere near a row, is not
1009    /// a blanket — and a candidate that then matches nothing must not be told its
1010    /// zero is a density artefact.
1011    #[test]
1012    fn a_sparse_candidate_that_misses_is_not_blamed_on_density() {
1013        let sparse = vec![finding(SHA_A, "src/a.rs", 100)];
1014        let scored = score(&corpus(), &run(&[SHA_A, SHA_B], sparse)).expect("scores");
1015        // One finding spans one line, so a row has nowhere else to be and the
1016        // baseline is 1.0 for that row — correct, and the documented behaviour.
1017        assert_eq!(scored.found, 1);
1018        assert!(scored.expected_by_position.is_some());
1019    }
1020
1021    /// A run whose findings never touch a file carrying a row has no density to
1022    /// describe, and must report `None` rather than a flattering zero that would
1023    /// read as "comfortably above chance".
1024    #[test]
1025    fn a_run_touching_no_anchored_file_has_no_chance_baseline() {
1026        let elsewhere = vec![finding(SHA_A, "src/nowhere.rs", 10)];
1027        let scored = score(&corpus(), &run(&[SHA_A, SHA_B], elsewhere)).expect("scores");
1028        assert_eq!(scored.expected_by_position, None);
1029        assert!(
1030            !scored
1031                .caveats()
1032                .iter()
1033                .any(|c| c.contains("NOT CLEARLY ABOVE CHANCE")),
1034            "a caveat about density fired with no findings to be dense"
1035        );
1036    }
1037
1038    /// **Overlapping windows are merged, not summed.** Ten findings within a few
1039    /// lines of each other reach barely further than one does; counting each
1040    /// `±LINE_WINDOW` span whole would inflate the baseline by about half, which
1041    /// is how the first version of this read 6.2 where a permutation read 4.19.
1042    #[test]
1043    fn overlapping_windows_count_once() {
1044        // Ten findings packed into 10 lines, plus one far away so the span is wide
1045        // enough for the difference to show. Merged, the reach is [90,119] plus
1046        // [990,1010] = 51 lines of a 911-line span, so each of the two rows on
1047        // this file scores ~0.06. Summed it would be 11 x 21 = 231 lines, ~0.25
1048        // each — four times larger, and the direction that hides a real result.
1049        let mut clustered: Vec<CandidateFinding> = (0..10)
1050            .map(|i| finding(SHA_A, "src/a.rs", 100 + i))
1051            .collect();
1052        clustered.push(finding(SHA_A, "src/a.rs", 1_000));
1053        let scored = score(&corpus(), &run(&[SHA_A, SHA_B], clustered)).expect("scores");
1054        let expected = scored.expected_by_position.expect("some");
1055        assert!(
1056            expected < 0.25,
1057            "overlapping windows were summed rather than merged: {expected}"
1058        );
1059    }
1060
1061    fn run(shas: &[&str], findings: Vec<CandidateFinding>) -> CandidateRun {
1062        CandidateRun {
1063            attempted_shas: shas.iter().map(|s| (*s).to_owned()).collect(),
1064            findings,
1065            ..CandidateRun::default()
1066        }
1067    }
1068
1069    /// A perfect run on one commit: every real row of that commit found, per class.
1070    #[test]
1071    fn a_found_row_counts_in_its_own_class() {
1072        let scored = score(
1073            &corpus(),
1074            &run(
1075                &[SHA_A],
1076                vec![
1077                    finding(SHA_A, "src/a.rs", 100),
1078                    finding(SHA_A, "src/a.rs", 200),
1079                    finding(SHA_A, "src/b.rs", 10),
1080                ],
1081            ),
1082        )
1083        .expect("scores");
1084        assert_eq!(scored.schema, SCORE_SCHEMA);
1085        assert_eq!(scored.found, 3);
1086        assert_eq!(scored.real_in_scope, 3);
1087        let drift = scored
1088            .per_class
1089            .iter()
1090            .find(|c| c.class == DefectClass::ContractDrift)
1091            .expect("every class is present");
1092        assert_eq!((drift.real, drift.found), (2, 2));
1093        assert_eq!(drift.recall(), Some(1.0));
1094        // A class with no row in scope has no recall, rather than 0.0 — which
1095        // would read as a failure to find something that was not there.
1096        let cleanup = scored
1097            .per_class
1098            .iter()
1099            .find(|c| c.class == DefectClass::CleanupGap)
1100            .expect("present with real: 0");
1101        assert_eq!(cleanup.real, 0);
1102        assert_eq!(cleanup.recall(), None);
1103    }
1104
1105    /// **A partial run must not look like a bad one.** Attempting one of two
1106    /// commits excludes the other's rows from the denominator, and the caveat says
1107    /// so.
1108    #[test]
1109    fn rows_outside_the_attempted_commits_are_out_of_scope_not_missed() {
1110        let scored = score(&corpus(), &run(&[SHA_A], vec![])).expect("scores");
1111        assert_eq!(scored.real_in_scope, 3, "only SHA_A's real rows");
1112        assert_eq!(scored.found, 0);
1113        assert_eq!(scored.known_false_in_scope, 0, "the false row is on SHA_B");
1114        assert_eq!((scored.attempted_shas, scored.corpus_shas), (1, 2));
1115        assert!(
1116            scored.caveats().iter().any(|c| c.contains("partial run")),
1117            "{:?}",
1118            scored.caveats()
1119        );
1120    }
1121
1122    /// **The most expensive available mistake**, refused rather than scored: a run
1123    /// against a merged PR head names a commit the corpus does not know, and the
1124    /// error explains why the number would have been zero.
1125    #[test]
1126    fn a_sha_the_corpus_does_not_know_is_refused_with_the_reason() {
1127        let head = "cccccccccccccccccccccccccccccccccccccccc";
1128        let err = score(&corpus(), &run(&[head], vec![])).expect_err("not a corpus commit");
1129        let ScoreError::UnknownSha { what, .. } = err else {
1130            panic!("expected UnknownSha, got {err:?}");
1131        };
1132        assert_eq!(what, "attempted_shas");
1133        let text = err.to_string();
1134        assert!(text.contains("reviewed_sha"), "{text}");
1135        assert!(
1136            text.contains("fix commits") && text.contains("silently reports zero"),
1137            "says what goes wrong, not just that it did: {text}"
1138        );
1139
1140        // And via a finding, which is the other way it arrives.
1141        let mut r = run(&[SHA_A], vec![finding(head, "src/a.rs", 100)]);
1142        r.attempted_shas.insert(SHA_A.to_owned());
1143        let err = score(&corpus(), &r).expect_err("a finding on an unknown commit");
1144        assert!(
1145            matches!(
1146                err,
1147                ScoreError::UnknownSha {
1148                    what: "a finding",
1149                    ..
1150                }
1151            ),
1152            "{err:?}"
1153        );
1154    }
1155
1156    /// A finding on a commit the run did not declare is refused: the attempted set
1157    /// is the denominator, so it has to be complete.
1158    #[test]
1159    fn a_finding_outside_the_attempted_set_is_refused() {
1160        let err = score(
1161            &corpus(),
1162            &run(&[SHA_A], vec![finding(SHA_B, "src/c.rs", 50)]),
1163        )
1164        .expect_err("SHA_B was not attempted");
1165        assert!(matches!(err, ScoreError::UndeclaredSha { .. }), "{err:?}");
1166    }
1167
1168    #[test]
1169    fn an_empty_run_is_refused_rather_than_scored_as_zero() {
1170        let err = score(&corpus(), &CandidateRun::default()).expect_err("nothing attempted");
1171        assert!(matches!(err, ScoreError::NothingAttempted), "{err:?}");
1172    }
1173
1174    /// A finding matching no row is **unadjudicated**, never a false positive: the
1175    /// corpus records what one reviewer said, not every defect in the tree.
1176    #[test]
1177    fn an_unmatched_finding_is_unadjudicated_not_false() {
1178        let scored = score(
1179            &corpus(),
1180            &run(&[SHA_A], vec![finding(SHA_A, "src/z.rs", 7)]),
1181        )
1182        .expect("scores");
1183        assert_eq!(scored.unadjudicated, 1);
1184        assert_eq!(scored.known_false_reproduced, 0);
1185        assert_eq!(scored.found, 0);
1186        assert_eq!(
1187            scored.corpus_precision(),
1188            None,
1189            "no adjudicated finding means no precision, not 1.0 and not 0.0"
1190        );
1191        let caveat = scored.caveats().join(" ");
1192        assert!(caveat.contains("UNADJUDICATED"), "{caveat}");
1193        assert!(
1194            caveat.contains("not every defect in them"),
1195            "says why it is not precision: {caveat}"
1196        );
1197    }
1198
1199    /// Repeating a known-false claim is the one precision signal the corpus
1200    /// licenses, and it lands in the precision denominator.
1201    #[test]
1202    fn reproducing_a_known_false_row_costs_precision() {
1203        let scored = score(
1204            &corpus(),
1205            &run(
1206                &[SHA_B],
1207                vec![
1208                    finding(SHA_B, "src/c.rs", 50),  // the real row
1209                    finding(SHA_B, "src/c.rs", 300), // the known-false one
1210                ],
1211            ),
1212        )
1213        .expect("scores");
1214        assert_eq!((scored.found, scored.known_false_reproduced), (1, 1));
1215        assert_eq!(scored.corpus_precision(), Some(0.5));
1216        assert_eq!(scored.unadjudicated, 0);
1217    }
1218
1219    /// Matching tolerates a slightly-off line — a reviewer's cited line can be
1220    /// wrong while its point is right — but not an arbitrary one.
1221    #[test]
1222    fn matching_tolerates_a_near_miss_but_not_a_far_one() {
1223        let near = score(
1224            &corpus(),
1225            &run(
1226                &[SHA_A],
1227                vec![finding(SHA_A, "src/a.rs", 100 + LINE_WINDOW)],
1228            ),
1229        )
1230        .expect("scores");
1231        assert_eq!(near.found, 1, "at the window edge");
1232
1233        let far = score(
1234            &corpus(),
1235            &run(
1236                &[SHA_A],
1237                vec![finding(SHA_A, "src/a.rs", 100 + LINE_WINDOW + 1)],
1238            ),
1239        )
1240        .expect("scores");
1241        assert_eq!(far.found, 0, "one line past the window");
1242        assert_eq!(far.unadjudicated, 1);
1243    }
1244
1245    /// **The window has to bound something.** The three `vacuous-test` rows of
1246    /// #299 sit 50 lines apart in one file — a reviewer that commented once,
1247    /// anywhere in that file, must be credited with the row it is near and with
1248    /// none of the others.
1249    ///
1250    /// Written against a finding placed *between* two rows and near neither,
1251    /// because that is the case an unbounded window gets wrong. A finding placed
1252    /// exactly on a row would still credit one row without a window at all, since
1253    /// matching is one-to-one and nearest-first — which is a different property,
1254    /// tested below.
1255    #[test]
1256    fn the_window_bounds_which_row_a_distant_finding_can_claim() {
1257        let text = [
1258            row(1, SHA_A, "tests/t.rs", 75, "real", "vacuous-test"),
1259            row(2, SHA_A, "tests/t.rs", 125, "real", "vacuous-test"),
1260            row(3, SHA_A, "tests/t.rs", 176, "real", "vacuous-test"),
1261        ]
1262        .join("\n");
1263        let corpus = Corpus::parse(&text).expect("parses");
1264        // The real gaps: no window this size can bridge them.
1265        const { assert!(LINE_WINDOW * 2 < 50, "the window would span two #299 rows") };
1266
1267        // Line 100: 25 from row 1 and 25 from row 2, so outside both windows. A
1268        // reviewer that commented here found none of the three.
1269        let scored = score(
1270            &corpus,
1271            &run(&[SHA_A], vec![finding(SHA_A, "tests/t.rs", 100)]),
1272        )
1273        .expect("scores");
1274        assert_eq!(
1275            scored.found, 0,
1276            "a finding 25 lines from the nearest row has not found it"
1277        );
1278        assert_eq!(scored.unadjudicated, 1);
1279        let vacuous = scored
1280            .per_class
1281            .iter()
1282            .find(|c| c.class == DefectClass::VacuousTest)
1283            .expect("present");
1284        let missed: Vec<u64> = vacuous.missed.iter().map(|m| m.id).collect();
1285        assert_eq!(missed, vec![1, 2, 3], "names what to read next");
1286        assert!(
1287            vacuous
1288                .missed
1289                .iter()
1290                .all(|m| !m.comment_url.is_empty() && m.line > 0),
1291            "a miss carries enough to go and look at it, not just an id"
1292        );
1293
1294        // Three findings, one on each row, are credited to all three — the window
1295        // is a bound, not an obstacle.
1296        let all = score(
1297            &corpus,
1298            &run(
1299                &[SHA_A],
1300                vec![
1301                    finding(SHA_A, "tests/t.rs", 75),
1302                    finding(SHA_A, "tests/t.rs", 125),
1303                    finding(SHA_A, "tests/t.rs", 176),
1304                ],
1305            ),
1306        )
1307        .expect("scores");
1308        assert_eq!(all.found, 3);
1309    }
1310
1311    /// **One finding cannot be credited to two rows**, so a comment sitting between
1312    /// two nearby defects counts as finding one of them, not both.
1313    ///
1314    /// The rows here are 5 apart, inside one window — the only arrangement in which
1315    /// one-to-one matching is distinguishable from crediting every row in range.
1316    #[test]
1317    fn one_finding_cannot_claim_two_rows_in_the_same_window() {
1318        let text = [
1319            row(1, SHA_A, "src/a.rs", 100, "real", "contract-drift"),
1320            row(2, SHA_A, "src/a.rs", 105, "real", "contract-drift"),
1321        ]
1322        .join("\n");
1323        let corpus = Corpus::parse(&text).expect("parses");
1324        let scored = score(
1325            &corpus,
1326            &run(&[SHA_A], vec![finding(SHA_A, "src/a.rs", 102)]),
1327        )
1328        .expect("scores");
1329        assert_eq!(
1330            scored.found, 1,
1331            "one comment is one finding, however many rows it is near"
1332        );
1333        let drift = scored
1334            .per_class
1335            .iter()
1336            .find(|c| c.class == DefectClass::ContractDrift)
1337            .expect("present");
1338        assert_eq!((drift.real, drift.found), (2, 1));
1339        // Nearest wins: 102 is 2 from row 1 and 3 from row 2.
1340        assert_eq!(
1341            drift.missed.iter().map(|m| m.id).collect::<Vec<_>>(),
1342            vec![2]
1343        );
1344    }
1345
1346    /// And the converse: two rows cannot share one finding, so spraying findings at
1347    /// a line cannot inflate recall past the number of rows there.
1348    #[test]
1349    fn extra_findings_in_one_window_do_not_inflate_recall() {
1350        let scored = score(
1351            &corpus(),
1352            &run(
1353                &[SHA_A],
1354                vec![
1355                    finding(SHA_A, "src/a.rs", 98),
1356                    finding(SHA_A, "src/a.rs", 100),
1357                    finding(SHA_A, "src/a.rs", 102),
1358                ],
1359            ),
1360        )
1361        .expect("scores");
1362        assert_eq!(scored.found, 1, "one row, so one credit");
1363        assert_eq!(scored.unadjudicated, 2);
1364    }
1365
1366    /// The score does not depend on the order findings arrive in — a reviewer that
1367    /// emits its findings in a different order must score identically.
1368    #[test]
1369    fn the_score_is_independent_of_finding_order() {
1370        let findings = vec![
1371            finding(SHA_A, "src/a.rs", 98),
1372            finding(SHA_A, "src/a.rs", 205),
1373            finding(SHA_A, "src/b.rs", 10),
1374        ];
1375        let forward = score(&corpus(), &run(&[SHA_A], findings.clone())).expect("scores");
1376        let mut reversed = findings;
1377        reversed.reverse();
1378        let backward = score(&corpus(), &run(&[SHA_A], reversed)).expect("scores");
1379        assert_eq!(forward, backward);
1380        assert_eq!(forward.found, 3);
1381    }
1382
1383    /// Finding the defect and mislabelling it still counts as found — recall is
1384    /// about the defect, not the taxonomy — but the disagreement is reported.
1385    #[test]
1386    fn a_misclassified_finding_still_counts_as_found() {
1387        let mut f = finding(SHA_A, "src/b.rs", 10);
1388        f.defect_class = Some(DefectClass::ProseClarity); // the row is vacuous-test
1389        let scored = score(&corpus(), &run(&[SHA_A], vec![f])).expect("scores");
1390        let vacuous = scored
1391            .per_class
1392            .iter()
1393            .find(|c| c.class == DefectClass::VacuousTest)
1394            .expect("present");
1395        assert_eq!(
1396            (vacuous.real, vacuous.found, vacuous.misclassified),
1397            (1, 1, 1)
1398        );
1399    }
1400
1401    /// **The suppression filter's own cost, measured.** A withheld finding that
1402    /// would have matched a known-false row is the filter working; one that would
1403    /// have matched a real row is the filter breaking, and it must show up as a
1404    /// caveat rather than as an unexplained miss.
1405    #[test]
1406    fn suppressed_findings_are_scored_separately_and_a_true_one_raises_a_caveat() {
1407        let mut good = CandidateRun {
1408            attempted_shas: [SHA_B.to_owned()].into_iter().collect(),
1409            suppressed: vec![finding(SHA_B, "src/c.rs", 300)],
1410            ..CandidateRun::default()
1411        };
1412        let scored = score(&corpus(), &good).expect("scores");
1413        assert_eq!(scored.suppressed_known_false, 1);
1414        assert_eq!(scored.suppressed_real, 0);
1415        assert_eq!(
1416            scored.known_false_reproduced, 0,
1417            "withheld, so not reproduced"
1418        );
1419        assert!(
1420            !scored.caveats().iter().any(|c| c.contains("REAL row")),
1421            "nothing true was withheld: {:?}",
1422            scored.caveats()
1423        );
1424
1425        good.suppressed.push(finding(SHA_B, "src/c.rs", 50));
1426        let bad = score(&corpus(), &good).expect("scores");
1427        assert_eq!(bad.suppressed_real, 1);
1428        let caveat = bad.caveats().join(" ");
1429        assert!(
1430            caveat.contains("REAL row") && caveat.contains("licence"),
1431            "says the filter's licence no longer holds: {caveat}"
1432        );
1433    }
1434
1435    /// A withheld finding is not counted twice: it is not in `unadjudicated`,
1436    /// which counts emitted findings only.
1437    #[test]
1438    fn a_withheld_finding_is_not_an_unadjudicated_emitted_one() {
1439        let scored = score(
1440            &corpus(),
1441            &CandidateRun {
1442                attempted_shas: [SHA_A.to_owned()].into_iter().collect(),
1443                suppressed: vec![finding(SHA_A, "src/z.rs", 7)],
1444                ..CandidateRun::default()
1445            },
1446        )
1447        .expect("scores");
1448        assert_eq!(scored.unadjudicated, 0);
1449        assert_eq!(scored.suppressed_unadjudicated, 1);
1450    }
1451
1452    /// Every class appears in the report, in a fixed order, whatever the run
1453    /// covered — so two reports can be read side by side.
1454    #[test]
1455    fn the_report_shape_does_not_change_with_the_run() {
1456        let scored = score(&corpus(), &run(&[SHA_A], vec![])).expect("scores");
1457        let classes: Vec<_> = scored.per_class.iter().map(|c| c.class).collect();
1458        assert_eq!(classes, crate::review_corpus::CLASSES.to_vec());
1459    }
1460
1461    /// The known-false denominator follows scope too: a run that never saw the
1462    /// commit carrying the false row cannot be credited for avoiding it.
1463    #[test]
1464    fn avoiding_a_false_row_out_of_scope_is_not_a_credit() {
1465        let scored = score(&corpus(), &run(&[SHA_A], vec![])).expect("scores");
1466        assert_eq!(scored.known_false_in_scope, 0);
1467        let with_b = score(&corpus(), &run(&[SHA_A, SHA_B], vec![])).expect("scores");
1468        assert_eq!(with_b.known_false_in_scope, 1);
1469        assert_eq!(with_b.known_false_reproduced, 0);
1470    }
1471
1472    /// The corpus's own verdict vocabulary is what scoping splits on, so a row
1473    /// whose verdict changed would move between the two denominators rather than
1474    /// vanish.
1475    #[test]
1476    fn scope_splits_on_verdict_exhaustively() {
1477        let scored = score(&corpus(), &run(&[SHA_A, SHA_B], vec![])).expect("scores");
1478        assert_eq!(
1479            scored.real_in_scope + scored.known_false_in_scope,
1480            corpus().rows().len(),
1481            "every in-scope row is in exactly one denominator"
1482        );
1483        assert_eq!(
1484            corpus().with_verdict(Verdict::False).count(),
1485            scored.known_false_in_scope
1486        );
1487    }
1488
1489    fn verdict(sha: &str, stance: VerdictStance) -> CandidateVerdict {
1490        CandidateVerdict {
1491            reviewed_sha: sha.to_owned(),
1492            stance,
1493            summary: "a judgement".to_owned(),
1494        }
1495    }
1496
1497    /// **The one thing the corpus can adjudicate about a whole-change judgement**
1498    /// (issue #649, part 2): a verdict that declared a change clean over a commit
1499    /// the corpus knows carries a real defect.
1500    ///
1501    /// # The fixture has to contain the difference
1502    ///
1503    /// A local corpus, not [`corpus`], and the two commits differ in the only
1504    /// respect the rule reads: `SHA_A` carries a **real** row and `SHA_B` carries
1505    /// only a **known-false** one. The shared fixture has a real row on both, so a
1506    /// test built on it counted 1 against an implementation that inverted the
1507    /// stance — found by injecting exactly that, which is why this fixture is
1508    /// here rather than the convenient one.
1509    #[test]
1510    fn a_clean_verdict_over_a_known_defect_is_contradicted_and_said_so_loudly() {
1511        let text = [
1512            row(1, SHA_A, "src/a.rs", 100, "real", "contract-drift"),
1513            row(2, SHA_B, "src/c.rs", 300, "false", "false-compile-claim"),
1514        ]
1515        .join("\n");
1516        let corpus = Corpus::parse(&text).expect("the test corpus parses");
1517
1518        let both_clean = CandidateRun {
1519            verdicts: vec![
1520                verdict(SHA_A, VerdictStance::Clean),
1521                verdict(SHA_B, VerdictStance::Clean),
1522            ],
1523            ..run(&[SHA_A, SHA_B], vec![])
1524        };
1525        let scored = score(&corpus, &both_clean).expect("scores");
1526        assert_eq!(scored.verdicts, 2);
1527        assert_eq!(
1528            scored.verdicts_contradicted, 1,
1529            "only the one over a commit the corpus holds a REAL row for — a \
1530             known-false row is not a defect to have missed"
1531        );
1532        assert_eq!(scored.verdicts_unadjudicated, 1);
1533        assert!(
1534            scored
1535                .caveats()
1536                .iter()
1537                .any(|c| c.contains("DECLARED A CHANGE CLEAN")),
1538            "the contradiction caveat did not fire: {:?}",
1539            scored.caveats()
1540        );
1541
1542        // The stance is read, not ignored: `concerns` over the very same real row
1543        // is not contradicted by anything.
1544        let concerns = CandidateRun {
1545            verdicts: vec![verdict(SHA_A, VerdictStance::Concerns)],
1546            ..run(&[SHA_A], vec![])
1547        };
1548        let scored = score(&corpus, &concerns).expect("scores");
1549        assert_eq!(
1550            scored.verdicts_contradicted, 0,
1551            "a reviewer that said it had concerns has not claimed the change was \
1552             clean, whatever the corpus knows"
1553        );
1554        assert_eq!(scored.verdicts_unadjudicated, 1);
1555    }
1556
1557    /// A verdict never enters the recall figures. Modelling it as a finding would
1558    /// have put it in the denominator, where it would count as a defect the
1559    /// reviewer claimed to detect.
1560    #[test]
1561    fn verdicts_do_not_move_recall_precision_or_the_unadjudicated_count() {
1562        let findings = vec![finding(SHA_A, "src/a.rs", 100)];
1563        let without = score(&corpus(), &run(&[SHA_A, SHA_B], findings.clone())).expect("scores");
1564        let with = score(
1565            &corpus(),
1566            &CandidateRun {
1567                verdicts: vec![
1568                    verdict(SHA_A, VerdictStance::Clean),
1569                    verdict(SHA_B, VerdictStance::Concerns),
1570                ],
1571                ..run(&[SHA_A, SHA_B], findings)
1572            },
1573        )
1574        .expect("scores");
1575        assert_eq!(with.found, without.found);
1576        assert_eq!(with.real_in_scope, without.real_in_scope);
1577        assert_eq!(with.unadjudicated, without.unadjudicated);
1578        assert_eq!(with.per_class, without.per_class);
1579        assert_eq!(with.corpus_precision(), without.corpus_precision());
1580    }
1581
1582    /// A run carrying no verdict scores exactly as it did before the field
1583    /// existed — the zero is a zero, not a contradiction.
1584    #[test]
1585    fn a_run_with_no_verdicts_reports_zeroes_and_no_caveat() {
1586        let scored = score(&corpus(), &run(&[SHA_A], vec![])).expect("scores");
1587        assert_eq!(
1588            (
1589                scored.verdicts,
1590                scored.verdicts_contradicted,
1591                scored.verdicts_unadjudicated
1592            ),
1593            (0, 0, 0)
1594        );
1595        assert!(!scored.caveats().iter().any(|c| c.contains("verdict")));
1596    }
1597
1598    /// Verdicts are held to the same sha rules as findings: a judgement of a
1599    /// commit the corpus does not know is overwhelmingly a merged PR head, whose
1600    /// fix commits make any judgement of it a judgement of repaired code.
1601    #[test]
1602    fn a_verdict_naming_an_unknown_or_undeclared_commit_is_refused() {
1603        let unknown = CandidateRun {
1604            verdicts: vec![verdict(
1605                "cccccccccccccccccccccccccccccccccccccccc",
1606                VerdictStance::Clean,
1607            )],
1608            ..run(&[SHA_A], vec![])
1609        };
1610        assert!(matches!(
1611            score(&corpus(), &unknown),
1612            Err(ScoreError::UnknownSha {
1613                what: "a verdict",
1614                ..
1615            })
1616        ));
1617
1618        let undeclared = CandidateRun {
1619            verdicts: vec![verdict(SHA_B, VerdictStance::Clean)],
1620            ..run(&[SHA_A], vec![])
1621        };
1622        assert!(matches!(
1623            score(&corpus(), &undeclared),
1624            Err(ScoreError::UndeclaredSha { .. })
1625        ));
1626    }
1627
1628    /// One judgement per change. Two verdicts on one commit is the candidate
1629    /// contradicting itself, and counting both would double whatever
1630    /// `verdicts_contradicted` says about that commit — a number a reader acts
1631    /// on. Findings are deliberately not held to this: a reviewer may say several
1632    /// things about one commit, each scored against its own row.
1633    #[test]
1634    fn two_verdicts_on_one_commit_are_refused_while_two_findings_are_not() {
1635        let twice = CandidateRun {
1636            verdicts: vec![
1637                verdict(SHA_A, VerdictStance::Clean),
1638                verdict(SHA_A, VerdictStance::Concerns),
1639            ],
1640            ..run(&[SHA_A], vec![])
1641        };
1642        assert!(matches!(
1643            score(&corpus(), &twice),
1644            Err(ScoreError::DuplicateVerdict { .. })
1645        ));
1646
1647        let many_findings = run(
1648            &[SHA_A],
1649            vec![
1650                finding(SHA_A, "src/a.rs", 100),
1651                finding(SHA_A, "src/a.rs", 200),
1652            ],
1653        );
1654        assert!(
1655            score(&corpus(), &many_findings).is_ok(),
1656            "several findings on one commit are ordinary and stay ordinary"
1657        );
1658    }
1659
1660    /// A `v1` document written before verdicts existed still parses, and one
1661    /// carrying them round-trips — the additive promise `arm` was added under.
1662    #[test]
1663    fn the_run_document_carries_verdicts_and_still_reads_one_without_them() {
1664        let old = format!(
1665            "{{\"schema\": \"{RUN_SCHEMA}\", \"attempted_shas\": [{SHA_A:?}], \
1666             \"findings\": []}}"
1667        );
1668        let parsed = CandidateRun::parse(&old).expect("an older document still parses");
1669        assert!(parsed.verdicts.is_empty());
1670
1671        let judged = CandidateRun {
1672            verdicts: vec![verdict(SHA_A, VerdictStance::Concerns)],
1673            ..run(&[SHA_A], vec![])
1674        };
1675        let text = serde_json::to_string(&judged).expect("serialises");
1676        assert!(
1677            text.contains("\"stance\":\"concerns\""),
1678            "the stance is a stable kebab token: {text}"
1679        );
1680        assert_eq!(
1681            CandidateRun::parse(&text).expect("round-trips"),
1682            judged,
1683            "a run document that cannot round-trip cannot be replayed"
1684        );
1685
1686        // An empty list is omitted entirely, so a run with no verdicts is byte-wise
1687        // what it always was and an older build can still read it.
1688        let bare = serde_json::to_string(&run(&[SHA_A], vec![])).expect("serialises");
1689        assert!(!bare.contains("verdicts"), "{bare}");
1690    }
1691}