Skip to main content

choir_queue/
differential.rs

1//! Executable merged-vs-both-parents differential testing (D23).
2//!
3//! The same explicit command runs in three caller-supplied trees. A failure is
4//! merge-specific only when both parents pass and the merged tree fails. If a
5//! parent already fails, the observation is inconclusive rather than evidence
6//! about the merge. This is intentionally smaller than a test orchestrator:
7//! callers own checkout/sandbox construction, while this module fixes the
8//! classification and calibration semantics shared by queue implementations.
9//!
10//! Commands are argv, not shell strings, and the environment they run in is
11//! explicit: the child sees exactly the map the caller passes, never this
12//! process's inherited variables. Execution is synchronous and adds no
13//! runtime or dependency.
14
15use std::collections::BTreeMap;
16use std::path::Path;
17
18/// One command's process outcome in one revision tree.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Observation {
21    /// Whether the process exited successfully.
22    pub success: bool,
23    /// Numeric exit code, or `None` when the process ended by signal.
24    pub exit_code: Option<i32>,
25}
26
27impl Observation {
28    /// Versioned-report representation used by the calibration ledger.
29    #[must_use]
30    pub fn to_json(self) -> serde_json::Value {
31        serde_json::json!({
32            "success": self.success,
33            "exit_code": self.exit_code,
34        })
35    }
36
37    fn from_json(value: &serde_json::Value) -> Result<Self, String> {
38        let success = value["success"]
39            .as_bool()
40            .ok_or("differential observation needs boolean success")?;
41        let exit_code = match &value["exit_code"] {
42            serde_json::Value::Null => None,
43            value => Some(
44                i32::try_from(
45                    value
46                        .as_i64()
47                        .ok_or("differential observation exit_code must be an integer or null")?,
48                )
49                .map_err(|_| "differential observation exit_code is outside i32")?,
50            ),
51        };
52        Ok(Self { success, exit_code })
53    }
54}
55
56/// What the three executions establish about the merge.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Verdict {
59    /// Both parents and the merge passed this command.
60    Clean,
61    /// Both parents passed and only the merge failed.
62    InteractionFailure,
63    /// At least one parent failed, so the merge cannot be blamed.
64    InconclusiveParentFailure,
65}
66
67impl Verdict {
68    /// Stable spelling used in versioned receipts.
69    #[must_use]
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            Self::Clean => "clean",
73            Self::InteractionFailure => "interaction_failure",
74            Self::InconclusiveParentFailure => "inconclusive_parent_failure",
75        }
76    }
77
78    fn from_str(value: &str) -> Result<Self, String> {
79        match value {
80            "clean" => Ok(Self::Clean),
81            "interaction_failure" => Ok(Self::InteractionFailure),
82            "inconclusive_parent_failure" => Ok(Self::InconclusiveParentFailure),
83            _ => Err("unknown differential verdict".to_string()),
84        }
85    }
86}
87
88/// Complete three-revision receipt for one command.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct DifferentialReport {
91    /// First parent outcome.
92    pub parent_a: Observation,
93    /// Second parent outcome.
94    pub parent_b: Observation,
95    /// Merged-tree outcome.
96    pub merged: Observation,
97    /// Classification derived from the three outcomes.
98    pub verdict: Verdict,
99}
100
101impl DifferentialReport {
102    /// Stable JSON embedded in each versioned observation row.
103    #[must_use]
104    pub fn to_json(&self) -> serde_json::Value {
105        serde_json::json!({
106            "parent_a": self.parent_a.to_json(),
107            "parent_b": self.parent_b.to_json(),
108            "merged": self.merged.to_json(),
109            "verdict": self.verdict.as_str(),
110        })
111    }
112
113    /// Parses and independently rechecks the recorded classification.
114    ///
115    /// # Errors
116    ///
117    /// A required field is absent, malformed, or claims a verdict that does
118    /// not follow from the three process observations.
119    pub fn from_json(value: &serde_json::Value) -> Result<Self, String> {
120        let parent_a = Observation::from_json(&value["parent_a"])?;
121        let parent_b = Observation::from_json(&value["parent_b"])?;
122        let merged = Observation::from_json(&value["merged"])?;
123        let recorded = Verdict::from_str(
124            value["verdict"]
125                .as_str()
126                .ok_or("differential report needs a verdict")?,
127        )?;
128        let derived = if !parent_a.success || !parent_b.success {
129            Verdict::InconclusiveParentFailure
130        } else if !merged.success {
131            Verdict::InteractionFailure
132        } else {
133            Verdict::Clean
134        };
135        if recorded != derived {
136            return Err("differential report verdict disagrees with its observations".to_string());
137        }
138        Ok(Self {
139            parent_a,
140            parent_b,
141            merged,
142            verdict: derived,
143        })
144    }
145}
146
147/// Ends a timed-out run and everything it spawned.
148///
149/// `Child::kill` signals one pid. The commands this module runs are whole
150/// test invocations, and a shell forks rather than execs anything that is
151/// not its tail call, so the pid we hold is usually a shell whose death
152/// leaves the real work running. Signalling the group covers the
153/// descendants; the direct kill after it is the fallback for a child that
154/// never got a group of its own. Without this the deadline bounded the
155/// verdict but not the machine: each timeout left a live process behind.
156fn kill_run_and_descendants(child: &mut std::process::Child) {
157    #[cfg(unix)]
158    {
159        // std has no `killpg` and the workspace has no libc dependency, so
160        // the one call is declared here — the trade `choir-spike` already
161        // makes for `clonefile`. It has to be a syscall rather than a
162        // spawned `kill`: what this recovers from is a machine filling with
163        // survivors, which is exactly when spawning anything is least
164        // likely to work.
165        extern "C" {
166            fn killpg(pgrp: std::ffi::c_int, sig: std::ffi::c_int) -> std::ffi::c_int;
167            fn getpgid(pid: std::ffi::c_int) -> std::ffi::c_int;
168        }
169        // `process_group(0)` above made the child its own group leader.
170        // Checked rather than assumed, because the two live thirty lines
171        // apart and the failure is not local: a pid that is *not* also a
172        // group id names some other group, and signalling that would kill
173        // processes this module never started. If the spawn ever stops
174        // setting the group, this degrades to the direct kill below.
175        if let Ok(pid) = std::ffi::c_int::try_from(child.id()) {
176            if unsafe { getpgid(pid) } == pid {
177                // SIGKILL, not SIGTERM: the run has already ignored its
178                // deadline, so this is the reap, not a wind-down request.
179                unsafe { killpg(pid, 9) };
180            }
181        }
182    }
183    child.kill().ok();
184}
185
186fn run_one(
187    program: &str,
188    args: &[String],
189    dir: &Path,
190    env: &BTreeMap<String, String>,
191    timeout: Option<std::time::Duration>,
192) -> Result<Observation, String> {
193    let mut command = std::process::Command::new(program);
194    command
195        .args(args)
196        .env_clear()
197        .envs(env)
198        .current_dir(dir)
199        .stdout(std::process::Stdio::null())
200        .stderr(std::process::Stdio::null());
201    // Its own process group, so a deadline can reach what the command
202    // spawned and not only the command. The cost is that the child no
203    // longer shares this process's terminal group, so an interactive
204    // Ctrl-C reaches it through us rather than directly.
205    #[cfg(unix)]
206    {
207        use std::os::unix::process::CommandExt;
208        command.process_group(0);
209    }
210    let mut child = command
211        .spawn()
212        .map_err(|error| format!("run differential command in {}: {error}", dir.display()))?;
213    let status = match timeout {
214        None => child.wait().map_err(|error| {
215            format!(
216                "wait for differential command in {}: {error}",
217                dir.display()
218            )
219        })?,
220        Some(timeout) => {
221            // Hand-rolled deadline poll: no wait-with-timeout in std, and no
222            // dependency for something this small. 100 ms of granularity is
223            // noise against runs measured in tens of seconds.
224            let deadline = std::time::Instant::now() + timeout;
225            loop {
226                match child.try_wait() {
227                    Ok(Some(status)) => break status,
228                    Ok(None) if std::time::Instant::now() >= deadline => {
229                        kill_run_and_descendants(&mut child);
230                        child.wait().ok();
231                        // Operational error, never a verdict: a timeout cannot
232                        // distinguish a hang from a slow run, so it must not
233                        // be allowed to mint an interaction failure.
234                        return Err(format!(
235                            "differential command in {} timed out after {} s",
236                            dir.display(),
237                            timeout.as_secs()
238                        ));
239                    }
240                    Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
241                    Err(error) => {
242                        return Err(format!(
243                            "wait for differential command in {}: {error}",
244                            dir.display()
245                        ))
246                    }
247                }
248            }
249        }
250    };
251    Ok(Observation {
252        success: status.success(),
253        exit_code: status.code(),
254    })
255}
256
257/// Runs one explicit command in both parent trees and the merged tree.
258///
259/// `env` is the complete environment every run sees; nothing is inherited
260/// from this process. That closes the reproducibility gap where "same
261/// command" enforced same argv while an ambient variable (a leaked
262/// `CARGO_TARGET_DIR`, a changed `RUSTFLAGS`) silently changed what the
263/// three runs measured. Callers building the map from a command file should
264/// use [`crate::differential_ledger::effective_environment`], which is what
265/// the ledger's recorded environment hash is computed over.
266///
267/// The three runs happen **concurrently**, one thread each. They are
268/// independent by construction — three separate checkouts, and the caller
269/// owns their isolation — so this is a straight 3x on the dominant cost
270/// without touching what is measured: every run is still really run, which
271/// is what the calibration's declared `independent_runs` assumption needs.
272/// A result cache would be faster still and would quietly void that
273/// assumption, since determinism is the property under test.
274///
275/// The one thing to know before pointing a command at this: if the command
276/// writes to a location the three trees *share* — an absolute
277/// `--target-dir`, say — they will serialize on that tool's own lock
278/// rather than run in parallel. Correctness is unaffected either way; the
279/// speedup is not. Keeping such state per-tree is what makes this pay.
280///
281/// `timeout` bounds each of the three runs individually; `None` waits
282/// forever, which was the only behavior before the parameter existed. A
283/// run that exceeds it is killed and reported as an **error**, never as a
284/// verdict — a timeout cannot distinguish a hung command from a slow one,
285/// so it must not become evidence about the merge. Unattended corpus
286/// walks (`choir-bridge harvest`, D27) are the reason it exists: one hung
287/// test run must not stall a multi-hour walk forever.
288///
289/// # Errors
290///
291/// The program could not be spawned in one of the three directories, or a
292/// run exceeded `timeout`. All
293/// three are attempted before reporting: unlike the previous sequential
294/// form, a spawn failure in `parent_a` no longer prevents the other two
295/// from running. The reported error is still the earliest in
296/// `parent_a`, `parent_b`, `merged` order, so the message a caller sees
297/// for a given failure is unchanged.
298///
299/// # Panics
300///
301/// If one of the three worker threads panics, this propagates that panic
302/// rather than reporting a verdict computed from two runs.
303pub fn run_merged_vs_parents(
304    program: &str,
305    args: &[String],
306    parent_a: &Path,
307    parent_b: &Path,
308    merged: &Path,
309    env: &BTreeMap<String, String>,
310    timeout: Option<std::time::Duration>,
311) -> Result<DifferentialReport, String> {
312    if program.is_empty() {
313        return Err("differential program must not be empty".to_string());
314    }
315    // `scope` rather than `spawn`: the borrows of `program`, `args` and the
316    // three paths outlive the threads without cloning anything, and the
317    // scope will not return until all three have been joined.
318    let (parent_a, parent_b, merged) = std::thread::scope(|scope| {
319        let a = scope.spawn(|| run_one(program, args, parent_a, env, timeout));
320        let b = scope.spawn(|| run_one(program, args, parent_b, env, timeout));
321        let m = scope.spawn(|| run_one(program, args, merged, env, timeout));
322        (
323            a.join().expect("parent-a differential thread panicked"),
324            b.join().expect("parent-b differential thread panicked"),
325            m.join().expect("merged differential thread panicked"),
326        )
327    });
328    let (parent_a, parent_b, merged) = (parent_a?, parent_b?, merged?);
329    let verdict = if !parent_a.success || !parent_b.success {
330        Verdict::InconclusiveParentFailure
331    } else if !merged.success {
332        Verdict::InteractionFailure
333    } else {
334        Verdict::Clean
335    };
336    Ok(DifferentialReport {
337        parent_a,
338        parent_b,
339        merged,
340        verdict,
341    })
342}
343
344/// Minimum conclusive sample for the fixed 95% zero-spurious confidence rule.
345///
346/// At the target boundary, `(999 / 1000)^2994` remains above 5%, while
347/// `(999 / 1000)^2995` is below 5%. The rule is deliberately conservative:
348/// any observed spurious failure refuses the confidence claim rather than
349/// selecting a more favorable test after seeing the data.
350pub const CONFIDENCE_MIN_EVALUATED_MERGES: u64 = 2_995;
351
352/// Frozen statistical rule attached to every calibration receipt.
353///
354/// This rule only covers sampling error. Its independence and
355/// representativeness assumptions are named rather than inferred from counts.
356#[must_use]
357pub fn confidence_policy() -> serde_json::Value {
358    serde_json::json!({
359        "format_version": 1,
360        "method": "one_sided_exact_binomial_zero_spurious",
361        "confidence": {
362            "numerator": 95,
363            "denominator": 100,
364        },
365        "target": {
366            "numerator": 1,
367            "denominator": 1000,
368            "comparison": "strictly_less_than",
369        },
370        "minimum_evaluated_merges": CONFIDENCE_MIN_EVALUATED_MERGES,
371        "requires_zero_spurious_failures": true,
372        "assumptions": [
373            "independent_runs",
374            "representative_queue_command_and_merge_population",
375        ],
376    })
377}
378
379/// Count-based false-positive calibration for differential failures.
380///
381/// Every interaction failure must be adjudicated before the target has a
382/// verdict. The operational D23 target is exact rational arithmetic: spurious
383/// failures / evaluated merges must be strictly below 1/1000. This reports the
384/// observed rate. The frozen confidence rule is a sufficient zero-spurious
385/// test and remains indeterminate until its minimum sample is reached.
386#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
387pub struct Calibration {
388    evaluated_merges: u64,
389    inconclusive_merges: u64,
390    interaction_failures: u64,
391    confirmed_interactions: u64,
392    spurious_failures: u64,
393    pending_interactions: u64,
394}
395
396impl Calibration {
397    /// Records one report. Interaction failures require a ground-truth
398    /// adjudication (`true` = real interaction, `false` = spurious).
399    /// Clean and inconclusive reports must not carry an adjudication.
400    ///
401    /// # Errors
402    ///
403    /// An interaction is unadjudicated, or an adjudication is attached to a
404    /// report that did not flag an interaction.
405    pub fn record(
406        &mut self,
407        report: &DifferentialReport,
408        real_interaction: Option<bool>,
409    ) -> Result<(), String> {
410        match report.verdict {
411            Verdict::Clean => {
412                if real_interaction.is_some() {
413                    return Err("a clean report must not carry an adjudication".to_string());
414                }
415                self.evaluated_merges = self.evaluated_merges.saturating_add(1);
416            }
417            Verdict::InteractionFailure => {
418                let real = real_interaction
419                    .ok_or("an interaction failure needs adjudication before calibration")?;
420                self.evaluated_merges = self.evaluated_merges.saturating_add(1);
421                self.interaction_failures = self.interaction_failures.saturating_add(1);
422                if real {
423                    self.confirmed_interactions = self.confirmed_interactions.saturating_add(1);
424                } else {
425                    self.spurious_failures = self.spurious_failures.saturating_add(1);
426                }
427            }
428            Verdict::InconclusiveParentFailure => {
429                if real_interaction.is_some() {
430                    return Err("an inconclusive report must not carry an adjudication".to_string());
431                }
432                self.inconclusive_merges = self.inconclusive_merges.saturating_add(1);
433            }
434        }
435        Ok(())
436    }
437
438    /// Records an interaction flag whose ground truth has not been decided.
439    /// It enters the observed denominator, but suppresses the target verdict
440    /// until an adjudication-led replay replaces it with [`Self::record`].
441    ///
442    /// # Errors
443    ///
444    /// The report is not an interaction failure.
445    pub fn record_pending(&mut self, report: &DifferentialReport) -> Result<(), String> {
446        if report.verdict != Verdict::InteractionFailure {
447            return Err("only an interaction failure can be pending adjudication".to_string());
448        }
449        self.evaluated_merges = self.evaluated_merges.saturating_add(1);
450        self.interaction_failures = self.interaction_failures.saturating_add(1);
451        self.pending_interactions = self.pending_interactions.saturating_add(1);
452        Ok(())
453    }
454
455    /// Whether the observed spurious-failure rate is strictly below 0.1%.
456    /// `None` means no merge has produced a conclusive three-revision result.
457    #[must_use]
458    pub fn target_met(&self) -> Option<bool> {
459        (self.evaluated_merges != 0 && self.pending_interactions == 0)
460            .then(|| u128::from(self.spurious_failures) * 1000 < u128::from(self.evaluated_merges))
461    }
462
463    fn confidence_claim(&self) -> Option<bool> {
464        (self.evaluated_merges >= CONFIDENCE_MIN_EVALUATED_MERGES && self.pending_interactions == 0)
465            .then_some(self.spurious_failures == 0)
466    }
467
468    /// Versioned JSON receipt. The exact numerator and denominator are
469    /// action-driving; basis points are presentation only and floor-rounded.
470    #[must_use]
471    pub fn receipt(&self) -> serde_json::Value {
472        let rate_basis_points = (self.evaluated_merges != 0).then(|| {
473            (u128::from(self.spurious_failures) * 10_000 / u128::from(self.evaluated_merges)) as u64
474        });
475        serde_json::json!({
476            "format_version": 1,
477            "evaluated_merges": self.evaluated_merges,
478            "inconclusive_parent_failures": self.inconclusive_merges,
479            "interaction_failures": self.interaction_failures,
480            "confirmed_interactions": self.confirmed_interactions,
481            "spurious_failures": self.spurious_failures,
482            "pending_interactions": self.pending_interactions,
483            "spurious_failure_rate": {
484                "numerator": self.spurious_failures,
485                "denominator": self.evaluated_merges,
486                "basis_points_floor": rate_basis_points,
487            },
488            "target": {
489                "numerator": 1,
490                "denominator": 1000,
491                "comparison": "strictly_less_than",
492                "met": self.target_met(),
493            },
494            "confidence_claim": self.confidence_claim(),
495            "confidence_policy": confidence_policy(),
496            "landing_gate_enabled": false,
497        })
498    }
499}