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}