Skip to main content

car_server_core/coder/
heal_gate.rs

1//! The review gate for an unattended fix: N independent verdicts, a threshold.
2//!
3//! T4 of `docs/proposals/self-healing-issue-loop.md`. Pure — the verdicts are
4//! an argument. Fetching them costs inference and a network; deciding what they
5//! mean must be reproducible and reviewable, so the two are separated.
6//!
7//! ## This is the SECOND gate, never the first
8//!
9//! The first gate is the [`super::contract::OutcomeContract`], which the
10//! runtime re-runs itself before asking for merge approval regardless of which
11//! engine did the work. That is deterministic and non-negotiable: it answers
12//! "does this actually work". No count of model verdicts substitutes for it or
13//! overrides it, and [`decide`] takes the contract result as an input it can
14//! only ever *narrow*.
15//!
16//! The distinction is not theoretical. In the peer-messaging work that
17//! motivated this loop, a change shipped that made every *successful*
18//! cross-host delivery report as a failure — and every model-written test
19//! passed, because they all asserted inside the dispatcher and never
20//! round-tripped the real client. A deterministic check caught it in one run.
21//! Conversely, model review caught a change that compiled, passed every test,
22//! and would have silently refused every fleet dispatch. Neither gate subsumes
23//! the other.
24//!
25//! ## Independent verdicts, never synthesis
26//!
27//! CAR has a measured result that constrains this: the MoA / tau-bench
28//! experiment found **cross-vendor synthesis degrades agentic tool-use below
29//! the single strong model**. That was about *combining answers*, and
30//! verification is a different operation, so the result does not forbid a panel
31//! — but it is the reason this fans out to N verdicts and applies a threshold,
32//! rather than merging reviewers' reasoning into one answer.
33//!
34//! Reviewers therefore never see each other's verdicts. A panel that reaches
35//! consensus by reading itself is one reviewer with extra steps, and the
36//! failure it is meant to catch — everyone missing the same thing — is exactly
37//! the one correlation reintroduces.
38//!
39//! ## Unreachable is not approval
40//!
41//! A vendor that times out yields no verdict, and a missing verdict is never a
42//! pass. Degrading to "2 of the 2 that answered" would let an outage silently
43//! halve the panel, which is the review equivalent of turning the gate off on
44//! the day it is most needed.
45
46/// Proof that a [`GateOutcome`] came from [`decide`].
47///
48/// A private unit field is the whole mechanism: no other module can name it, so
49/// no other module can build an outcome.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct Sealed(());
52
53/// One reviewer's answer.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Verdict {
56    /// Which model produced it. Recorded so a panel that disagrees can be
57    /// audited by vendor rather than by anonymous count.
58    pub model: String,
59    pub pass: bool,
60    /// The reviewer's stated reason. Shown to humans, never parsed.
61    pub reason: String,
62}
63
64/// A reviewer that could not be reached.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Unreachable {
67    pub model: String,
68    pub error: String,
69}
70
71/// What the panel found, and what follows from it.
72///
73/// Every variant carries [`Sealed`], which only this module can construct, so
74/// [`decide`] is the **only** way to obtain a `GateOutcome`. Without that, any
75/// caller could hand back `Approved` having run no contract and asked no
76/// reviewer — and a gate that a caller can mint is not a gate. The first draft
77/// of this had exactly that hole: the tick's injected `run_coder` returned a
78/// `GateOutcome` directly and `decide` had no callers at all.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum GateOutcome {
81    /// Deterministic checks passed and the panel cleared it.
82    Approved {
83        passes: usize,
84        of: usize,
85        seal: Sealed,
86    },
87    /// The deterministic gate failed. The panel is not consulted at all: a
88    /// change that does not build is not a question for reviewers.
89    ContractFailed { detail: String, seal: Sealed },
90    /// Reviewers were reached but did not clear it.
91    PanelRejected {
92        passes: usize,
93        required: usize,
94        dissent: Vec<Verdict>,
95        seal: Sealed,
96    },
97    /// Too few reviewers answered to reach a threshold at all.
98    PanelIncomplete {
99        answered: usize,
100        required: usize,
101        unreachable: Vec<Unreachable>,
102        seal: Sealed,
103    },
104}
105
106impl GateOutcome {
107    pub fn approved(&self) -> bool {
108        matches!(self, GateOutcome::Approved { .. })
109    }
110
111    /// One line an operator can read without opening the run.
112    pub fn summary(&self) -> String {
113        match self {
114            GateOutcome::Approved { passes, of, .. } => {
115                format!("approved by {passes}/{of} reviewers, checks green")
116            }
117            GateOutcome::ContractFailed { detail, .. } => {
118                format!("outcome contract failed: {detail}")
119            }
120            GateOutcome::PanelRejected {
121                passes,
122                required,
123                dissent,
124                ..
125            } => {
126                let why = dissent
127                    .iter()
128                    .map(|v| format!("{}: {}", v.model, v.reason))
129                    .collect::<Vec<_>>()
130                    .join("; ");
131                format!("{passes}/{required} required approvals — {why}")
132            }
133            GateOutcome::PanelIncomplete {
134                answered,
135                required,
136                unreachable,
137                ..
138            } => {
139                let who = unreachable
140                    .iter()
141                    .map(|u| format!("{} ({})", u.model, u.error))
142                    .collect::<Vec<_>>()
143                    .join("; ");
144                format!(
145                    "only {answered} reviewers answered, {required} approvals required — \
146                     unreachable: {who}"
147                )
148            }
149        }
150    }
151}
152
153/// How many approvals a panel of `panel_size` needs.
154///
155/// A strict majority, so a panel can never be cleared by a minority and a
156/// single compromised or malfunctioning reviewer cannot approve alone. For the
157/// default panel of three this is two.
158pub fn required_approvals(panel_size: usize) -> usize {
159    panel_size / 2 + 1
160}
161
162/// Decide, from the deterministic result and the reviewers' answers.
163///
164/// `panel_size` is how many reviewers were *asked*, not how many replied — the
165/// threshold is fixed before the vote, so an outage cannot lower the bar it has
166/// to clear.
167pub fn decide(
168    contract_passed: bool,
169    contract_detail: &str,
170    panel_size: usize,
171    verdicts: &[Verdict],
172    unreachable: &[Unreachable],
173) -> GateOutcome {
174    // Deterministic first, and reviewers are not consulted on a red build.
175    // Asking a model to bless code that does not compile invites it to explain
176    // why the failure is acceptable, which is a conversation with only one bad
177    // outcome.
178    if !contract_passed {
179        return GateOutcome::ContractFailed {
180            detail: contract_detail.to_string(),
181            seal: Sealed(()),
182        };
183    }
184
185    if panel_size == 0 {
186        return GateOutcome::ContractFailed {
187            detail: "no reviewers were configured; a panel of zero cannot approve".into(),
188            seal: Sealed(()),
189        };
190    }
191
192    // ONE answer per model. `Verdict::model` is recorded so a panel can be
193    // audited by vendor rather than by anonymous count — and then counting
194    // anonymously would let one vendor, retried after a timeout and appended
195    // twice, supply a majority by itself. First answer per model wins; a
196    // retry does not get a second vote.
197    let mut seen: Vec<&str> = Vec::new();
198    let mut answers: Vec<Verdict> = Vec::new();
199    for v in verdicts {
200        if seen.contains(&v.model.as_str()) {
201            continue;
202        }
203        seen.push(&v.model);
204        answers.push(v.clone());
205    }
206    // More distinct answers than reviewers asked means the caller is confused
207    // about its own panel; refusing is safer than picking an interpretation.
208    if answers.len() > panel_size {
209        return GateOutcome::ContractFailed {
210            detail: format!(
211                "{} distinct verdicts for a panel of {panel_size}",
212                answers.len()
213            ),
214            seal: Sealed(()),
215        };
216    }
217
218    let required = required_approvals(panel_size);
219
220    // A missing verdict is not a pass. If too few answered to reach the
221    // threshold even unanimously, that is incomplete rather than rejected —
222    // the distinction matters because the remedy differs: retry the panel
223    // versus fix the change.
224    if answers.len() < required {
225        return GateOutcome::PanelIncomplete {
226            answered: answers.len(),
227            required,
228            unreachable: unreachable.to_vec(),
229            seal: Sealed(()),
230        };
231    }
232
233    let passes = answers.iter().filter(|v| v.pass).count();
234    if passes >= required {
235        return GateOutcome::Approved {
236            passes,
237            of: panel_size,
238            seal: Sealed(()),
239        };
240    }
241
242    GateOutcome::PanelRejected {
243        passes,
244        required,
245        dissent: answers.iter().filter(|v| !v.pass).cloned().collect(),
246        seal: Sealed(()),
247    }
248}
249
250/// The criteria one reviewer is asked to judge against.
251///
252/// Written once, here, so every reviewer in a panel is asked the *same*
253/// question — a panel whose members were prompted differently is measuring
254/// prompt variance, not agreement.
255///
256/// It deliberately does not include the issue body verbatim. The body is
257/// untrusted text (see [`super::provenance`]), and a reviewer prompt is exactly
258/// the place where "ignore the above" would land. Reviewers judge the DIFF
259/// against the stated intent and the deterministic results.
260pub fn review_criteria(intent_summary: &str, contract_detail: &str) -> String {
261    format!(
262        "You are reviewing an automated code change before it is opened as a pull request.\n\
263         \n\
264         Stated intent: {intent_summary}\n\
265         Deterministic checks: {contract_detail}\n\
266         \n\
267         The build and the project's own acceptance checks have ALREADY passed. Do not \
268         re-litigate them. Judge only what they cannot:\n\
269         - Does the change do what the intent says, or something adjacent to it?\n\
270         - Does it introduce a defect the checks would not catch?\n\
271         - Is it scoped to the intent, or does it change unrelated behaviour?\n\
272         \n\
273         Answer PASS or FAIL and one sentence of reason. Default to FAIL if you are \
274         uncertain: a rejected change costs one retry, an approved bad one costs a human's \
275         trust in every later change."
276    )
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn v(model: &str, pass: bool) -> Verdict {
284        Verdict {
285            model: model.into(),
286            pass,
287            reason: if pass { "looks right" } else { "wrong scope" }.into(),
288        }
289    }
290
291    #[test]
292    fn a_red_contract_is_never_put_to_the_panel() {
293        // Even with a unanimous panel: a change that does not build is not a
294        // question reviewers should be asked.
295        let out = decide(
296            false,
297            "cargo test failed",
298            3,
299            &[v("a", true), v("b", true), v("c", true)],
300            &[],
301        );
302        assert!(matches!(out, GateOutcome::ContractFailed { .. }));
303        assert!(!out.approved());
304    }
305
306    #[test]
307    fn a_strict_majority_approves() {
308        let out = decide(
309            true,
310            "green",
311            3,
312            &[v("a", true), v("b", true), v("c", false)],
313            &[],
314        );
315        assert!(matches!(
316            out,
317            GateOutcome::Approved {
318                passes: 2,
319                of: 3,
320                ..
321            }
322        ));
323    }
324
325    #[test]
326    fn a_minority_does_not_approve() {
327        let out = decide(
328            true,
329            "green",
330            3,
331            &[v("a", true), v("b", false), v("c", false)],
332            &[],
333        );
334        match out {
335            GateOutcome::PanelRejected {
336                passes,
337                required,
338                dissent,
339                ..
340            } => {
341                assert_eq!((passes, required), (1, 2));
342                assert_eq!(dissent.len(), 2, "dissent is recorded, not just counted");
343            }
344            other => panic!("expected rejection, got {other:?}"),
345        }
346    }
347
348    #[test]
349    fn an_unreachable_vendor_cannot_halve_the_panel() {
350        // The threshold is fixed before the vote. Two of three answering
351        // unanimously is NOT two of two.
352        let out = decide(
353            true,
354            "green",
355            3,
356            &[v("a", true)],
357            &[Unreachable {
358                model: "b".into(),
359                error: "timeout".into(),
360            }],
361        );
362        match out {
363            GateOutcome::PanelIncomplete {
364                answered, required, ..
365            } => assert_eq!((answered, required), (1, 2)),
366            other => panic!("expected incomplete, got {other:?}"),
367        }
368    }
369
370    #[test]
371    fn exactly_enough_answers_can_still_approve() {
372        let out = decide(true, "green", 3, &[v("a", true), v("b", true)], &[]);
373        assert!(matches!(
374            out,
375            GateOutcome::Approved {
376                passes: 2,
377                of: 3,
378                ..
379            }
380        ));
381    }
382
383    #[test]
384    fn exactly_enough_answers_can_also_reject() {
385        let out = decide(true, "green", 3, &[v("a", true), v("b", false)], &[]);
386        assert!(matches!(out, GateOutcome::PanelRejected { .. }));
387    }
388
389    #[test]
390    fn a_single_reviewer_cannot_approve_alone_on_a_panel_of_three() {
391        assert_eq!(required_approvals(3), 2);
392        assert_eq!(required_approvals(5), 3);
393        // A panel of one is a strict majority of itself — degenerate, and the
394        // caller's problem to avoid, but the arithmetic must not surprise.
395        assert_eq!(required_approvals(1), 1);
396    }
397
398    #[test]
399    fn incomplete_and_rejected_are_different_outcomes() {
400        // The remedy differs: retry the panel versus fix the change. Collapsing
401        // them would send an operator to debug code when a vendor was down.
402        let incomplete = decide(true, "green", 3, &[], &[]);
403        let rejected = decide(true, "green", 3, &[v("a", false), v("b", false)], &[]);
404        assert!(matches!(incomplete, GateOutcome::PanelIncomplete { .. }));
405        assert!(matches!(rejected, GateOutcome::PanelRejected { .. }));
406    }
407
408    #[test]
409    fn the_summary_names_the_dissenters_and_the_unreachable() {
410        let rejected = decide(true, "green", 3, &[v("a", true), v("b", false)], &[]);
411        assert!(rejected.summary().contains('b'), "{}", rejected.summary());
412
413        let incomplete = decide(
414            true,
415            "green",
416            3,
417            &[],
418            &[Unreachable {
419                model: "gpt".into(),
420                error: "429".into(),
421            }],
422        );
423        assert!(incomplete.summary().contains("gpt"));
424        assert!(incomplete.summary().contains("429"));
425    }
426
427    #[test]
428    fn the_criteria_do_not_carry_the_issue_body() {
429        // A reviewer prompt is exactly where "ignore the above" would land, so
430        // untrusted tracker text must not reach it.
431        let c = review_criteria("fix the off-by-one in the parser", "all checks green");
432        assert!(c.contains("off-by-one"));
433        assert!(
434            c.contains("Default to FAIL"),
435            "uncertainty must not read as approval"
436        );
437    }
438
439    #[test]
440    fn every_reviewer_is_asked_the_same_question() {
441        let a = review_criteria("intent", "green");
442        let b = review_criteria("intent", "green");
443        assert_eq!(
444            a, b,
445            "a panel prompted differently measures prompt variance"
446        );
447    }
448}