Skip to main content

forge_pilot/
act.rs

1//! Act phase of the OODA loop.
2//!
3//! Executes the selected verification plan (oracle evaluation or
4//! paired-patch experiment) and produces an evidence bundle for the
5//! canonical Forge export path.
6
7use crate::bundle_builder::{
8    build_bundle_from_oracle, build_bundle_from_patch, OracleBundleInput, PatchBundleInput,
9};
10use crate::config::LoopConfig;
11use crate::error::PilotError;
12use crate::observe::Observation;
13use forge_engine::lab::evidence::{
14    RefutationArtifact, RefutationArtifactOutcome, RefutationArtifactType,
15};
16use forge_engine::{
17    select_backend, CargoAdapter, ExperimentConfig, ExperimentEvidenceBundle,
18    PairedExperimentRunner, ProjectAdapter, StructuredPatch,
19};
20use kernel_oracles::{
21    evaluate_causal_refuter, evaluate_conservative, evaluate_delta_parity, evaluate_exact_bounded,
22    evaluate_minimal_perturbation, evaluate_temporal_replay, DeltaParityAssessment,
23    OracleAssessment, OracleRefutationOutcome, OracleRefutationResult, TemporalReplayAssessment,
24};
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27use stack_ids::{ClaimVersionId, OracleSliceId};
28use std::path::Path;
29use verification_policy::ExecutionPermit;
30
31/// An advisory-only plan that produces no promotable evidence.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct AdvisoryPlan {
34    pub description: String,
35}
36
37/// The concrete execution plan selected for a verification target.
38///
39/// Each variant determines which kernel oracle or paired-patch path
40/// will be used during the act phase.
41#[derive(Debug, Clone)]
42pub enum PlanKind {
43    OracleExactBounded {
44        oracle_slice_id: OracleSliceId,
45    },
46    OracleConservative,
47    OracleDeltaParity {
48        changed_node_ids: Vec<String>,
49        max_iterations: u32,
50    },
51    OracleTemporalReplay {
52        cutoff_recorded_at: String,
53    },
54    OracleCausalRefuter {
55        target_node_id: String,
56        max_removed_nodes: usize,
57    },
58    OracleMinimalPerturbation {
59        target_node_id: String,
60        max_removed_nodes: usize,
61    },
62    PairedPatch {
63        fixture_path: String,
64        patch: StructuredPatch,
65        experiment_config: ExperimentConfig,
66        description: String,
67    },
68    AdvisoryOnlyVerificationPlan(AdvisoryPlan),
69}
70
71impl PlanKind {
72    /// Returns the superseded claim version targeted by this plan, if any.
73    pub fn supersedes_claim_version_id(&self) -> Option<ClaimVersionId> {
74        None
75    }
76
77    /// Returns the verification check names implied by this plan.
78    pub fn check_names(&self) -> Vec<String> {
79        match self {
80            Self::PairedPatch { .. } => vec!["fmt".into(), "clippy".into(), "test".into()],
81            _ => vec!["kernel_oracle".into()],
82        }
83    }
84}
85
86/// Broad classification of the action executed during the act phase.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
88#[serde(rename_all = "snake_case")]
89pub enum ActionFamily {
90    Oracle,
91    PairedPatch,
92    AdvisoryOnly,
93}
94
95/// Results from running kernel oracle evaluation against compiled constraints.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct OracleExecution {
98    pub assessment: Option<OracleAssessment>,
99    pub delta_parity: Option<DeltaParityAssessment>,
100    pub temporal_replay: Option<TemporalReplayAssessment>,
101    pub refutation: Option<OracleRefutationResult>,
102}
103
104impl OracleExecution {
105    /// Returns a short textual summary of the oracle execution outcome.
106    pub fn outcome_summary(&self) -> Option<String> {
107        if let Some(assessment) = &self.assessment {
108            return Some(format!(
109                "oracle mode {:?} supported={} satisfied_constraints={}",
110                assessment.mode, assessment.supported, assessment.satisfied_constraint_count
111            ));
112        }
113        if let Some(delta) = &self.delta_parity {
114            return Some(format!(
115                "delta parity matched={} recomputed_nodes={}",
116                delta.parity_match,
117                delta.recomputed_node_ids.len()
118            ));
119        }
120        if let Some(replay) = &self.temporal_replay {
121            return Some(format!(
122                "temporal replay matched_expected_hash={}",
123                replay.matched_expected_hash
124            ));
125        }
126        self.refutation
127            .as_ref()
128            .map(|refutation| format!("{:?}", refutation.outcome))
129    }
130
131    /// Returns a JSON summary of the oracle execution outcome.
132    pub fn summary_json(&self) -> serde_json::Value {
133        serde_json::json!({
134            "assessment": self.assessment,
135            "delta_parity": self.delta_parity,
136            "temporal_replay": self.temporal_replay,
137            "refutation": self.refutation,
138        })
139    }
140
141    /// Returns the refutation artifacts emitted by this oracle execution.
142    pub fn refutation_artifacts(&self) -> Vec<RefutationArtifact> {
143        self.refutation
144            .iter()
145            .map(|refutation| RefutationArtifact {
146                artifact_id: format!("refutation:{}", refutation.target_node_id),
147                artifact_type: RefutationArtifactType::SubsampleStability,
148                trial_id: None,
149                attempt_id: None,
150                outcome: match &refutation.outcome {
151                    OracleRefutationOutcome::FlipWitness { .. } => {
152                        RefutationArtifactOutcome::Passed
153                    }
154                    OracleRefutationOutcome::NoFlipFound { searched_budget } => {
155                        RefutationArtifactOutcome::Failed {
156                            reason: format!("no flip found in budget {searched_budget}"),
157                        }
158                    }
159                    OracleRefutationOutcome::NotApplicable { reason } => {
160                        RefutationArtifactOutcome::Inconclusive {
161                            reason: reason.clone(),
162                        }
163                    }
164                },
165                estimate_delta: None,
166                details: Some(format!("{:?}", refutation.outcome)),
167            })
168            .collect()
169    }
170}
171
172/// Results from running a paired-patch experiment through the Forge engine.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct PatchExecution {
175    pub run_id: String,
176    pub improvements: u32,
177    pub regressions: u32,
178}
179
180/// Full outcome of executing a plan, including the evidence bundle and execution details.
181#[derive(Debug, Clone)]
182pub struct ActionOutcome {
183    pub family: ActionFamily,
184    pub plan: PlanKind,
185    pub bundle: Option<ExperimentEvidenceBundle>,
186    pub oracle_execution: Option<OracleExecution>,
187    pub patch_execution: Option<PatchExecution>,
188    pub advisory_only: bool,
189    pub outcome_signature: String,
190}
191
192/// Executes a verification plan under an issued execution permit.
193///
194/// Dispatches to the appropriate oracle or paired-patch path based on
195/// the plan variant. Returns an error if the permit scope does not
196/// match `target_key`.
197pub async fn execute_plan(
198    observation: &Observation,
199    target_key: &str,
200    plan: &PlanKind,
201    permit: &ExecutionPermit,
202    config: &LoopConfig,
203) -> Result<ActionOutcome, PilotError> {
204    if permit.scope().target_key() != target_key {
205        return Err(PilotError::Other(format!(
206            "execution permit target {} does not match requested target {}",
207            permit.scope().target_key(),
208            target_key
209        )));
210    }
211    match plan {
212        PlanKind::OracleExactBounded { .. }
213        | PlanKind::OracleConservative
214        | PlanKind::OracleDeltaParity { .. }
215        | PlanKind::OracleTemporalReplay { .. }
216        | PlanKind::OracleCausalRefuter { .. }
217        | PlanKind::OracleMinimalPerturbation { .. } => {
218            execute_oracle_plan(observation, target_key, plan, config).await
219        }
220        PlanKind::PairedPatch {
221            fixture_path,
222            patch,
223            experiment_config,
224            ..
225        } => {
226            execute_patch_plan(
227                observation,
228                target_key,
229                plan,
230                fixture_path,
231                patch,
232                experiment_config,
233                config,
234            )
235            .await
236        }
237        PlanKind::AdvisoryOnlyVerificationPlan(_) => Err(PilotError::Other(
238            "advisory-only plans cannot consume execution permits".into(),
239        )),
240    }
241}
242
243async fn execute_oracle_plan(
244    observation: &Observation,
245    target_key: &str,
246    plan: &PlanKind,
247    _config: &LoopConfig,
248) -> Result<ActionOutcome, PilotError> {
249    let compiled = observation
250        .compiled
251        .as_ref()
252        .ok_or(PilotError::MissingCompiledContext)?;
253
254    let oracle_execution = match plan {
255        PlanKind::OracleExactBounded { .. } => OracleExecution {
256            assessment: evaluate_exact_bounded(compiled),
257            delta_parity: None,
258            temporal_replay: None,
259            refutation: None,
260        },
261        PlanKind::OracleConservative => OracleExecution {
262            assessment: Some(evaluate_conservative(compiled)),
263            delta_parity: None,
264            temporal_replay: None,
265            refutation: None,
266        },
267        PlanKind::OracleDeltaParity {
268            changed_node_ids,
269            max_iterations,
270        } => OracleExecution {
271            assessment: None,
272            delta_parity: Some(evaluate_delta_parity(
273                compiled,
274                changed_node_ids,
275                *max_iterations,
276            )),
277            temporal_replay: None,
278            refutation: None,
279        },
280        PlanKind::OracleTemporalReplay { cutoff_recorded_at } => OracleExecution {
281            assessment: None,
282            delta_parity: None,
283            temporal_replay: Some(
284                evaluate_temporal_replay(
285                    &observation.temporal_snapshots,
286                    cutoff_recorded_at,
287                    &constraint_compiler::CompilerPolicy {
288                        policy_version: "forge-pilot.v1".into(),
289                        include_hyperedges: true,
290                    },
291                    &compiled.graph_hash,
292                )
293                .ok_or(PilotError::MissingTemporalSnapshots)?,
294            ),
295            refutation: None,
296        },
297        PlanKind::OracleCausalRefuter {
298            target_node_id,
299            max_removed_nodes,
300        } => OracleExecution {
301            assessment: None,
302            delta_parity: None,
303            temporal_replay: None,
304            refutation: Some(evaluate_causal_refuter(
305                compiled,
306                target_node_id,
307                *max_removed_nodes,
308            )),
309        },
310        PlanKind::OracleMinimalPerturbation {
311            target_node_id,
312            max_removed_nodes,
313        } => OracleExecution {
314            assessment: None,
315            delta_parity: None,
316            temporal_replay: None,
317            refutation: Some(evaluate_minimal_perturbation(
318                compiled,
319                target_node_id,
320                *max_removed_nodes,
321            )),
322        },
323        // LIB-HIGH-001: replaced unreachable!() with recoverable error
324        _ => {
325            return Err(PilotError::Other(format!(
326                "unsupported plan kind for oracle execution: {:?}",
327                plan
328            )))
329        }
330    };
331
332    let bundle = build_bundle_from_oracle(OracleBundleInput {
333        plan,
334        target_key,
335        trace_id: observation
336            .batch
337            .as_ref()
338            .and_then(|batch| batch.trace_ctx.as_ref().map(|ctx| ctx.trace_id.clone())),
339        scope_namespace: &observation.scope_key.namespace,
340        oracle_execution: &oracle_execution,
341        known_threats: observation
342            .degradations
343            .iter()
344            .map(|degradation| degradation.kind.clone())
345            .collect(),
346    });
347    let outcome_signature = oracle_execution
348        .outcome_summary()
349        .unwrap_or_else(|| "oracle".into());
350
351    Ok(ActionOutcome {
352        family: ActionFamily::Oracle,
353        plan: plan.clone(),
354        bundle: Some(bundle),
355        oracle_execution: Some(oracle_execution),
356        patch_execution: None,
357        advisory_only: false,
358        outcome_signature,
359    })
360}
361
362async fn execute_patch_plan(
363    observation: &Observation,
364    target_key: &str,
365    plan: &PlanKind,
366    fixture_path: &str,
367    patch: &StructuredPatch,
368    experiment_config: &ExperimentConfig,
369    config: &LoopConfig,
370) -> Result<ActionOutcome, PilotError> {
371    let backend = select_backend(&config.forge_config)?;
372    let fixture = Path::new(fixture_path);
373    if !CargoAdapter::detect(fixture) {
374        return Err(PilotError::UnsupportedPatchFixture {
375            fixture_path: fixture_path.to_string(),
376        });
377    }
378    let adapter = CargoAdapter;
379    let runner = PairedExperimentRunner::new(backend.as_ref(), &adapter, &config.forge_config);
380    let experiment_result = runner.run(fixture, patch, experiment_config).await?;
381    let bundle = build_bundle_from_patch(PatchBundleInput {
382        plan,
383        target_key,
384        trace_id: observation
385            .batch
386            .as_ref()
387            .and_then(|batch| batch.trace_ctx.as_ref().map(|ctx| ctx.trace_id.clone())),
388        scope_namespace: &observation.scope_key.namespace,
389        experiment_result: &experiment_result,
390        known_threats: observation
391            .degradations
392            .iter()
393            .map(|degradation| degradation.kind.clone())
394            .collect(),
395    });
396
397    Ok(ActionOutcome {
398        family: ActionFamily::PairedPatch,
399        plan: plan.clone(),
400        bundle: Some(bundle),
401        oracle_execution: None,
402        patch_execution: Some(PatchExecution {
403            run_id: experiment_result.run_id.clone(),
404            improvements: experiment_result.diff.improvements,
405            regressions: experiment_result.diff.regressions,
406        }),
407        advisory_only: false,
408        outcome_signature: format!(
409            "patch:improvements={} regressions={}",
410            experiment_result.diff.improvements, experiment_result.diff.regressions
411        ),
412    })
413}