Skip to main content

perspt_agent/
measure.rs

1//! Plugin-backed candidate measurement: the full verifier suite at gate
2//! boundaries and the cheap syntax-only pass at mutation boundaries.
3//! Extracted from `candidate.rs` so the reversible overlay and the
4//! measurement plane each stay within the file-size rules.
5
6use std::collections::BTreeSet;
7use std::path::Path;
8use std::sync::Arc;
9
10use anyhow::{Context, Result};
11use perspt_coding::{CodingAdapterRegistry, CodingDomain, LanguageId};
12use perspt_sdk::{
13    score_candidate, AgentDomainPackage, CorrectionDirection, DomainScope, IndependenceRoute,
14    ResidualClass, ResidualEvent, ResidualSeverity, SensorRef,
15};
16
17use crate::candidate::{CandidateWorkspace, TestEvidenceWorkspace};
18use crate::toolloop::{CandidateMeasurer, Measured};
19use crate::verifier::{run_governed_verifier, VerifierExecution, VerifierJob};
20
21/// Every verifier process of one candidate shares this target-dir suffix:
22/// gate stages, interactive `run_*` tools, and the incremental syntax pass
23/// warm one another instead of each paying a cold build.
24pub(crate) const SHARED_TARGET_SUFFIX: &str = "shared";
25
26#[derive(Default)]
27struct AdditionalTestEvidence<'a> {
28    regression_root: Option<&'a Path>,
29    external_oracle: Option<(&'a Path, &'a str)>,
30}
31
32#[derive(Default)]
33struct PreparedTestEvidence {
34    regression: Option<TestEvidenceWorkspace>,
35    external: Option<(TestEvidenceWorkspace, String)>,
36}
37
38impl PreparedTestEvidence {
39    fn borrowed(&self) -> AdditionalTestEvidence<'_> {
40        AdditionalTestEvidence {
41            regression_root: self.regression.as_ref().map(|view| view.root.as_path()),
42            external_oracle: self
43                .external
44                .as_ref()
45                .map(|(view, command)| (view.root.as_path(), command.as_str())),
46        }
47    }
48}
49
50/// Execution order within one shared target dir: the cheap check warms the
51/// metadata, the build warms the full artifacts, tests/lints reuse them.
52fn stage_rank(stage: perspt_core::plugin::VerifierStage) -> u8 {
53    use perspt_core::plugin::VerifierStage;
54    match stage {
55        VerifierStage::SyntaxCheck => 0,
56        VerifierStage::Build => 1,
57        VerifierStage::Test => 2,
58        VerifierStage::Lint => 3,
59        VerifierStage::Format => 4,
60    }
61}
62
63/// Complete plugin-backed candidate measurement for one coding node.
64pub struct CodingCandidateMeasurer<'a> {
65    candidate: &'a CandidateWorkspace,
66    node_id: String,
67    generation: u32,
68    domain: Arc<dyn AgentDomainPackage>,
69    adapters: CodingAdapterRegistry,
70    max_parallel: usize,
71    require_format: bool,
72    test_policy: perspt_core::TestPolicy,
73    external_oracle: Option<perspt_core::ExternalOracleConfig>,
74    correction_packets: bool,
75}
76
77impl<'a> CodingCandidateMeasurer<'a> {
78    pub fn new(candidate: &'a CandidateWorkspace, node_id: &str, generation: u32) -> Self {
79        Self {
80            candidate,
81            node_id: node_id.into(),
82            generation,
83            domain: Arc::new(CodingDomain::new()),
84            adapters: CodingAdapterRegistry::with_builtins(),
85            max_parallel: 4,
86            require_format: false,
87            test_policy: perspt_core::TestPolicy::Evolving,
88            external_oracle: None,
89            correction_packets: true,
90        }
91    }
92
93    /// Evaluation ablation: disable typed correction packets (legacy
94    /// first-direction text only).
95    pub fn with_correction_packets(mut self, enabled: bool) -> Self {
96        self.correction_packets = enabled;
97        self
98    }
99
100    /// Enable the declared `format` acceptance stage
101    /// (`[verification] require_format`).
102    pub fn with_require_format(mut self, require_format: bool) -> Self {
103        self.require_format = require_format;
104        self
105    }
106
107    /// Select how test evidence participates in the hard gate. The default is
108    /// `Evolving`: project tests and their configuration may change together
109    /// with the implementation. Stronger policies are explicit.
110    pub fn with_test_policy(
111        mut self,
112        policy: perspt_core::TestPolicy,
113        external_oracle: Option<perspt_core::ExternalOracleConfig>,
114    ) -> Self {
115        self.test_policy = policy;
116        self.external_oracle = external_oracle;
117        self
118    }
119
120    pub fn with_max_parallel(mut self, max_parallel: usize) -> Self {
121        self.max_parallel = max_parallel.max(1);
122        self
123    }
124
125    /// Measure under the selected domain's energy model instead of the
126    /// default coding domain.
127    pub fn with_domain(mut self, domain: Arc<dyn AgentDomainPackage>) -> Self {
128        self.domain = domain;
129        self
130    }
131
132    fn adapter_for(plugin_name: &str) -> LanguageId {
133        match plugin_name.to_ascii_lowercase().as_str() {
134            "js" | "javascript" | "typescript" => LanguageId::new("typescript"),
135            other => LanguageId::new(other),
136        }
137    }
138
139    /// Enumerate every declared verifier capability as a runnable job; a
140    /// capability with no effective command is a blocking sensor residual.
141    /// A declared no-op stage *satisfies* its required-stage obligation —
142    /// the plugin states there is nothing to run, which is a covered
143    /// sensor, not a missing one (Python's build stage would otherwise
144    /// make hard pass unreachable and loop the model forever).
145    fn collect_jobs(
146        &self,
147        test_evidence: AdditionalTestEvidence<'_>,
148        residuals: &mut Vec<ResidualEvent>,
149        advisory: &mut Vec<ResidualEvent>,
150        satisfied_stages: &mut BTreeSet<&'static str>,
151        all_passed: &mut bool,
152        required: &BTreeSet<String>,
153    ) -> Result<Vec<VerifierJob>> {
154        let registry = perspt_core::PluginRegistry::new();
155        let mut jobs = Vec::new();
156        let plugins = registry.detect_all(self.candidate.overlay_root());
157        *all_passed = !plugins.is_empty();
158        for plugin in plugins {
159            let adapter_id = Self::adapter_for(plugin.name());
160            for capability in plugin.verifier_profile().capabilities {
161                // Formatting gates acceptance only when the run declares it
162                // (`[verification] require_format`); otherwise the formatter
163                // stays a governed tool, never a silent gate change.
164                if capability.stage == perspt_core::plugin::VerifierStage::Format
165                    && !self.require_format
166                {
167                    continue;
168                }
169                let Some(command) = capability.effective_command() else {
170                    // A stage the plugin declares as a no-op — no primary or
171                    // fallback command form at all, marked available — has
172                    // nothing to run and is not a missing sensor (e.g.
173                    // Python's build stage). Only a stage that *has* a
174                    // command form with no runnable binary blocks hard pass.
175                    if capability.available
176                        && capability.command.is_none()
177                        && capability.fallback_command.is_none()
178                    {
179                        satisfied_stages.insert(capability.stage.policy_name());
180                        continue;
181                    }
182                    fold_infra_failure(
183                        &self.node_id,
184                        self.generation,
185                        capability.stage,
186                        &format!("{}:{}", plugin.name(), capability.stage),
187                        residuals,
188                        advisory,
189                        all_passed,
190                        required,
191                    )?;
192                    continue;
193                };
194                jobs.push(VerifierJob {
195                    plugin: plugin.name().to_string(),
196                    adapter_id: adapter_id.clone(),
197                    stage: capability.stage,
198                    command: command.to_string(),
199                    root: self.candidate.overlay_root().to_path_buf(),
200                });
201            }
202            if let Some(root) = test_evidence.regression_root {
203                jobs.push(VerifierJob {
204                    plugin: format!("{}:backward-compatible-tests", plugin.name()),
205                    adapter_id,
206                    stage: perspt_core::plugin::VerifierStage::Test,
207                    command: plugin.test_command(),
208                    root: root.to_path_buf(),
209                });
210            }
211        }
212        if let Some((root, command)) = test_evidence.external_oracle {
213            jobs.push(VerifierJob {
214                plugin: "external-oracle".into(),
215                adapter_id: LanguageId::new("external-oracle"),
216                stage: perspt_core::plugin::VerifierStage::Test,
217                command: command.to_string(),
218                root: root.to_path_buf(),
219            });
220        }
221        Ok(jobs)
222    }
223
224    /// Fold one verifier run. A residual from a stage the domain's
225    /// `HardGatePolicy` does not require (e.g. lint) lands in `advisory` —
226    /// still scored, still steering corrections, never blocking the gate —
227    /// so a pre-existing repository warning cannot make hard pass
228    /// unreachable. Required-stage residuals stay blocking.
229    fn fold_execution(
230        &self,
231        job: &VerifierJob,
232        execution: VerifierExecution,
233        residuals: &mut Vec<ResidualEvent>,
234        advisory: &mut Vec<ResidualEvent>,
235        all_passed: &mut bool,
236        required: &BTreeSet<String>,
237    ) -> Result<()> {
238        let blocking = required.contains(job.stage.policy_name());
239        let sink: &mut Vec<ResidualEvent> = if blocking { residuals } else { advisory };
240        let mut output = execution.output.clone();
241        // A stage that declared a JUnit report file (pytest `--junitxml`)
242        // produced structured evidence on disk: fold it into the raw sensor
243        // text so the structured parser sees it, then drop the file.
244        if let Some(report) = junit_report_path(&job.command) {
245            let path = job.root.join(report);
246            if let Ok(xml) = std::fs::read_to_string(&path) {
247                output.push('\n');
248                output.push_str(&xml);
249                let _ = std::fs::remove_file(&path);
250            }
251        }
252        let execution = VerifierExecution {
253            output,
254            ..execution
255        };
256        if let Some(adapter) = self.adapters.get(&job.adapter_id) {
257            sink.extend(adapter.parse_diagnostics(
258                &self.node_id,
259                self.generation,
260                &execution.output,
261            ));
262        }
263        if !execution.success {
264            if blocking {
265                *all_passed = false;
266            }
267            let class = match job.stage {
268                perspt_core::plugin::VerifierStage::Test => ResidualClass::TestFailure,
269                perspt_core::plugin::VerifierStage::Lint => ResidualClass::Lint,
270                perspt_core::plugin::VerifierStage::Format => ResidualClass::Format,
271                _ => ResidualClass::Build,
272            };
273            if !sink.iter().any(|r| r.class == class) {
274                sink.push(tool_residual(
275                    &self.node_id,
276                    self.generation,
277                    class,
278                    &format!("{} failed: {}", job.stage, concise(&execution.output)),
279                )?);
280            }
281        }
282        Ok(())
283    }
284
285    /// One task per `(root, plugin)` group, its stages run **sequentially**
286    /// in stage order against one shared `CARGO_TARGET_DIR` (suffix
287    /// `shared`, the same warm pool the interactive tools and incremental
288    /// syntax pass use), so a gate pays one cold build plus incrementals
289    /// instead of a cold tree per stage. Groups still run in parallel under
290    /// the `max_parallel` semaphore; any additional regression or protected
291    /// acceptance root is its own group and stays isolated from candidate
292    /// build artifacts.
293    fn spawn_verifier_groups(
294        &self,
295        jobs: Vec<VerifierJob>,
296    ) -> tokio::task::JoinSet<Vec<(VerifierJob, Result<VerifierExecution>)>> {
297        let mut groups: std::collections::BTreeMap<(std::path::PathBuf, String), Vec<VerifierJob>> =
298            std::collections::BTreeMap::new();
299        for job in jobs {
300            groups
301                .entry((job.root.clone(), job.plugin.clone()))
302                .or_default()
303                .push(job);
304        }
305        let semaphore = Arc::new(tokio::sync::Semaphore::new(self.max_parallel));
306        let mut workers = tokio::task::JoinSet::new();
307        for (_, mut group) in groups {
308            group.sort_by_key(|job| stage_rank(job.stage));
309            let semaphore = semaphore.clone();
310            let allow_unisolated = self.candidate.unisolated_verifiers_allowed();
311            let extra_env = self.candidate.verifier_env();
312            let timeouts = self.candidate.verifier_timeouts();
313            workers.spawn(async move {
314                let _permit = semaphore.acquire_owned().await.expect("semaphore closed");
315                let mut results = Vec::with_capacity(group.len());
316                for job in group {
317                    let execution = run_governed_verifier(
318                        job.root.clone(),
319                        job.command.clone(),
320                        allow_unisolated,
321                        SHARED_TARGET_SUFFIX.into(),
322                        extra_env.clone(),
323                        timeouts.for_stage(Some(job.stage)),
324                    )
325                    .await;
326                    results.push((job, execution));
327                }
328                results
329            });
330        }
331        workers
332    }
333
334    /// The stages whose residuals block hard pass: the domain's
335    /// `HardGatePolicy`, plus `format` when the run declares it
336    /// (`[verification] require_format`).
337    fn required_stages(&self) -> BTreeSet<String> {
338        let scope = DomainScope {
339            label: self.node_id.clone(),
340            paths: Vec::new(),
341        };
342        let mut required: BTreeSet<String> = self
343            .domain
344            .hard_gate_policy(&scope)
345            .required_stages
346            .into_iter()
347            .collect();
348        if self.require_format {
349            required.insert("format".into());
350        }
351        required
352    }
353
354    fn prepare_test_evidence(&self) -> Result<PreparedTestEvidence> {
355        anyhow::ensure!(
356            self.test_policy == perspt_core::TestPolicy::ExternalOracle
357                || self.external_oracle.is_none(),
358            "protected external test configuration requires the external-oracle test policy"
359        );
360        let regression = match self.test_policy {
361            perspt_core::TestPolicy::BackwardCompatible => {
362                self.candidate.backward_compatibility_tests()?
363            }
364            _ => None,
365        };
366        let external = match self.test_policy {
367            perspt_core::TestPolicy::ExternalOracle => {
368                let configured = self
369                    .external_oracle
370                    .as_ref()
371                    .context("external-oracle test policy has no protected oracle configuration")?;
372                Some((
373                    self.candidate.external_oracle_tests(&configured.path)?,
374                    configured.command.clone(),
375                ))
376            }
377            _ => None,
378        };
379        Ok(PreparedTestEvidence {
380            regression,
381            external,
382        })
383    }
384
385    /// Gate X / PSP-10 Phase 1: `HardGatePolicy::required_stages` is
386    /// authoritative. A declared stage that no plugin ran is a missing
387    /// sensor: it records `SensorUnavailable` and forces `hard_pass = false`.
388    /// The check only adds a necessary condition; it can never loosen the
389    /// plugin-derived verdict.
390    fn enforce_required_stages(
391        &self,
392        ran_stages: &BTreeSet<&'static str>,
393        residuals: &mut Vec<ResidualEvent>,
394        all_passed: &mut bool,
395    ) -> Result<()> {
396        let scope = DomainScope {
397            label: self.node_id.clone(),
398            paths: Vec::new(),
399        };
400        for stage in self.domain.hard_gate_policy(&scope).required_stages {
401            if !ran_stages.contains(stage.as_str()) {
402                residuals.push(sensor_unavailable(
403                    &self.node_id,
404                    self.generation,
405                    &format!("required-stage:{stage}"),
406                )?);
407                *all_passed = false;
408            }
409        }
410        Ok(())
411    }
412
413    #[allow(clippy::type_complexity)]
414    fn score(
415        &self,
416        residuals: &[ResidualEvent],
417    ) -> Result<(
418        f64,
419        Option<CorrectionDirection>,
420        Option<perspt_sdk::CorrectionPacket>,
421    )> {
422        let scope = DomainScope {
423            label: self.node_id.clone(),
424            paths: Vec::new(),
425        };
426        let score = score_candidate(&self.domain.energy_model(&scope), residuals)
427            .map_err(|e| anyhow::anyhow!(e.to_string()))?;
428        // PSP-10 system 26: the typed packet folds every direction with
429        // full paths/symbols/rationale; its rendering through the
430        // `branch_correct` section program becomes the steering message.
431        // Domains without packets keep the legacy first-direction path.
432        let packet = self
433            .correction_packets
434            .then(|| self.domain.correction_packet(residuals))
435            .flatten();
436        let correction = match &packet {
437            Some(packet) if !packet.is_empty() => {
438                let rendered = perspt_coding::prompts::render_correction(packet)
439                    .map_err(|e| anyhow::anyhow!("rendering correction packet: {e}"))?;
440                Some(
441                    CorrectionDirection::new(packet.dominant_cluster.class, rendered)
442                        .with_paths(packet.affected.paths.clone()),
443                )
444            }
445            _ => self
446                .domain
447                .correction_directions(residuals)
448                .into_iter()
449                .next(),
450        };
451        Ok((score.total, correction, packet))
452    }
453}
454
455#[async_trait::async_trait]
456impl CandidateMeasurer for CodingCandidateMeasurer<'_> {
457    async fn measure(&self) -> Result<Measured> {
458        let mut residuals = Vec::new();
459        let mut advisory = Vec::new();
460        let required = self.required_stages();
461        let mut all_passed = false;
462        let mut ran_stages: BTreeSet<&'static str> = BTreeSet::new();
463        let test_evidence = self.prepare_test_evidence()?;
464        let jobs = self.collect_jobs(
465            test_evidence.borrowed(),
466            &mut residuals,
467            &mut advisory,
468            &mut ran_stages,
469            &mut all_passed,
470            &required,
471        )?;
472
473        let ran = jobs.len();
474        ran_stages.extend(jobs.iter().map(|job| job.stage.policy_name()));
475        self.enforce_required_stages(&ran_stages, &mut residuals, &mut all_passed)?;
476        let mut workers = self.spawn_verifier_groups(jobs);
477
478        while let Some(result) = workers.join_next().await {
479            for (job, execution) in result.context("verifier worker panicked")? {
480                match execution {
481                    Ok(execution) => self.fold_execution(
482                        &job,
483                        execution,
484                        &mut residuals,
485                        &mut advisory,
486                        &mut all_passed,
487                        &required,
488                    )?,
489                    Err(error) => fold_infra_failure(
490                        &self.node_id,
491                        self.generation,
492                        job.stage,
493                        &format!("{}:{} ({error})", job.plugin, job.stage),
494                        &mut residuals,
495                        &mut advisory,
496                        &mut all_passed,
497                        &required,
498                    )?,
499                }
500            }
501        }
502
503        if ran == 0 {
504            all_passed = false;
505        }
506        // Only required-stage residuals gate; advisory ones (lint outside
507        // the hard-gate policy) still enter the energy and corrections.
508        let hard_pass = self.candidate.has_mutated() && all_passed && residuals.is_empty();
509        residuals.extend(advisory);
510        let (energy, correction, packet) = self.score(&residuals)?;
511        Ok(Measured {
512            hard_pass,
513            energy,
514            residuals,
515            correction,
516            packet,
517        })
518    }
519
520    /// Per-mutation boundary: a syntax-only pass. The complete suite — every
521    /// build and test, each with its own timeout — belongs to the gate
522    /// boundary (`measure`); running it after every single admitted mutation
523    /// multiplies a turn's cost by its edit count for no additional gate
524    /// evidence. `hard_pass` is never claimed from this cheap pass.
525    async fn measure_incremental(&self) -> Result<Measured> {
526        let registry = perspt_core::PluginRegistry::new();
527        let plugins = registry.detect_all(self.candidate.overlay_root());
528        let mut residuals = Vec::new();
529        for plugin in plugins {
530            let Some(command) = plugin.syntax_check_command() else {
531                continue;
532            };
533            let adapter_id = Self::adapter_for(plugin.name());
534            let execution = run_governed_verifier(
535                self.candidate.overlay_root().to_path_buf(),
536                command,
537                self.candidate.unisolated_verifiers_allowed(),
538                SHARED_TARGET_SUFFIX.into(),
539                self.candidate.verifier_env(),
540                self.candidate
541                    .verifier_timeouts()
542                    .for_stage(Some(perspt_core::plugin::VerifierStage::SyntaxCheck)),
543            )
544            .await?;
545            if let Some(adapter) = self.adapters.get(&adapter_id) {
546                residuals.extend(adapter.parse_diagnostics(
547                    &self.node_id,
548                    self.generation,
549                    &execution.output,
550                ));
551            }
552            if !execution.success && !residuals.iter().any(|r| r.class == ResidualClass::Build) {
553                residuals.push(tool_residual(
554                    &self.node_id,
555                    self.generation,
556                    ResidualClass::Build,
557                    &format!("syntax check failed: {}", concise(&execution.output)),
558                )?);
559            }
560        }
561        let (energy, correction, packet) = self.score(&residuals)?;
562        Ok(Measured {
563            hard_pass: false,
564            energy,
565            residuals,
566            correction,
567            packet,
568        })
569    }
570}
571
572fn tool_residual(
573    node: &str,
574    generation: u32,
575    class: ResidualClass,
576    summary: &str,
577) -> Result<ResidualEvent> {
578    let mut residual = ResidualEvent::new(
579        node,
580        generation,
581        class,
582        ResidualSeverity::Error,
583        1.0,
584        SensorRef::new("governed-verifier", IndependenceRoute::DeterministicTool),
585    )
586    .map_err(|e| anyhow::anyhow!(e.to_string()))?;
587    residual.evidence.summary = summary.into();
588    Ok(residual)
589}
590
591/// The report path a verifier command declared via `--junitxml <path>` or
592/// `--junitxml=<path>`.
593fn junit_report_path(command: &str) -> Option<String> {
594    let mut words = command.split_whitespace().peekable();
595    while let Some(word) = words.next() {
596        if word == "--junitxml" {
597            return words.peek().map(|path| (*path).to_string());
598        }
599        if let Some(path) = word.strip_prefix("--junitxml=") {
600            return Some(path.to_string());
601        }
602    }
603    None
604}
605
606/// Route one verifier *infrastructure* failure (unavailable capability,
607/// launch error, timeout) by stage: it blocks hard pass only when the
608/// stage is in the domain's required set — a lint timeout or a missing
609/// lint binary is as advisory as a lint finding (erratum 11: non-required
610/// stages cannot block acceptance), while a build or test failure to run
611/// still blocks.
612#[allow(clippy::too_many_arguments)]
613fn fold_infra_failure(
614    node_id: &str,
615    generation: u32,
616    stage: perspt_core::plugin::VerifierStage,
617    detail: &str,
618    residuals: &mut Vec<ResidualEvent>,
619    advisory: &mut Vec<ResidualEvent>,
620    all_passed: &mut bool,
621    required: &BTreeSet<String>,
622) -> Result<()> {
623    let blocking = required.contains(stage.policy_name());
624    let sink = if blocking { residuals } else { advisory };
625    sink.push(sensor_unavailable(node_id, generation, detail)?);
626    if blocking {
627        *all_passed = false;
628    }
629    Ok(())
630}
631
632fn sensor_unavailable(node: &str, generation: u32, sensor: &str) -> Result<ResidualEvent> {
633    tool_residual(
634        node,
635        generation,
636        ResidualClass::SensorUnavailable,
637        &format!("required sensor unavailable: {sensor}"),
638    )
639}
640
641fn concise(output: &str) -> String {
642    output
643        .lines()
644        .filter(|line| !line.trim().is_empty())
645        .take(8)
646        .collect::<Vec<_>>()
647        .join("\n")
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn required() -> BTreeSet<String> {
655        ["syntax", "build", "test"]
656            .into_iter()
657            .map(String::from)
658            .collect()
659    }
660
661    /// Erratum 11 covers infrastructure too: a lint-stage timeout or
662    /// missing lint binary lands in the advisory sink and leaves
663    /// `all_passed` alone, while the same failure on a required stage
664    /// still blocks.
665    #[test]
666    fn infra_failures_block_only_on_required_stages() {
667        use perspt_core::plugin::VerifierStage;
668        let mut residuals = Vec::new();
669        let mut advisory = Vec::new();
670        let mut all_passed = true;
671        fold_infra_failure(
672            "n1",
673            0,
674            VerifierStage::Lint,
675            "rust:lint (deadline elapsed)",
676            &mut residuals,
677            &mut advisory,
678            &mut all_passed,
679            &required(),
680        )
681        .unwrap();
682        assert!(all_passed, "a lint infra failure must not clear all_passed");
683        assert!(residuals.is_empty(), "nothing enters the blocking sink");
684        assert_eq!(advisory.len(), 1, "the failure is still reported");
685        assert_eq!(advisory[0].class, ResidualClass::SensorUnavailable);
686
687        fold_infra_failure(
688            "n1",
689            0,
690            VerifierStage::Test,
691            "rust:test (deadline elapsed)",
692            &mut residuals,
693            &mut advisory,
694            &mut all_passed,
695            &required(),
696        )
697        .unwrap();
698        assert!(!all_passed, "a required-stage infra failure still blocks");
699        assert_eq!(residuals.len(), 1);
700    }
701}