use std::collections::{BTreeMap, BTreeSet};
use pr_review_core::config::Config;
use pr_review_core::llm::Finding;
use pr_review_core::review::{run_review, RunReviewInput};
use serde::Deserialize;
const TOLERANCE: i64 = 3;
const CONCURRENT_CASES: usize = 4;
#[derive(Deserialize, Clone)]
struct Case {
#[serde(default)]
provider: String,
#[serde(default)]
repo: String,
#[serde(default)]
pr: u64,
#[serde(default)]
id: String,
#[serde(default)]
diff: Option<String>,
#[serde(default)]
#[allow(dead_code)]
lang: String,
#[serde(default)]
issues: Vec<Issue>,
}
impl Case {
fn label(&self) -> String {
if !self.id.is_empty() {
return self.id.clone();
}
if self.diff.is_some() {
return if self.repo.is_empty() {
"local".to_string()
} else {
self.repo.clone()
};
}
format!("{}#{}", self.repo, self.pr)
}
fn is_local(&self) -> bool {
self.diff.is_some()
}
fn synthetic_meta(&self) -> pr_review_core::providers::PrMeta {
pr_review_core::providers::PrMeta {
repo: if self.repo.is_empty() {
"local/bench".to_string()
} else {
self.repo.clone()
},
pr: 0,
title: (!self.id.is_empty()).then(|| self.id.clone()),
base_branch: None,
head_sha: None,
body: None,
ci_status: None,
}
}
}
#[derive(Deserialize, Clone)]
struct Issue {
file: String,
#[serde(default)]
line: Option<u64>,
#[serde(default)]
#[allow(dead_code)]
r#type: String,
#[serde(default)]
#[allow(dead_code)]
note: String,
}
fn hits(f: &Finding, i: &Issue) -> bool {
f.file == i.file
&& match (f.line, i.line) {
(Some(fl), Some(il)) => (fl as i64 - il as i64).abs() <= TOLERANCE,
(None, None) => true,
_ => false,
}
}
#[derive(Default)]
struct UniqueFindings {
anchored: BTreeMap<String, Vec<u64>>,
summary: BTreeSet<String>,
}
impl UniqueFindings {
fn insert(&mut self, f: &Finding) -> bool {
match f.line {
None => self.summary.insert(f.file.clone()),
Some(line) => {
let seen = self.anchored.entry(f.file.clone()).or_default();
if seen
.iter()
.any(|&s| (s as i64 - line as i64).abs() <= TOLERANCE)
{
return false;
}
seen.push(line);
true
}
}
}
fn len(&self) -> usize {
self.anchored.values().map(Vec::len).sum::<usize>() + self.summary.len()
}
}
struct CaseRun {
caught: Vec<usize>,
fps: Vec<usize>,
issues: usize,
errored: bool,
}
async fn run_case(cfg: &Config, client: &reqwest::Client, case: &Case, rounds: usize) -> CaseRun {
let mut hit_issues: BTreeSet<usize> = BTreeSet::new();
let mut seen_fps = UniqueFindings::default();
let mut caught = Vec::with_capacity(rounds);
let mut fps = Vec::with_capacity(rounds);
let mut errored = false;
for round in 1..=rounds {
let out: anyhow::Result<Vec<Finding>> = match &case.diff {
Some(diff) => pr_review_core::llm::review_diff(
client,
cfg,
&case.synthetic_meta(),
diff,
None,
None,
&pr_review_core::prompt::review_system_prompt(cfg),
)
.await
.map(|r| r.review.findings),
None => run_review(
cfg,
RunReviewInput {
provider: case.provider.clone(),
repo: case.repo.clone(),
pr: case.pr,
dry_run: true,
placeholder: false,
},
)
.await
.map(|o| o.findings_detail),
};
match out {
Ok(findings) => {
for f in &findings {
let mut hit_any = false;
for (idx, issue) in case.issues.iter().enumerate() {
if hits(f, issue) {
hit_issues.insert(idx);
hit_any = true;
}
}
if !hit_any {
seen_fps.insert(f);
}
}
eprintln!(
" round {round}: {} finding(s) · cumulative {}/{} bug(s), {} unique FP(s)",
findings.len(),
hit_issues.len(),
case.issues.len(),
seen_fps.len()
);
}
Err(e) => {
errored = true;
eprintln!(" round {round}: ERROR {e}");
}
}
caught.push(hit_issues.len());
fps.push(seen_fps.len());
}
CaseRun {
caught,
fps,
issues: case.issues.len(),
errored,
}
}
struct ReplicateAgg {
recall: Vec<f64>,
fps: Vec<usize>,
caught: Vec<usize>,
errors: usize,
}
fn mean(xs: &[f64]) -> f64 {
if xs.is_empty() {
0.0
} else {
xs.iter().sum::<f64>() / xs.len() as f64
}
}
fn stddev(xs: &[f64]) -> f64 {
if xs.len() < 2 {
return 0.0;
}
let m = mean(xs);
(xs.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (xs.len() - 1) as f64).sqrt()
}
fn range(xs: &[f64]) -> (f64, f64) {
xs.iter()
.fold((f64::MAX, f64::MIN), |(lo, hi), &x| (lo.min(x), hi.max(x)))
}
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let path = match args.next() {
Some(p) => p,
None => {
eprintln!(
"usage: cargo run --example multiround -- <corpus.json> [rounds] [replicates]"
);
std::process::exit(2);
}
};
let rounds: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(3).max(1);
let replicates: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(5).max(1);
let corpus: Vec<Case> = serde_json::from_str(
&std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}")),
)
.unwrap_or_else(|e| panic!("parse {path}: {e}"));
let cfg = Config::from_env();
let client = reqwest::Client::new();
let local = corpus.first().is_some_and(|c| c.is_local());
let total_issues: usize = corpus.iter().map(|c| c.issues.len()).sum();
let total_reviews = corpus.len() * rounds * replicates;
eprintln!(
"multiround: {} case(s), {} planted bug(s) · {} round(s) × {} replicate(s) = {} reviews\n\
mode={} · model={} · agentic={} · self_critique={}\n\
No fixes are applied between rounds — this measures re-review alone.\n",
corpus.len(),
total_issues,
rounds,
replicates,
total_reviews,
if local {
"local diffs (no provider)"
} else {
"live PRs"
},
cfg.openrouter_model,
cfg.agentic,
cfg.self_critique,
);
let mut per_case: BTreeMap<String, Vec<CaseRun>> = BTreeMap::new();
let mut aggs: Vec<ReplicateAgg> = Vec::with_capacity(replicates);
for rep in 1..=replicates {
eprintln!("── replicate {rep}/{replicates} ──");
let mut caught_total = vec![0usize; rounds];
let mut fps_total = vec![0usize; rounds];
let mut issues_total = 0usize;
let mut errors = 0usize;
let mut ordered: Vec<(String, CaseRun)> = Vec::with_capacity(corpus.len());
for chunk in corpus.chunks(CONCURRENT_CASES) {
let mut set = tokio::task::JoinSet::new();
for case in chunk {
let (cfg, client, case) = (cfg.clone(), client.clone(), case.clone());
set.spawn(async move {
let run = run_case(&cfg, &client, &case, rounds).await;
(case.label(), run)
});
}
while let Some(joined) = set.join_next().await {
match joined {
Ok(pair) => ordered.push(pair),
Err(e) => {
eprintln!(" task panicked, excluded: {e}");
errors += 1;
}
}
}
}
ordered.sort_by(|a, b| a.0.cmp(&b.0));
for (label, run) in ordered {
let _ = &label;
if run.errored {
errors += 1;
eprintln!(" \u{21b3} excluded (a round failed)");
continue;
}
issues_total += run.issues;
for k in 0..rounds {
caught_total[k] += run.caught[k];
fps_total[k] += run.fps[k];
}
per_case.entry(label).or_default().push(run);
}
let recall: Vec<f64> = caught_total
.iter()
.map(|c| {
if issues_total == 0 {
0.0
} else {
*c as f64 / issues_total as f64
}
})
.collect();
eprintln!(
" → recall by cumulative round: {}\n",
recall
.iter()
.enumerate()
.map(|(i, r)| format!("r1..{}={r:.2}", i + 1))
.collect::<Vec<_>>()
.join(" ")
);
aggs.push(ReplicateAgg {
recall,
fps: fps_total,
caught: caught_total,
errors,
});
}
let bar = "─".repeat(76);
println!("{bar}");
println!(
"{:<16}{:>12}{:>10}{:>14}{:>12}",
"cumulative", "recall mean", "±sd", "range", "unique FPs"
);
println!("{bar}");
let mut recall_by_round: Vec<Vec<f64>> = vec![Vec::new(); rounds];
for a in &aggs {
for (k, r) in a.recall.iter().enumerate() {
recall_by_round[k].push(*r);
}
}
let fp_mean: Vec<f64> = (0..rounds)
.map(|k| mean(&aggs.iter().map(|a| a.fps[k] as f64).collect::<Vec<_>>()))
.collect();
let caught_mean: Vec<f64> = (0..rounds)
.map(|k| mean(&aggs.iter().map(|a| a.caught[k] as f64).collect::<Vec<_>>()))
.collect();
for k in 0..rounds {
let (lo, hi) = range(&recall_by_round[k]);
println!(
"{:<16}{:>12.3}{:>10.3}{:>14}{:>12.1}",
format!("rounds 1..{}", k + 1),
mean(&recall_by_round[k]),
stddev(&recall_by_round[k]),
format!("{lo:.3}–{hi:.3}"),
fp_mean[k],
);
}
println!("{bar}");
let r1 = mean(&recall_by_round[0]);
let rn = mean(&recall_by_round[rounds - 1]);
let gain = rn - r1;
let (lo1, hi1) = range(&recall_by_round[0]);
let spread = hi1 - lo1;
let errors: usize = aggs.iter().map(|a| a.errors).sum();
let attempted = corpus.len() * replicates;
let excluded_frac = errors as f64 / attempted.max(1) as f64;
const MAX_EXCLUDED: f64 = 0.10;
let no_data = total_issues == 0 || caught_mean[rounds - 1] == 0.0;
let too_lossy = excluded_frac > MAX_EXCLUDED;
if no_data || too_lossy {
println!("\n{bar}");
println!("NO VERDICT — the run did not produce evaluable data.");
if too_lossy {
println!(
" · {errors}/{attempted} case-replicate(s) ({:.0}%) were excluded after a",
excluded_frac * 100.0
);
println!(
" failed round — above the {:.0}% ceiling for a readable result.",
MAX_EXCLUDED * 100.0
);
}
if total_issues == 0 {
println!(" · no planted bugs survived exclusion, so recall is undefined.");
} else if caught_mean[rounds - 1] == 0.0 {
println!(" · no planted bug was caught in any round, by any replicate.");
println!(" That is a finding about the corpus or the config, not about rounds.");
}
println!("{bar}");
println!("\nFix the above and re-run. Do not read the table as a result.");
std::process::exit(1);
}
if errors > 0 {
println!(
"\nNote: {errors}/{attempted} case-replicate(s) ({:.1}%) were excluded after a failed\n\
round (transient model/API errors). They are dropped whole rather than counted\n\
as zero, which would have biased the result toward CUT.",
excluded_frac * 100.0
);
}
println!("\nKILL CRITERION (fixed before the run)\n");
println!(
" 1. recall gain from extra rounds : {gain:+.3} (round 1 = {r1:.3} → union of {rounds} = {rn:.3})"
);
println!(" replicate spread of round 1 : {spread:.3} (range {lo1:.3}–{hi1:.3})");
let recall_verdict = if gain <= spread || gain <= 0.0 {
"CUT — the gain is inside the noise: extra rounds are indistinguishable from re-rolling."
} else {
"SURVIVES — the gain exceeds replicate spread."
};
println!(" → {recall_verdict}\n");
let added_fps = fp_mean[rounds - 1] - fp_mean[0];
let added_catches = caught_mean[rounds - 1] - caught_mean[0];
println!(" 2. false positives added by rounds 2..{rounds} : {added_fps:+.1}");
println!(" planted bugs added by rounds 2..{rounds} : {added_catches:+.1}");
let fp_verdict = if added_fps > added_catches {
"CUT — added rounds contribute noise faster than they contribute bugs."
} else {
"SURVIVES — added rounds contribute bugs at least as fast as noise."
};
println!(" → {fp_verdict}\n");
let cut = gain <= spread || gain <= 0.0 || added_fps > added_catches;
println!("{bar}");
println!(
"VERDICT: {}",
if cut {
"CUT multi-round convergence. Stage 5 ships max_rounds default = 1."
} else {
"Multi-round convergence SURVIVES. Stage 5 ships max_rounds default = 3."
}
);
println!("{bar}");
println!("\nper case (mean cumulative bugs caught, pooled over replicates):");
println!("{:<40}{:>10}{:>26}", "case", "planted", "caught by round");
for (label, runs) in &per_case {
let issues = runs.first().map(|r| r.issues).unwrap_or(0);
let by_round: Vec<String> = (0..rounds)
.map(|k| {
let m = mean(&runs.iter().map(|r| r.caught[k] as f64).collect::<Vec<_>>());
format!("{m:.1}")
})
.collect();
println!("{:<40}{:>10}{:>26}", label, issues, by_round.join(" → "));
}
println!(
"\n(recall = planted bugs a finding hit, ±{TOLERANCE} line tolerance; \
FPs deduped by file+line bucket across rounds)"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(file: &str, line: Option<u64>) -> Finding {
Finding {
severity: "LOW".to_string(),
file: file.to_string(),
line,
body: "x".to_string(),
confidence: Some(50),
}
}
#[test]
fn findings_within_tolerance_are_one_finding() {
let mut u = UniqueFindings::default();
assert!(u.insert(&finding("a.rs", Some(3))));
assert!(
!u.insert(&finding("a.rs", Some(4))),
"1 line apart is one defect"
);
assert!(
!u.insert(&finding("a.rs", Some(6))),
"3 apart is still within tolerance"
);
assert_eq!(u.len(), 1);
}
#[test]
fn findings_beyond_tolerance_are_distinct() {
let mut u = UniqueFindings::default();
assert!(u.insert(&finding("a.rs", Some(10))));
assert!(
u.insert(&finding("a.rs", Some(14))),
"4 apart exceeds tolerance"
);
assert_eq!(u.len(), 2);
}
#[test]
fn the_same_line_in_two_files_is_two_findings() {
let mut u = UniqueFindings::default();
assert!(u.insert(&finding("a.rs", Some(10))));
assert!(u.insert(&finding("b.rs", Some(10))));
assert_eq!(u.len(), 2);
}
#[test]
fn summary_and_anchored_findings_do_not_merge() {
let mut u = UniqueFindings::default();
assert!(u.insert(&finding("a.rs", None)));
assert!(u.insert(&finding("a.rs", Some(10))));
assert!(!u.insert(&finding("a.rs", None)), "one summary per file");
assert_eq!(u.len(), 2);
}
#[test]
fn the_old_bucketing_could_only_overcount() {
let bucket = |l: i64| l / (TOLERANCE + 1);
for a in 0..200i64 {
for b in a..(a + 10).min(200) {
let same_bucket = bucket(a) == bucket(b);
let within_tolerance = (b - a) <= TOLERANCE;
if same_bucket {
assert!(
within_tolerance,
"lines {a},{b} shared a bucket but exceed tolerance — \
bucketing would have merged two distinct findings"
);
}
}
}
}
}