Skip to main content

forge_engine/
experiment.rs

1//! Experiment execution: baseline/patched paired trials with typed diffs.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::baseline::{BaselineDescriptor, ComparabilityPolicy, WorkspacePolicy};
8use crate::error::{ForgeError, ForgeResult};
9use crate::exec::backend::{CheckResult, ExecutionBackendKind};
10
11// ── Experiment mode ──
12
13/// How an experiment is structured.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ExperimentMode {
17    /// Single baseline + single patched execution.
18    Paired,
19    /// Multiple paired trials for statistical robustness.
20    RepeatedPaired,
21    /// Follow-up experiment targeting specific verification steps.
22    VerificationFollowup,
23}
24
25// ── Typed effect kinds ──
26
27/// Classification of an observed effect from experiment comparison.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum EffectKind {
31    CompileFailure,
32    TestFailure,
33    LintFailure,
34    Timeout,
35    PanicCrash,
36    OutputMismatch,
37    WarningRegression,
38    WarningImprovement,
39    PerformanceRegression,
40    PerformanceImprovement,
41    FlakySignal,
42    InformationalOnly,
43}
44
45/// An effect located in the experiment diff.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct TypedLocatedEffect {
48    pub kind: EffectKind,
49    pub file: Option<PathBuf>,
50    pub line: Option<u32>,
51    pub message: String,
52    /// Whether this effect appeared in the baseline.
53    pub in_baseline: bool,
54    /// Whether this effect appeared in the patched run.
55    pub in_patched: bool,
56}
57
58// ── Trial record ──
59
60/// Record of a single trial execution (baseline or patched).
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TrialRecord {
63    /// Which side of the experiment.
64    pub side: TrialSide,
65    /// Summarized check pass/fail flags.
66    pub fmt_pass: bool,
67    pub clippy_pass: bool,
68    pub test_pass: bool,
69    /// Backend used.
70    pub backend_kind: ExecutionBackendKind,
71    /// Duration in milliseconds.
72    pub duration_ms: u64,
73    /// Seed used for reproducibility.
74    pub seed: u64,
75    /// Whether caches were warm.
76    pub cache_mode: CacheMode,
77    /// Whether network was available.
78    pub network_available: bool,
79    /// Whether timing data is admissible.
80    pub timing_admissible: bool,
81}
82
83/// Which side of a paired experiment a trial belongs to.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum TrialSide {
87    Baseline,
88    Patched,
89}
90
91/// Whether build caches were warm or cold during a trial execution.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum CacheMode {
95    Cold,
96    Warm,
97    Unknown,
98}
99
100// ── Experiment diff ──
101
102/// Typed diff between baseline and patched experiment results.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ExperimentDiff {
105    /// Effects that differ between baseline and patched.
106    pub effects: Vec<TypedLocatedEffect>,
107    /// Summary counts.
108    pub regressions: u32,
109    pub improvements: u32,
110    pub stable_failures: u32,
111    pub stable_passes: u32,
112    /// Whether the diff is statistically meaningful.
113    pub statistically_meaningful: bool,
114    /// Warning if sample size is insufficient.
115    pub sample_warning: Option<String>,
116}
117
118impl ExperimentDiff {
119    /// Derive a typed diff from baseline and patched check results.
120    pub fn from_paired(baseline: &CheckResult, patched: &CheckResult) -> Self {
121        let mut effects = Vec::new();
122        let mut regressions = 0u32;
123        let mut improvements = 0u32;
124        let mut stable_failures = 0u32;
125        let mut stable_passes = 0u32;
126
127        // Compare fmt
128        match (baseline.fmt_pass, patched.fmt_pass) {
129            (true, false) => {
130                regressions += 1;
131                for eff in &patched.fmt_output.effects {
132                    effects.push(TypedLocatedEffect {
133                        kind: EffectKind::LintFailure,
134                        file: eff.file.clone(),
135                        line: eff.line,
136                        message: eff.message.clone(),
137                        in_baseline: false,
138                        in_patched: true,
139                    });
140                }
141            }
142            (false, true) => {
143                improvements += 1;
144            }
145            (false, false) => {
146                stable_failures += 1;
147            }
148            (true, true) => {
149                stable_passes += 1;
150            }
151        }
152
153        // Compare clippy
154        match (baseline.clippy_pass, patched.clippy_pass) {
155            (true, false) => {
156                regressions += 1;
157                for eff in &patched.clippy_output.effects {
158                    effects.push(TypedLocatedEffect {
159                        kind: EffectKind::LintFailure,
160                        file: eff.file.clone(),
161                        line: eff.line,
162                        message: eff.message.clone(),
163                        in_baseline: false,
164                        in_patched: true,
165                    });
166                }
167            }
168            (false, true) => {
169                improvements += 1;
170                for eff in &baseline.clippy_output.effects {
171                    effects.push(TypedLocatedEffect {
172                        kind: EffectKind::WarningImprovement,
173                        file: eff.file.clone(),
174                        line: eff.line,
175                        message: format!("fixed: {}", eff.message),
176                        in_baseline: true,
177                        in_patched: false,
178                    });
179                }
180            }
181            (false, false) => {
182                stable_failures += 1;
183                // Classify effects that are stable across both
184                diff_effects(
185                    &baseline.clippy_output.effects,
186                    &patched.clippy_output.effects,
187                    &mut effects,
188                    &mut regressions,
189                    &mut improvements,
190                );
191            }
192            (true, true) => {
193                stable_passes += 1;
194            }
195        }
196
197        // Compare tests
198        match (baseline.test_pass, patched.test_pass) {
199            (true, false) => {
200                regressions += 1;
201                for eff in &patched.test_output.effects {
202                    effects.push(TypedLocatedEffect {
203                        kind: EffectKind::TestFailure,
204                        file: eff.file.clone(),
205                        line: eff.line,
206                        message: eff.message.clone(),
207                        in_baseline: false,
208                        in_patched: true,
209                    });
210                }
211            }
212            (false, true) => {
213                improvements += 1;
214            }
215            (false, false) => {
216                stable_failures += 1;
217                diff_effects(
218                    &baseline.test_output.effects,
219                    &patched.test_output.effects,
220                    &mut effects,
221                    &mut regressions,
222                    &mut improvements,
223                );
224            }
225            (true, true) => {
226                stable_passes += 1;
227            }
228        }
229
230        ExperimentDiff {
231            effects,
232            regressions,
233            improvements,
234            stable_failures,
235            stable_passes,
236            // Single-pair is NOT statistically meaningful. It is a provisional
237            // local observation only.
238            statistically_meaningful: false,
239            sample_warning: Some(
240                "single paired trial: provisional local attribution only, not statistically meaningful".to_string(),
241            ),
242        }
243    }
244}
245
246/// Diff individual effects between baseline and patched, classifying new/removed.
247fn diff_effects(
248    baseline_effects: &[crate::exec::backend::LocatedEffect],
249    patched_effects: &[crate::exec::backend::LocatedEffect],
250    out: &mut Vec<TypedLocatedEffect>,
251    regressions: &mut u32,
252    improvements: &mut u32,
253) {
254    let baseline_classes: std::collections::BTreeSet<_> = baseline_effects
255        .iter()
256        .map(|e| &e.sig.message_class)
257        .collect();
258    let patched_classes: std::collections::BTreeSet<_> = patched_effects
259        .iter()
260        .map(|e| &e.sig.message_class)
261        .collect();
262
263    // New in patched (regressions)
264    for eff in patched_effects {
265        if !baseline_classes.contains(&eff.sig.message_class) {
266            *regressions += 1;
267            out.push(TypedLocatedEffect {
268                kind: EffectKind::WarningRegression,
269                file: eff.file.clone(),
270                line: eff.line,
271                message: eff.message.clone(),
272                in_baseline: false,
273                in_patched: true,
274            });
275        }
276    }
277
278    // Gone from patched (improvements)
279    for eff in baseline_effects {
280        if !patched_classes.contains(&eff.sig.message_class) {
281            *improvements += 1;
282            out.push(TypedLocatedEffect {
283                kind: EffectKind::WarningImprovement,
284                file: eff.file.clone(),
285                line: eff.line,
286                message: format!("fixed: {}", eff.message),
287                in_baseline: true,
288                in_patched: false,
289            });
290        }
291    }
292}
293
294// ── Statistics policy ──
295
296/// Policy controlling statistical validity claims.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct StatisticsPolicy {
299    /// Minimum paired trials for scalar stability claims.
300    #[serde(default = "default_min_paired")]
301    pub min_paired_trials: u32,
302    /// Whether timing claims require timing_admissible = true.
303    #[serde(default = "default_true")]
304    pub require_timing_admissibility: bool,
305}
306
307fn default_min_paired() -> u32 {
308    3
309}
310fn default_true() -> bool {
311    true
312}
313
314impl Default for StatisticsPolicy {
315    fn default() -> Self {
316        Self {
317            min_paired_trials: default_min_paired(),
318            require_timing_admissibility: true,
319        }
320    }
321}
322
323// ── Run identity (split record model) ──
324
325/// Identity of an experiment run (who/what/when).
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct RunIdentity {
328    pub run_id: String,
329    pub candidate_id: String,
330    pub task_id: String,
331    pub trace_id: String,
332    pub started_at: String,
333}
334
335/// Record of execution details (how it ran).
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct ExecutionRecord {
338    pub run_id: String,
339    pub mode: ExperimentMode,
340    pub baseline: BaselineDescriptor,
341    pub trials: Vec<TrialRecord>,
342    pub backend_kind: ExecutionBackendKind,
343    pub workspace_path: PathBuf,
344    pub completed_at: String,
345}
346
347/// Record of analysis derived from execution.
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct AnalysisRecord {
350    pub run_id: String,
351    pub diff: ExperimentDiff,
352    pub scores_json: String,
353    pub attribution_json: Option<String>,
354}
355
356/// Record of evidence produced.
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct EvidenceRecord {
359    pub run_id: String,
360    pub bundle_id: String,
361    pub hypothesis_ids: Vec<String>,
362    pub verification_plan_id: Option<String>,
363}
364
365/// Record of export to semantic-memory.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct ExperimentExportRecord {
368    pub export_key: String,
369    pub bundle_id: String,
370    pub rendering_version: u32,
371    pub namespace: String,
372    pub exported_at: String,
373    /// Whether the compatibility-only direct import escape hatch succeeded.
374    pub write_through_ok: Option<bool>,
375}
376
377// ── Experiment execution ──
378
379/// Configuration for a single experiment.
380#[derive(Debug, Clone)]
381pub struct ExperimentConfig {
382    pub mode: ExperimentMode,
383    pub trial_count: u32,
384    pub statistics_policy: StatisticsPolicy,
385    pub comparability: ComparabilityPolicy,
386    pub workspace_policy: WorkspacePolicy,
387}
388
389impl Default for ExperimentConfig {
390    fn default() -> Self {
391        Self {
392            mode: ExperimentMode::Paired,
393            trial_count: 1,
394            statistics_policy: StatisticsPolicy::default(),
395            comparability: ComparabilityPolicy::default(),
396            workspace_policy: WorkspacePolicy::default(),
397        }
398    }
399}
400
401/// Result of running an experiment.
402///
403/// Note: CheckResult is not directly serializable (comes from check-runner primitive),
404/// so we serialize the diff + trial summaries instead. The full CheckResults are
405/// available in-memory for downstream processing.
406#[derive(Debug, Clone)]
407pub struct ExperimentResult {
408    pub run_id: String,
409    pub mode: ExperimentMode,
410    pub baseline_descriptor: BaselineDescriptor,
411    pub baseline_result: CheckResult,
412    pub patched_result: CheckResult,
413    pub diff: ExperimentDiff,
414    pub trials: Vec<TrialRecord>,
415    pub completed_at: String,
416}
417
418/// Real experiment runner that executes baseline and patched checks.
419pub struct PairedExperimentRunner<'a> {
420    backend: &'a dyn crate::exec::backend::ExecutionBackend,
421    adapter: &'a dyn crate::adapters::ProjectAdapter,
422    config: &'a crate::config::ForgeConfig,
423}
424
425impl<'a> PairedExperimentRunner<'a> {
426    /// Create a new runner bound to the given backend, project adapter, and config.
427    pub fn new(
428        backend: &'a dyn crate::exec::backend::ExecutionBackend,
429        adapter: &'a dyn crate::adapters::ProjectAdapter,
430        config: &'a crate::config::ForgeConfig,
431    ) -> Self {
432        Self {
433            backend,
434            adapter,
435            config,
436        }
437    }
438
439    /// Run a paired experiment: baseline then patched.
440    ///
441    /// 1. Prepare workspace from fixture
442    /// 2. Run baseline checks
443    /// 3. Apply patch
444    /// 4. Run patched checks
445    /// 5. Compute typed diff
446    pub async fn run(
447        &self,
448        fixture_path: &Path,
449        patch: &crate::runtime::patch::types::StructuredPatch,
450        experiment_config: &ExperimentConfig,
451    ) -> ForgeResult<ExperimentResult> {
452        let run_id = uuid::Uuid::new_v4().to_string();
453        let _started_at = chrono::Utc::now().to_rfc3339();
454
455        tracing::info!(run_id = %run_id, mode = ?experiment_config.mode, "starting experiment");
456
457        // Prepare workspace
458        let workspace = self.backend.prepare_workspace(fixture_path).await?;
459        let ws_path = &workspace.host_path;
460
461        // Capture baseline provenance
462        let baseline_descriptor = crate::baseline::capture_baseline_provenance(ws_path).await?;
463
464        let timeout = self.config.container.command_timeout_secs;
465        let mut all_trials = Vec::new();
466
467        // Run baseline checks
468        tracing::info!(run_id = %run_id, "running baseline checks");
469        let baseline_result = self
470            .run_checks(ws_path, timeout, TrialSide::Baseline, &mut all_trials)
471            .await?;
472
473        // Apply patch to workspace
474        tracing::info!(run_id = %run_id, "applying patch");
475        let _line_map = crate::runtime::patch::apply::apply_patch(patch, ws_path)?;
476
477        // Run patched checks
478        tracing::info!(run_id = %run_id, "running patched checks");
479        let patched_result = self
480            .run_checks(ws_path, timeout, TrialSide::Patched, &mut all_trials)
481            .await?;
482
483        // Compute typed diff
484        let diff = ExperimentDiff::from_paired(&baseline_result, &patched_result);
485
486        let completed_at = chrono::Utc::now().to_rfc3339();
487        tracing::info!(
488            run_id = %run_id,
489            regressions = diff.regressions,
490            improvements = diff.improvements,
491            "experiment completed"
492        );
493
494        Ok(ExperimentResult {
495            run_id,
496            mode: experiment_config.mode,
497            baseline_descriptor,
498            baseline_result,
499            patched_result,
500            diff,
501            trials: all_trials,
502            completed_at,
503        })
504    }
505
506    /// Run all check commands and aggregate into a CheckResult.
507    async fn run_checks(
508        &self,
509        workspace: &Path,
510        timeout: u64,
511        side: TrialSide,
512        trials: &mut Vec<TrialRecord>,
513    ) -> ForgeResult<CheckResult> {
514        let commands = self.adapter.check_commands(self.config);
515        let start = std::time::Instant::now();
516
517        let mut fmt_output = crate::exec::backend::ParsedCheckOutput::default();
518        let mut clippy_output = crate::exec::backend::ParsedCheckOutput::default();
519        let mut test_output = crate::exec::backend::ParsedCheckOutput::default();
520
521        for cmd in &commands {
522            let args: Vec<&str> = cmd.args.iter().map(|s| s.as_str()).collect();
523            let env: Vec<(&str, &str)> = cmd
524                .env
525                .iter()
526                .map(|(k, v)| (k.as_str(), v.as_str()))
527                .collect();
528
529            let output = self
530                .backend
531                .run_command(workspace, &cmd.program, &args, &env, timeout)
532                .await;
533
534            let kind = cmd.kind.clone();
535            match output {
536                Ok(output) => {
537                    let parsed = self.adapter.parse_check_output(
538                        cmd,
539                        &output.stdout,
540                        &output.stderr,
541                        output.exit_code,
542                    );
543                    match kind {
544                        crate::exec::backend::CheckKind::Fmt => fmt_output = parsed,
545                        crate::exec::backend::CheckKind::Clippy => clippy_output = parsed,
546                        crate::exec::backend::CheckKind::Test => test_output = parsed,
547                    }
548                }
549                Err(ForgeError::CommandTimeout { .. }) => {
550                    // Record timeout as failure
551                    let parsed = crate::exec::backend::ParsedCheckOutput {
552                        check_kind: kind.clone(),
553                        exit_code: -1,
554                        effects: vec![],
555                        raw_stdout: String::new(),
556                        raw_stderr: "timeout".to_string(),
557                    };
558                    match kind {
559                        crate::exec::backend::CheckKind::Fmt => fmt_output = parsed,
560                        crate::exec::backend::CheckKind::Clippy => clippy_output = parsed,
561                        crate::exec::backend::CheckKind::Test => test_output = parsed,
562                    }
563                }
564                Err(e) => return Err(e),
565            }
566        }
567
568        let duration_ms = start.elapsed().as_millis() as u64;
569
570        let check_result = CheckResult {
571            fmt_pass: fmt_output.exit_code == 0,
572            clippy_pass: clippy_output.exit_code == 0,
573            test_pass: test_output.exit_code == 0,
574            fmt_output,
575            clippy_output,
576            test_output,
577            total_duration_ms: duration_ms,
578        };
579
580        trials.push(TrialRecord {
581            side,
582            fmt_pass: check_result.fmt_pass,
583            clippy_pass: check_result.clippy_pass,
584            test_pass: check_result.test_pass,
585            backend_kind: self.backend.kind(),
586            duration_ms,
587            seed: 0,
588            cache_mode: CacheMode::Unknown,
589            network_available: true,
590            timing_admissible: false,
591        });
592
593        Ok(check_result)
594    }
595}