Skip to main content

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}