Skip to main content

formal_ai/promotion/
gates.rs

1//! Canonical, executable promotion gates.
2//!
3//! Proposal documents name changes, never commands or observed results. This
4//! module owns the allow-listed commands and derives evidence only from their
5//! fresh process output, preventing a proposal from promoting itself by
6//! claiming fabricated counts or substituting a benign runner.
7
8use std::path::Path;
9use std::process::Command;
10
11use crate::engine::stable_id;
12
13use super::{PromotionProposal, PromotionRatchet};
14
15/// Captured result of one canonical benchmark command.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct GateCommandOutput {
18    /// Whether the command exited successfully.
19    pub succeeded: bool,
20    /// Captured standard output.
21    pub stdout: String,
22    /// Captured standard error.
23    pub stderr: String,
24}
25
26impl GateCommandOutput {
27    /// A successful fixture or executor result.
28    #[must_use]
29    pub fn success(stdout: impl Into<String>) -> Self {
30        Self {
31            succeeded: true,
32            stdout: stdout.into(),
33            stderr: String::new(),
34        }
35    }
36
37    /// A failed fixture or executor result.
38    #[must_use]
39    pub fn failure(stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
40        Self {
41            succeeded: false,
42            stdout: stdout.into(),
43            stderr: stderr.into(),
44        }
45    }
46}
47
48/// Replay all required promotion gates with an injectable command executor.
49///
50/// Each canonical command runs once and its immutable evidence is cloned onto
51/// every proposal considered in this batch. Successful commands without a
52/// parseable pass/fail report are rejected as malformed evidence; failed
53/// commands become ordinary blocking evidence so proposals remain durable.
54pub fn replay_promotion_gates_with<F>(
55    mut proposals: Vec<PromotionProposal>,
56    mut execute: F,
57) -> Result<Vec<PromotionProposal>, String>
58where
59    F: FnMut(&str) -> Result<GateCommandOutput, String>,
60{
61    let mut evidence = Vec::new();
62    for mut gate in required_gates() {
63        let output = execute(&gate.runner).map_err(|error| {
64            format!(
65                "could not execute canonical gate `{}`: {error}",
66                gate.suite_id
67            )
68        })?;
69        let joined = format!(
70            "suite={}\nrunner={}\nsucceeded={}\nstdout:\n{}\nstderr:\n{}",
71            gate.suite_id, gate.runner, output.succeeded, output.stdout, output.stderr
72        );
73        gate.evidence_digest = Some(stable_id("promotion_gate_output", &joined));
74        gate.command_succeeded = output.succeeded;
75        if output.succeeded {
76            let (passed, failed) =
77                parse_pass_fail(&output.stdout, &output.stderr).ok_or_else(|| {
78                    format!(
79                        "canonical gate `{}` succeeded without a parseable pass/fail report",
80                        gate.suite_id
81                    )
82                })?;
83            gate.passed = passed;
84            gate.failed = failed;
85        } else {
86            gate.passed = 0;
87            gate.failed = 1;
88        }
89        evidence.push(gate);
90    }
91    for proposal in &mut proposals {
92        proposal.gates.clone_from(&evidence);
93    }
94    Ok(proposals)
95}
96
97/// Replay canonical gates as subprocesses rooted at `workspace_root`.
98///
99/// Commands are not sourced from proposal data. They come exclusively from the
100/// checked-in manifests (plus the fixed unit-specification command), so invoking
101/// the shell here cannot introduce proposal-controlled command execution.
102pub fn replay_promotion_gates(
103    proposals: Vec<PromotionProposal>,
104    workspace_root: &Path,
105) -> Result<Vec<PromotionProposal>, String> {
106    replay_promotion_gates_with(proposals, |runner| {
107        let output = Command::new("sh")
108            .arg("-c")
109            .arg(runner)
110            .current_dir(workspace_root)
111            .output()
112            .map_err(|error| format!("failed to start `{runner}`: {error}"))?;
113        Ok(GateCommandOutput {
114            succeeded: output.status.success(),
115            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
116            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
117        })
118    })
119}
120
121pub(super) fn required_gates() -> Vec<PromotionRatchet> {
122    let mut gates = vec![
123        PromotionRatchet::coding_modification(0, 1),
124        PromotionRatchet::industry(0, 1),
125        PromotionRatchet::unit_specs(0, 1),
126    ];
127    for gate in &mut gates {
128        gate.command_succeeded = false;
129    }
130    gates
131}
132
133fn parse_pass_fail(stdout: &str, stderr: &str) -> Option<(usize, usize)> {
134    let combined = format!("{stdout}\n{stderr}");
135    for line in combined.lines().rev() {
136        if let (Some(passed), Some(failed)) =
137            (count_after(line, "passed="), count_after(line, "failed="))
138        {
139            return Some((passed, failed));
140        }
141    }
142    // Cargo's one-test harness summary follows custom benchmark output. Use it
143    // only for the unit gate; named benchmark case counts take precedence.
144    for line in combined.lines().rev() {
145        if line.contains("test result:") {
146            let passed = count_before(line, " passed;");
147            let failed = count_before(line, " failed;");
148            if let (Some(passed), Some(failed)) = (passed, failed) {
149                return Some((passed, failed));
150            }
151        }
152    }
153    None
154}
155
156fn count_after(line: &str, marker: &str) -> Option<usize> {
157    let tail = line.split_once(marker)?.1;
158    let digits: String = tail.chars().take_while(char::is_ascii_digit).collect();
159    digits.parse().ok()
160}
161
162fn count_before(line: &str, marker: &str) -> Option<usize> {
163    let head = line.split_once(marker)?.0;
164    head.split_whitespace().next_back()?.parse().ok()
165}