1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ExperimentMode {
17 Paired,
19 RepeatedPaired,
21 VerificationFollowup,
23}
24
25#[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#[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 pub in_baseline: bool,
54 pub in_patched: bool,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TrialRecord {
63 pub side: TrialSide,
65 pub fmt_pass: bool,
67 pub clippy_pass: bool,
68 pub test_pass: bool,
69 pub backend_kind: ExecutionBackendKind,
71 pub duration_ms: u64,
73 pub seed: u64,
75 pub cache_mode: CacheMode,
77 pub network_available: bool,
79 pub timing_admissible: bool,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum TrialSide {
87 Baseline,
88 Patched,
89}
90
91#[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#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ExperimentDiff {
105 pub effects: Vec<TypedLocatedEffect>,
107 pub regressions: u32,
109 pub improvements: u32,
110 pub stable_failures: u32,
111 pub stable_passes: u32,
112 pub statistically_meaningful: bool,
114 pub sample_warning: Option<String>,
116}
117
118impl ExperimentDiff {
119 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 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 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 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 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 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
246fn 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct StatisticsPolicy {
299 #[serde(default = "default_min_paired")]
301 pub min_paired_trials: u32,
302 #[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#[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#[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#[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#[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#[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 pub write_through_ok: Option<bool>,
375}
376
377#[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#[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
418pub 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 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 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 let workspace = self.backend.prepare_workspace(fixture_path).await?;
459 let ws_path = &workspace.host_path;
460
461 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 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 tracing::info!(run_id = %run_id, "applying patch");
475 let _line_map = crate::runtime::patch::apply::apply_patch(patch, ws_path)?;
476
477 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 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 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 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}