1use super::{ExperimentEvidenceBundle, HypothesisEdge, HypothesisEdgeKind, VerificationState};
8use crate::config::ForgeLimits;
9use crate::error::ForgeResult;
10use crate::experiment::ExperimentDiff;
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CausalHypothesis {
19 pub hypothesis_id: String,
21 pub cause_signature: String,
23 pub effect_signature: String,
25 pub confidence: f64,
27 pub status: HypothesisStatus,
29 pub support_count: u64,
31 pub contradiction_count: u64,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum HypothesisStatus {
42 Proposed,
44 Supported,
46 #[serde(alias = "Refuted")]
48 Contradicted,
49 #[serde(alias = "Confirmed")]
51 Neutral,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct VerificationPlan {
62 pub plan_id: String,
64 pub target_hypotheses: Vec<String>,
66 pub steps: Vec<VerificationStep>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub budget: Option<PlanBudget>,
71 #[serde(default)]
73 pub dropped_steps: Vec<DroppedStep>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct PlanBudget {
79 pub max_steps: usize,
81 pub estimated_duration_secs: u64,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct DroppedStep {
88 pub step: VerificationStep,
90 pub reason: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct VerificationStep {
97 pub verification_type: VerificationType,
99 pub description: String,
101 pub expected_outcome: String,
103 #[serde(default = "default_informational")]
105 pub requirement: StepRequirement,
106}
107
108fn default_informational() -> StepRequirement {
109 StepRequirement::Informational
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum VerificationType {
120 Reproduce,
122 Generalize,
124 Negate,
126 CrossProject,
128 RunTest,
130 CheckInvariant,
132 CompareBaseline,
134 ManualReview,
136 Ablation { edit_op_signatures: Vec<String> },
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum StepRequirement {
144 Required,
145 Informational,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct VerificationPolicy {
151 #[serde(default = "default_max_ablation")]
153 pub max_ablation_combinations: u32,
154 #[serde(default)]
156 pub generate_manual_review: bool,
157 #[serde(default = "default_true")]
159 pub generate_ablation_for_mixed_effects: bool,
160}
161
162fn default_max_ablation() -> u32 {
163 16
164}
165
166fn default_true() -> bool {
167 true
168}
169
170impl Default for VerificationPolicy {
171 fn default() -> Self {
172 Self {
173 max_ablation_combinations: default_max_ablation(),
174 generate_manual_review: false,
175 generate_ablation_for_mixed_effects: true,
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct EvidenceAssessment {
183 pub reproducibility: AssessmentCategory,
185 pub isolation: AssessmentCategory,
187 pub contradiction_state: ContradictionState,
189 pub sample_support: SampleSupport,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum AssessmentCategory {
197 Strong,
198 Adequate,
199 Weak,
200 Insufficient,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum ContradictionState {
207 Clean,
209 HasContradictions,
211 Undetermined,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum SampleSupport {
219 Sufficient,
221 Marginal,
223 Insufficient,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct PairComparability {
230 pub valid: bool,
232 pub violations: Vec<String>,
234}
235
236impl PairComparability {
237 #[allow(clippy::too_many_arguments)]
246 pub fn check(
247 baseline_workload: &str,
248 patched_workload: &str,
249 baseline_checks: &[String],
250 patched_checks: &[String],
251 baseline_timeout_class: &str,
252 patched_timeout_class: &str,
253 baseline_backend: &str,
254 patched_backend: &str,
255 baseline_config_flags: &[String],
256 patched_config_flags: &[String],
257 ) -> Self {
258 let mut violations = Vec::new();
259
260 if baseline_workload != patched_workload {
261 violations.push(format!(
262 "workload mismatch: baseline={baseline_workload}, patched={patched_workload}"
263 ));
264 }
265 if baseline_checks != patched_checks {
266 violations.push(format!(
267 "selected_checks mismatch: baseline={baseline_checks:?}, patched={patched_checks:?}"
268 ));
269 }
270 if baseline_timeout_class != patched_timeout_class {
271 violations.push(format!(
272 "timeout_class mismatch: baseline={baseline_timeout_class}, patched={patched_timeout_class}"
273 ));
274 }
275 if baseline_backend != patched_backend {
276 violations.push(format!(
277 "backend mismatch: baseline={baseline_backend}, patched={patched_backend}"
278 ));
279 }
280 if baseline_config_flags != patched_config_flags {
281 violations.push(format!(
282 "config_flags mismatch: baseline={baseline_config_flags:?}, patched={patched_config_flags:?}"
283 ));
284 }
285
286 Self {
287 valid: violations.is_empty(),
288 violations,
289 }
290 }
291
292 pub fn check_timeout_asymmetry(
295 baseline_timed_out: bool,
296 patched_timed_out: bool,
297 ) -> Option<String> {
298 if baseline_timed_out && !patched_timed_out {
299 Some("baseline timed out but patched succeeded: pair invalid for attribution".into())
300 } else {
301 None
302 }
303 }
304}
305
306#[async_trait::async_trait]
308pub trait ExperimentRunner: Send + Sync {
309 async fn run_plan(
311 &self,
312 plan: &VerificationPlan,
313 bundle: &ExperimentEvidenceBundle,
314 ) -> ForgeResult<Vec<CausalHypothesis>>;
315}
316
317pub struct LocalExperimentRunner {
323 pub workspace_dir: PathBuf,
325 pub max_parallelism: usize,
327}
328
329impl LocalExperimentRunner {
330 pub fn new(workspace_dir: PathBuf) -> Self {
332 Self {
333 workspace_dir,
334 max_parallelism: 4,
335 }
336 }
337
338 pub fn with_max_parallelism(mut self, max: usize) -> Self {
340 self.max_parallelism = max.clamp(1, 32);
341 self
342 }
343}
344
345#[async_trait::async_trait]
346impl ExperimentRunner for LocalExperimentRunner {
347 async fn run_plan(
348 &self,
349 plan: &VerificationPlan,
350 bundle: &ExperimentEvidenceBundle,
351 ) -> ForgeResult<Vec<CausalHypothesis>> {
352 tracing::info!(
353 plan_id = %plan.plan_id,
354 bundle_id = %bundle.bundle_id,
355 steps = plan.steps.len(),
356 workspace = %self.workspace_dir.display(),
357 "recording verification plan (Phase 5: plan-only, no execution)"
358 );
359
360 let updated = bundle.hypotheses.clone();
361 for step in &plan.steps {
362 tracing::info!(
363 verification_type = ?step.verification_type,
364 description = %step.description,
365 "recorded verification step (not executed in Phase 5)"
366 );
367 }
368
369 Ok(updated)
370 }
371}
372
373pub fn derive_status(support: u64, contradictions: u64) -> HypothesisStatus {
375 if support == 0 && contradictions == 0 {
376 HypothesisStatus::Proposed
377 } else if contradictions > support {
378 HypothesisStatus::Contradicted
379 } else if support > contradictions {
380 HypothesisStatus::Supported
381 } else {
382 HypothesisStatus::Neutral
383 }
384}
385
386pub fn local_hypothesis_support_confidence(support: u64, contradictions: u64) -> f64 {
388 let prior = 1.0_f64;
389 let total = support as f64 + contradictions as f64 + prior;
390 (support as f64 / total).clamp(0.0, 1.0)
391}
392
393#[deprecated(note = "Use `local_hypothesis_support_confidence` for explicit local semantics")]
398pub fn compute_confidence(support: u64, contradictions: u64) -> f64 {
399 local_hypothesis_support_confidence(support, contradictions)
400}
401
402pub fn update_hypotheses_from_diff(hypotheses: &mut [CausalHypothesis], diff: &ExperimentDiff) {
404 for effect in &diff.effects {
405 for hypothesis in hypotheses.iter_mut() {
406 let matches = effect.message.contains(&hypothesis.effect_signature)
407 || hypothesis.effect_signature.contains(&effect.message);
408 if !matches {
409 continue;
410 }
411
412 if effect.in_patched != effect.in_baseline {
413 hypothesis.support_count += 1;
414 }
415
416 hypothesis.status =
417 derive_status(hypothesis.support_count, hypothesis.contradiction_count);
418 hypothesis.confidence = local_hypothesis_support_confidence(
419 hypothesis.support_count,
420 hypothesis.contradiction_count,
421 );
422 }
423 }
424}
425
426pub fn build_hypothesis_edges(diff: &ExperimentDiff, bundle_id: &str) -> Vec<HypothesisEdge> {
428 let mut edges = Vec::new();
429
430 for effect in &diff.effects {
431 let kind = if effect.in_patched && !effect.in_baseline {
432 HypothesisEdgeKind::CausesRegression
433 } else if effect.in_baseline && !effect.in_patched {
434 HypothesisEdgeKind::FixesFailure
435 } else if effect.in_baseline && effect.in_patched {
436 HypothesisEdgeKind::AssociatedWithStableFailure
437 } else {
438 continue;
439 };
440
441 let status = match kind {
442 HypothesisEdgeKind::CausesRegression | HypothesisEdgeKind::FixesFailure => {
443 HypothesisStatus::Supported
444 }
445 HypothesisEdgeKind::AssociatedWithStableFailure => HypothesisStatus::Neutral,
446 };
447 let confidence = match kind {
448 HypothesisEdgeKind::CausesRegression | HypothesisEdgeKind::FixesFailure => {
449 local_hypothesis_support_confidence(1, 0)
450 }
451 HypothesisEdgeKind::AssociatedWithStableFailure => 0.0,
452 };
453 let edge_id = format!(
454 "edge-{}-{}",
455 bundle_id,
456 blake3::hash(effect.message.as_bytes())
457 .to_hex()
458 .to_string()
459 .get(..8)
460 .unwrap_or("00000000")
461 );
462
463 edges.push(HypothesisEdge {
464 edge_id,
465 source_edit: String::new(),
466 target_effect: serde_json::to_string(effect).unwrap_or_default(),
467 kind,
468 status,
469 confidence,
470 evidence_ids: vec![bundle_id.to_string()],
471 contradiction_ids: vec![],
472 verification_status: VerificationState::Unverified,
473 });
474 }
475
476 edges
477}
478
479pub fn generate_verification_plan(
481 bundle: &ExperimentEvidenceBundle,
482 edit_op_signatures: &[String],
483 policy: &VerificationPolicy,
484 limits: &ForgeLimits,
485) -> VerificationPlan {
486 let plan_id = uuid::Uuid::new_v4().to_string();
487 let target_hypotheses = bundle
488 .hypotheses
489 .iter()
490 .map(|hypothesis| hypothesis.hypothesis_id.clone())
491 .collect();
492
493 let mut all_steps = Vec::new();
494 let mut dropped_steps = Vec::new();
495
496 if let Some(diff) = bundle.experiment_diff.as_ref() {
497 for effect in &diff.effects {
498 if effect.in_patched && !effect.in_baseline {
499 all_steps.push(VerificationStep {
500 verification_type: VerificationType::RunTest,
501 description: format!("Re-run test for new effect: {}", effect.message),
502 expected_outcome: "Effect reproduces consistently".to_string(),
503 requirement: StepRequirement::Required,
504 });
505 }
506 }
507 }
508
509 all_steps.push(VerificationStep {
510 verification_type: VerificationType::CompareBaseline,
511 description: "Verify baseline results are stable".to_string(),
512 expected_outcome: "Baseline unchanged".to_string(),
513 requirement: StepRequirement::Required,
514 });
515 all_steps.push(VerificationStep {
516 verification_type: VerificationType::CheckInvariant,
517 description: "Verify no invariant violations".to_string(),
518 expected_outcome: "Zero violations".to_string(),
519 requirement: StepRequirement::Required,
520 });
521
522 if bundle
523 .hypotheses
524 .iter()
525 .any(|hypothesis| hypothesis.confidence < 0.3)
526 {
527 all_steps.push(VerificationStep {
528 verification_type: VerificationType::ManualReview,
529 description: "Manual review for low-confidence hypotheses".to_string(),
530 expected_outcome: "Reviewer confirms or rejects".to_string(),
531 requirement: StepRequirement::Informational,
532 });
533 }
534
535 if policy.generate_ablation_for_mixed_effects {
536 if let Some(diff) = bundle.experiment_diff.as_ref() {
537 if diff.regressions > 0 && diff.improvements > 0 && !edit_op_signatures.is_empty() {
538 let max_ablations = policy
539 .max_ablation_combinations
540 .min(edit_op_signatures.len() as u32);
541 for (index, signature) in edit_op_signatures.iter().enumerate() {
542 if index >= max_ablations as usize {
543 break;
544 }
545 all_steps.push(VerificationStep {
546 verification_type: VerificationType::Ablation {
547 edit_op_signatures: vec![signature.clone()],
548 },
549 description: format!("Ablation: remove edit op {index} and re-run"),
550 expected_outcome: "Identify which edit caused each effect".to_string(),
551 requirement: StepRequirement::Informational,
552 });
553 }
554 }
555 }
556 }
557
558 if policy.generate_manual_review {
559 all_steps.push(VerificationStep {
560 verification_type: VerificationType::ManualReview,
561 description: "Manual review of patch and effects".to_string(),
562 expected_outcome: "Reviewer approves".to_string(),
563 requirement: StepRequirement::Informational,
564 });
565 }
566
567 let max_steps = limits.max_verification_steps;
568 let mut steps = Vec::new();
569 for step in all_steps {
570 if steps.len() >= max_steps {
571 dropped_steps.push(DroppedStep {
572 step,
573 reason: format!("exceeded max_verification_steps={max_steps}"),
574 });
575 } else {
576 steps.push(step);
577 }
578 }
579
580 VerificationPlan {
581 plan_id,
582 target_hypotheses,
583 budget: Some(PlanBudget {
584 max_steps,
585 estimated_duration_secs: steps.len() as u64 * 60,
586 }),
587 steps,
588 dropped_steps,
589 }
590}
591
592pub fn compute_assessment(
594 bundle: &ExperimentEvidenceBundle,
595 trial_count: u32,
596 isolated: bool,
597 min_trials: u32,
598) -> EvidenceAssessment {
599 let reproducibility = if trial_count >= min_trials {
600 AssessmentCategory::Strong
601 } else if trial_count >= 2 {
602 AssessmentCategory::Adequate
603 } else if trial_count == 1 {
604 AssessmentCategory::Weak
605 } else {
606 AssessmentCategory::Insufficient
607 };
608 let isolation = if isolated {
609 AssessmentCategory::Strong
610 } else {
611 AssessmentCategory::Weak
612 };
613 let has_contradictions = bundle
614 .hypotheses
615 .iter()
616 .any(|hypothesis| hypothesis.contradiction_count > 0);
617 let contradiction_state = if bundle.hypotheses.is_empty() {
618 ContradictionState::Undetermined
619 } else if has_contradictions {
620 ContradictionState::HasContradictions
621 } else {
622 ContradictionState::Clean
623 };
624 let total_observations: u64 = bundle
625 .hypotheses
626 .iter()
627 .map(|hypothesis| hypothesis.support_count + hypothesis.contradiction_count)
628 .sum();
629 let sample_support = if total_observations >= 10 {
630 SampleSupport::Sufficient
631 } else if total_observations >= 3 {
632 SampleSupport::Marginal
633 } else {
634 SampleSupport::Insufficient
635 };
636
637 EvidenceAssessment {
638 reproducibility,
639 isolation,
640 contradiction_state,
641 sample_support,
642 }
643}