choir_queue/corpus.rs
1//! Base-rate measurement over a real repository's history (D23).
2//!
3//! The revised D23 tripwire's first clause is "measure our own
4//! semantic-conflict base rate", and nothing else on D23 can proceed
5//! without it: a detector's recall is meaningless at an unknown
6//! prevalence, and the escalation threshold `t = 1 - c/r` cannot be
7//! calibrated against a rate nobody has counted.
8//!
9//! Our own history cannot supply it — 60-odd commits, zero reverts, no
10//! CI — so the corpus is **borrowed**: point this at a repository the
11//! D21 bridge has mirrored and measure there.
12//!
13//! # What the label actually means
14//!
15//! This weak-labels a merge as bad if it was **reverted within a window
16//! of following commits**. That is a proxy, and it is wrong in both
17//! directions in ways worth stating rather than discovering:
18//!
19//! - *False negatives dominate.* A bad merge that was fixed forward, or
20//! noticed after the window, or never noticed, is labelled good. So the
21//! measured rate is a **lower bound** on the true rate.
22//! - *False positives exist.* Reverts also happen for reasons that are
23//! not defects — a feature deferred, a release scoped down.
24//!
25//! It is used anyway because it needs nothing but git: no CI history, no
26//! issue tracker, no human labelling. A lower bound on prevalence is
27//! enough to answer the question that actually blocks D23, which is
28//! whether a detector's false-positive rate is survivable at our rate.
29//!
30//! Same split as [`crate::blast`]: parsing is a pure function over `git
31//! log` output, and the shell-out is a separate call, so tests are
32//! hermetic.
33
34use std::collections::{BTreeMap, BTreeSet};
35
36/// Field separator in the `git log` format this module parses (ASCII
37/// unit separator; cannot appear in a commit subject or body).
38const FIELD: char = '\u{1f}';
39
40/// Record separator (ASCII record separator).
41const RECORD: char = '\u{1e}';
42
43/// The `--format` string [`parse_history`] expects.
44pub const LOG_FORMAT: &str = "--format=%H%x1f%P%x1f%B%x1e";
45
46/// One commit, reduced to what the labelling needs.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Commit {
49 /// Full object id.
50 pub id: String,
51 /// Parent object ids; 2+ means a merge.
52 pub parents: Vec<String>,
53 /// Full commit message.
54 pub body: String,
55}
56
57impl Commit {
58 /// Whether this commit has two or more parents.
59 #[must_use]
60 pub fn is_merge(&self) -> bool {
61 self.parents.len() > 1
62 }
63
64 /// The commit this one reverts, if its message says so.
65 ///
66 /// Matches git's own generated wording, `This reverts commit <oid>.`,
67 /// which is what `git revert` writes and therefore what our own
68 /// queue's auto-revert (`revert -m 1`) produces.
69 #[must_use]
70 pub fn reverts(&self) -> Option<String> {
71 let rest = self.body.split("This reverts commit ").nth(1)?;
72 let oid: String = rest.chars().take_while(char::is_ascii_hexdigit).collect();
73 (oid.len() >= 7).then_some(oid)
74 }
75}
76
77/// Maps a reverted commit back to the mainline commit that introduced it.
78///
79/// Needed because the obvious labelling — "a revert whose target is a
80/// merge" — sees almost nothing in a pull-request workflow. Measured on
81/// rust-lang/rust: of 307 unique revert targets, **39 are merges and 267
82/// are ordinary commits**, because a revert names the change, not the
83/// merge that landed it. Without attribution the proxy undercounts by
84/// roughly eight to one.
85///
86/// A struct rather than a bare map so it can carry *why* a target went
87/// unresolved, which is the part that would otherwise be silently lost.
88#[derive(Debug, Clone, Default, PartialEq, Eq)]
89pub struct Attribution {
90 resolved: BTreeMap<String, String>,
91 unresolved: BTreeSet<String>,
92}
93
94impl Attribution {
95 /// An empty attribution: every revert is matched by exact oid only,
96 /// which is the behaviour before attribution existed.
97 #[must_use]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 /// Records that `target` arrived on the mainline at `introduced_by`.
103 pub fn insert(&mut self, target: String, introduced_by: String) {
104 self.resolved.insert(target, introduced_by);
105 }
106
107 /// Records a target that could not be placed — not an ancestor of the
108 /// branch tip, or absent from the clone.
109 pub fn insert_unresolved(&mut self, target: String) {
110 self.unresolved.insert(target);
111 }
112
113 /// The mainline commit that introduced `target`, if known.
114 #[must_use]
115 pub fn get(&self, target: &str) -> Option<&str> {
116 self.resolved.get(target).map(String::as_str)
117 }
118
119 /// How many targets were placed.
120 #[must_use]
121 pub fn resolved_count(&self) -> usize {
122 self.resolved.len()
123 }
124
125 /// Targets that could not be placed. Non-empty is normal — a revert
126 /// may target work that never reached this branch — but a large
127 /// fraction means the corpus or the branch is the wrong one.
128 #[must_use]
129 pub fn unresolved(&self) -> &BTreeSet<String> {
130 &self.unresolved
131 }
132}
133
134/// Every distinct commit named by a `This reverts commit` line.
135#[must_use]
136pub fn revert_targets(history: &[Commit]) -> BTreeSet<String> {
137 history.iter().filter_map(Commit::reverts).collect()
138}
139
140/// One merge and whether history later took it back.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct MergeRecord {
143 /// The merge commit's object id.
144 pub merge: String,
145 /// The reverting commit, when one was found inside the window.
146 pub reverted_by: Option<String>,
147 /// How many commits after the merge the revert landed. `None` when
148 /// there was no revert.
149 pub distance: Option<usize>,
150}
151
152/// Base rate over a labelled history.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct BaseRate {
155 /// Merges examined.
156 pub merges: usize,
157 /// Merges reverted within the window.
158 pub reverted: usize,
159 /// Commits in the history that was scanned, for context: a rate over
160 /// 40 merges and a rate over 40,000 are not the same evidence.
161 pub commits: usize,
162 /// Commits that revert *anything*, anywhere in the scanned history.
163 ///
164 /// This is the corpus-suitability signal, and measuring git/git is
165 /// what showed it was needed: 15,579 merges, 46 revert commits in the
166 /// whole first-parent history, giving a labelled rate of 0.001. That
167 /// is not "git's merges are 99.9% safe" — it is a project that drops
168 /// bad topics from an integration branch before they reach the
169 /// mainline instead of reverting them. The proxy measures revert
170 /// *culture*, and where there is none it reads as safety.
171 ///
172 /// A corpus with near-zero reverts cannot supply a base rate at all.
173 /// Check this before believing [`Self::rate`].
174 pub revert_commits: usize,
175 /// The window the labelling used, in commits.
176 pub window: usize,
177}
178
179impl BaseRate {
180 /// Reverted merges as a fraction of merges, in `0.0..=1.0`.
181 ///
182 /// **A lower bound on the true bad-merge rate**, for the reasons in
183 /// the module docs. A history with no merges scores 0.0 rather than
184 /// dividing by zero — check [`Self::merges`] before believing it.
185 #[must_use]
186 pub fn rate(&self) -> f64 {
187 if self.merges == 0 {
188 return 0.0;
189 }
190 self.reverted as f64 / self.merges as f64
191 }
192
193 /// Whether the corpus reverts often enough for [`Self::rate`] to mean
194 /// anything, at a deliberately low bar: at least one revert commit
195 /// per 200 scanned commits.
196 ///
197 /// The threshold is a judgement, not a measurement, and it is set
198 /// where it is because git/git sits an order of magnitude below it
199 /// (46 reverts in 24,234 first-parent commits) while a
200 /// merge-queue-driven project sits above. A `false` here means "find
201 /// another corpus", never "this project's merges are safe".
202 #[must_use]
203 pub fn corpus_is_suitable(&self) -> bool {
204 self.commits > 0 && self.revert_commits * 200 >= self.commits
205 }
206}
207
208/// Parses `git log LOG_FORMAT` output, newest commit first.
209///
210/// Tolerates trailing whitespace between records, which git emits.
211#[must_use]
212pub fn parse_history(log: &str) -> Vec<Commit> {
213 log.split(RECORD)
214 .filter_map(|record| {
215 let record = record.trim_start_matches(['\n', '\r']);
216 if record.is_empty() {
217 return None;
218 }
219 let mut fields = record.split(FIELD);
220 let id = fields.next()?.trim().to_string();
221 if id.is_empty() {
222 return None;
223 }
224 let parents = fields
225 .next()?
226 .split_whitespace()
227 .map(String::from)
228 .collect();
229 let body = fields.next().unwrap_or_default().to_string();
230 Some(Commit { id, parents, body })
231 })
232 .collect()
233}
234
235/// Labels every merge in `history` (newest first) as reverted or not,
236/// looking at most `window` commits forward from each merge.
237///
238/// Distance is measured in positions within `history`, so the caller is
239/// responsible for handing over a sequence where that means something —
240/// [`history`] uses `--first-parent` for exactly this reason.
241///
242/// A `window` of 0 finds nothing; the caller picks it, because the right
243/// value is a property of the project's release cadence, not of this
244/// code.
245#[must_use]
246pub fn label_merges(
247 history: &[Commit],
248 window: usize,
249 attribution: &Attribution,
250) -> Vec<MergeRecord> {
251 // position in history -> commit, so "within N commits after" is a
252 // slice rather than a graph walk. History is newest-first, so a
253 // commit *after* the merge in time sits at a *lower* index.
254 let index: BTreeMap<&str, usize> = history
255 .iter()
256 .enumerate()
257 .map(|(i, c)| (c.id.as_str(), i))
258 .collect();
259
260 // Reverted oid -> the commit that reverted it. A commit may be
261 // reverted more than once in a messy history; the newest reverting
262 // commit wins, which is the one this loop sees last.
263 let mut reverts: BTreeMap<String, &Commit> = BTreeMap::new();
264 for commit in history {
265 if let Some(target) = commit.reverts() {
266 reverts.insert(target, commit);
267 }
268 }
269
270 history
271 .iter()
272 .filter(|c| c.is_merge())
273 .map(|merge| {
274 let hit = reverts
275 .iter()
276 .find(|(target, _)| {
277 // Direct hit: the revert names this merge. git's
278 // message may abbreviate the oid, so match by prefix
279 // in whichever direction is shorter.
280 if merge.id.starts_with(target.as_str()) || target.starts_with(&merge.id) {
281 return true;
282 }
283 // Attributed hit: the revert names a commit this merge
284 // introduced. This is the common case in a
285 // pull-request workflow and the whole reason
286 // `Attribution` exists.
287 attribution.get(target) == Some(merge.id.as_str())
288 })
289 .map(|(_, c)| *c);
290 let merge_pos = index.get(merge.id.as_str()).copied();
291 let (reverted_by, distance) = match (hit, merge_pos) {
292 (Some(rev), Some(mpos)) => match index.get(rev.id.as_str()) {
293 // Newest-first: the revert must sit at a lower index
294 // than the merge, or it predates it and is unrelated.
295 Some(&rpos) if rpos < mpos && mpos - rpos <= window => {
296 (Some(rev.id.clone()), Some(mpos - rpos))
297 }
298 _ => (None, None),
299 },
300 _ => (None, None),
301 };
302 MergeRecord {
303 merge: merge.id.clone(),
304 reverted_by,
305 distance,
306 }
307 })
308 .collect()
309}
310
311/// Rolls labelled merges up into a base rate.
312#[must_use]
313pub fn base_rate(history: &[Commit], window: usize, attribution: &Attribution) -> BaseRate {
314 let labelled = label_merges(history, window, attribution);
315 BaseRate {
316 merges: labelled.len(),
317 reverted: labelled.iter().filter(|m| m.reverted_by.is_some()).count(),
318 commits: history.len(),
319 revert_commits: history.iter().filter(|c| c.reverts().is_some()).count(),
320 window,
321 }
322}
323
324/// Runs `git log --first-parent` in `repo` and returns output for
325/// [`parse_history`].
326///
327/// **`--first-parent` is load-bearing, not a tidying flag.** Without it,
328/// the position of a commit in `git log` output depends on git's
329/// date-and-topology ordering heuristics, so "reverted within N commits"
330/// would mean something slightly different in every branchy history —
331/// two commits could sit 2 or 3 apart depending on how their timestamps
332/// happened to fall. Along first-parent order the listing *is* the
333/// mainline, so distance is exactly "N landings later", which is the
334/// quantity the window is trying to express.
335///
336/// It also fixes what gets counted: first-parent order lists the merges
337/// that landed on this branch and skips commits internal to the branches
338/// they merged, which is the population D23 cares about.
339///
340/// `max` caps the commits scanned; 0 means the whole history. Shelling
341/// out rather than linking a git library is the workspace's standing
342/// posture.
343///
344/// # Errors
345///
346/// Returns a description if git cannot be spawned or exits nonzero.
347pub fn history(repo: &std::path::Path, max: usize) -> Result<String, String> {
348 let mut cmd = std::process::Command::new("git");
349 cmd.arg("log").arg("--first-parent").arg(LOG_FORMAT);
350 if max > 0 {
351 cmd.arg(format!("-{max}"));
352 }
353 let out = cmd
354 .current_dir(repo)
355 .output()
356 .map_err(|e| format!("git log: {e}"))?;
357 if !out.status.success() {
358 return Err(format!(
359 "git log failed: {}",
360 String::from_utf8_lossy(&out.stderr).trim()
361 ));
362 }
363 // Lossy, not strict. Real histories carry commit messages that are
364 // not valid UTF-8 — git/git has them at around 9.5 MB into its log,
365 // from the pre-UTF-8 era — and refusing the whole corpus over an
366 // author's name in Latin-1 would be absurd. Everything this module
367 // reads is ASCII: the 0x1f/0x1e framing, hex oids, and the literal
368 // "This reverts commit ". Replacement characters land only inside
369 // message text the labelling never inspects.
370 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
371}
372
373/// Which of `oids` are commits present in `repo`, in one `cat-file`
374/// pass.
375///
376/// Lazy fetching is disabled. On a partial clone any command touching a
377/// missing object otherwise reaches for the network, and a revert can
378/// name a commit that no longer exists upstream at all — rust-lang/rust
379/// has one — which turns an offline analysis into a failed fetch that
380/// aborts the whole run.
381///
382/// # Errors
383///
384/// Git failing to run at all.
385fn existing_commits(
386 repo: &std::path::Path,
387 oids: &BTreeSet<String>,
388) -> Result<BTreeSet<String>, String> {
389 use std::io::Write;
390 let mut child = std::process::Command::new("git")
391 .args(["cat-file", "--batch-check"])
392 .current_dir(repo)
393 .env("GIT_NO_LAZY_FETCH", "1")
394 .stdin(std::process::Stdio::piped())
395 .stdout(std::process::Stdio::piped())
396 .stderr(std::process::Stdio::null())
397 .spawn()
398 .map_err(|e| format!("git cat-file: {e}"))?;
399 {
400 let mut stdin = child.stdin.take().ok_or("git cat-file: no stdin")?;
401 for oid in oids {
402 writeln!(stdin, "{oid}").map_err(|e| format!("git cat-file: {e}"))?;
403 }
404 }
405 let out = child
406 .wait_with_output()
407 .map_err(|e| format!("git cat-file: {e}"))?;
408 Ok(String::from_utf8_lossy(&out.stdout)
409 .lines()
410 .filter_map(|line| {
411 let mut f = line.split_whitespace();
412 let oid = f.next()?;
413 (f.next() == Some("commit")).then(|| oid.to_string())
414 })
415 .collect())
416}
417
418/// Whether `ancestor` is an ancestor of `descendant` in `repo`.
419///
420/// # Errors
421///
422/// Git failing for any reason other than a clean "no" — most often an
423/// object missing from a filtered clone.
424fn is_ancestor(repo: &std::path::Path, ancestor: &str, descendant: &str) -> Result<bool, String> {
425 let out = std::process::Command::new("git")
426 .args(["merge-base", "--is-ancestor", ancestor, descendant])
427 .current_dir(repo)
428 // Never reach for the network mid-analysis; callers have already
429 // established that both objects are present locally.
430 .env("GIT_NO_LAZY_FETCH", "1")
431 .output()
432 .map_err(|e| format!("git merge-base: {e}"))?;
433 match out.status.code() {
434 Some(0) => Ok(true),
435 Some(1) => Ok(false),
436 _ => Err(format!(
437 "git merge-base: {}",
438 String::from_utf8_lossy(&out.stderr).trim()
439 )),
440 }
441}
442
443/// Places every target in `targets` on the mainline `history`.
444///
445/// A target already on the mainline maps to itself. Otherwise it arrived
446/// through some merge's second parent, and the mainline commit that
447/// introduced it is the **oldest** one having it as an ancestor.
448///
449/// Found by binary search, not a scan: ancestry is monotone along
450/// first-parent order — if a commit is an ancestor of some mainline
451/// commit it is an ancestor of every later one — so the predicate flips
452/// exactly once. That is ~log2(n) git calls per target instead of n. On
453/// rust-lang/rust's 45,061-commit mainline it is 16 calls, about 0.15 s.
454///
455/// Targets that are not ancestors of the branch tip at all, or whose
456/// object is missing from a filtered clone, are recorded as unresolved
457/// rather than dropped: how many failed to place is part of reading the
458/// result.
459///
460/// # Errors
461///
462/// Git failing in a way that is not a clean answer, which would
463/// otherwise be silently miscounted as "not an ancestor".
464pub fn attribute(
465 repo: &std::path::Path,
466 history: &[Commit],
467 targets: &BTreeSet<String>,
468) -> Result<Attribution, String> {
469 let mut attribution = Attribution::new();
470 if history.is_empty() {
471 for t in targets {
472 attribution.insert_unresolved(t.clone());
473 }
474 return Ok(attribution);
475 }
476 let on_mainline: BTreeSet<&str> = history.iter().map(|c| c.id.as_str()).collect();
477 let tip = history[0].id.as_str();
478 // A revert can name a commit this clone does not have — force-pushed
479 // away, or from a branch that never landed. Establish that up front
480 // in one pass rather than discovering it as a git failure per target.
481 let present = existing_commits(repo, targets)?;
482
483 for target in targets {
484 if on_mainline.contains(target.as_str()) {
485 attribution.insert(target.clone(), target.clone());
486 continue;
487 }
488 if !present.contains(target) {
489 attribution.insert_unresolved(target.clone());
490 continue;
491 }
492 // Not on the mainline, and not even behind the tip: it belongs to
493 // another branch, so no merge here introduced it.
494 if !is_ancestor(repo, target, tip)? {
495 attribution.insert_unresolved(target.clone());
496 continue;
497 }
498 // Newest-first, so the predicate is true for every index up to
499 // some k and false after: find the largest such k.
500 let (mut lo, mut hi) = (0usize, history.len() - 1);
501 while lo < hi {
502 // Round up, so `lo == hi - 1` advances instead of looping.
503 let mid = lo + (hi - lo).div_ceil(2);
504 if is_ancestor(repo, target, &history[mid].id)? {
505 lo = mid;
506 } else {
507 hi = mid - 1;
508 }
509 }
510 attribution.insert(target.clone(), history[lo].id.clone());
511 }
512 Ok(attribution)
513}