1use 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
21pub(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
50fn 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
63pub 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 pub fn with_correction_packets(mut self, enabled: bool) -> Self {
96 self.correction_packets = enabled;
97 self
98 }
99
100 pub fn with_require_format(mut self, require_format: bool) -> Self {
103 self.require_format = require_format;
104 self
105 }
106
107 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 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 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 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 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 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 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 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 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 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 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 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 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
591fn 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#[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 #[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}