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}