Skip to main content

forge_engine/lab/
evidence_analysis.rs

1//! Hypothesis derivation, verification plan generation, and evidence assessment.
2//!
3//! Functions in this module operate on `ExperimentEvidenceBundle` to derive
4//! hypothesis status, build edges from diffs, generate verification plans,
5//! and compute deterministic evidence assessments.
6
7use 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/// A causal hypothesis linking an edit pattern to an observed effect.
15///
16/// Legacy structure. Prefer `HypothesisEdge` for new code.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CausalHypothesis {
19    /// Unique hypothesis identifier.
20    pub hypothesis_id: String,
21    /// The edit signature that is hypothesized to cause the effect.
22    pub cause_signature: String,
23    /// The effect signature observed.
24    pub effect_signature: String,
25    /// Confidence in this hypothesis (0.0 to 1.0).
26    pub confidence: f64,
27    /// Current status of the hypothesis.
28    pub status: HypothesisStatus,
29    /// Number of supporting observations.
30    pub support_count: u64,
31    /// Number of contradicting observations.
32    pub contradiction_count: u64,
33}
34
35/// Lifecycle status of a causal hypothesis.
36///
37/// Phase 5 uses: Proposed, Supported, Contradicted, Neutral.
38/// `Confirmed` and `Refuted` are preserved as serde aliases for backward compatibility.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum HypothesisStatus {
42    /// Newly proposed, awaiting evidence.
43    Proposed,
44    /// Partially supported by evidence from paired results.
45    Supported,
46    /// Contradicted by evidence from paired results.
47    #[serde(alias = "Refuted")]
48    Contradicted,
49    /// Neutral: no evidence supports or contradicts.
50    #[serde(alias = "Confirmed")]
51    Neutral,
52}
53
54/// A structured follow-up plan for verifying hypotheses.
55///
56/// Phase 5: these are STRUCTURED FOLLOW-UP PLANS, not robust refutation engines.
57/// Refutation artifacts (placebo, dummy outcome, subsample stability) are modeled
58/// explicitly as first-class evidence-artifact records and can be attached to the
59/// bundle without being executed in-plan.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct VerificationPlan {
62    /// Unique plan identifier.
63    pub plan_id: String,
64    /// Hypotheses to verify.
65    pub target_hypotheses: Vec<String>,
66    /// Steps in the verification.
67    pub steps: Vec<VerificationStep>,
68    /// Budget constraints for this plan.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub budget: Option<PlanBudget>,
71    /// Steps that were dropped due to budget constraints.
72    #[serde(default)]
73    pub dropped_steps: Vec<DroppedStep>,
74}
75
76/// Budget constraints for a verification plan.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct PlanBudget {
79    /// Maximum number of verification steps.
80    pub max_steps: usize,
81    /// Estimated total duration in seconds.
82    pub estimated_duration_secs: u64,
83}
84
85/// A step that was dropped from a verification plan due to budget constraints.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct DroppedStep {
88    /// The step that was dropped.
89    pub step: VerificationStep,
90    /// Why it was dropped.
91    pub reason: String,
92}
93
94/// A single step in a verification plan.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct VerificationStep {
97    /// What type of verification to perform.
98    pub verification_type: VerificationType,
99    /// Description of what this step checks.
100    pub description: String,
101    /// Expected outcome.
102    pub expected_outcome: String,
103    /// Whether this step is required for promotion.
104    #[serde(default = "default_informational")]
105    pub requirement: StepRequirement,
106}
107
108fn default_informational() -> StepRequirement {
109    StepRequirement::Informational
110}
111
112/// Types of verification experiments.
113///
114/// Phase 5 supports: RunTest, CheckInvariant, ManualReview, CompareBaseline.
115/// Reproduce/Generalize/Negate/CrossProject/Ablation are defined but not
116/// executed by Phase 5 runners.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum VerificationType {
120    /// Re-run the same edit and check for same effect.
121    Reproduce,
122    /// Apply a similar edit to see if effect generalizes.
123    Generalize,
124    /// Apply a counter-edit to see if effect disappears.
125    Negate,
126    /// Run on a different codebase to test portability.
127    CrossProject,
128    /// Run a specific test.
129    RunTest,
130    /// Check an invariant.
131    CheckInvariant,
132    /// Compare against baseline.
133    CompareBaseline,
134    /// Require manual review.
135    ManualReview,
136    /// Ablation: remove specific edit ops and re-run.
137    Ablation { edit_op_signatures: Vec<String> },
138}
139
140/// Whether a verification step is required for promotion.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum StepRequirement {
144    Required,
145    Informational,
146}
147
148/// Policy controlling verification plan generation.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct VerificationPolicy {
151    /// Maximum ablation combinations to explore.
152    #[serde(default = "default_max_ablation")]
153    pub max_ablation_combinations: u32,
154    /// Whether manual review steps are generated.
155    #[serde(default)]
156    pub generate_manual_review: bool,
157    /// Whether to generate ablation steps for mixed-effect patches.
158    #[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/// Deterministic evidence assessment derived from policy, not vibes.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct EvidenceAssessment {
183    /// Whether the evidence is reproducible (based on trial count).
184    pub reproducibility: AssessmentCategory,
185    /// Whether the execution was properly isolated.
186    pub isolation: AssessmentCategory,
187    /// Whether any hypotheses are contradicted.
188    pub contradiction_state: ContradictionState,
189    /// Sample support level.
190    pub sample_support: SampleSupport,
191}
192
193/// Assessment category (deterministic, policy-derived).
194#[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/// Whether hypotheses are contradicted.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum ContradictionState {
207    /// No contradictions found.
208    Clean,
209    /// Some hypotheses have contradicting observations.
210    HasContradictions,
211    /// Insufficient data to determine.
212    Undetermined,
213}
214
215/// Level of sample support for claims.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum SampleSupport {
219    /// Sufficient trials for statistical claims.
220    Sufficient,
221    /// Marginal — claims should carry warnings.
222    Marginal,
223    /// Insufficient — no scalar claims allowed.
224    Insufficient,
225}
226
227/// Result of checking whether a baseline/patched pair is comparable.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct PairComparability {
230    /// Whether the pair is valid for attribution.
231    pub valid: bool,
232    /// Reasons why the pair is incomparable, if any.
233    pub violations: Vec<String>,
234}
235
236impl PairComparability {
237    /// Check pair comparability between baseline and patched trial contexts.
238    ///
239    /// A paired comparison is valid only if baseline and patched runs share:
240    /// - same workload_id
241    /// - same ordered selected_checks
242    /// - same effective timeout class
243    /// - same execution backend family
244    /// - same config-flag projection
245    #[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    /// Check timeout asymmetry: if baseline times out and patched succeeds,
293    /// the pair is invalid for attribution.
294    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/// Trait for running verification experiments.
307#[async_trait::async_trait]
308pub trait ExperimentRunner: Send + Sync {
309    /// Execute a verification plan and return updated hypotheses.
310    async fn run_plan(
311        &self,
312        plan: &VerificationPlan,
313        bundle: &ExperimentEvidenceBundle,
314    ) -> ForgeResult<Vec<CausalHypothesis>>;
315}
316
317/// Local experiment runner that executes experiments on the host machine.
318///
319/// Phase 5 limitation: this runner logs verification steps but does NOT
320/// actually execute real checks. It records steps as planned, without
321/// fabricating support/contradiction counts.
322pub struct LocalExperimentRunner {
323    /// Working directory for experiments.
324    pub workspace_dir: PathBuf,
325    /// Maximum concurrent experiments.
326    pub max_parallelism: usize,
327}
328
329impl LocalExperimentRunner {
330    /// Create a new local experiment runner.
331    pub fn new(workspace_dir: PathBuf) -> Self {
332        Self {
333            workspace_dir,
334            max_parallelism: 4,
335        }
336    }
337
338    /// Set the maximum parallelism.
339    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
373/// Derive hypothesis status from observation counts.
374pub 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
386/// Compute hypothesis confidence from observation counts.
387pub 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/// Compatibility alias for historical call sites.
394///
395/// Phase status: migration-only
396/// Removal condition: remove when all consumers have migrated to `local_hypothesis_support_confidence`
397#[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
402/// Update hypotheses from typed experiment effects.
403pub 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
426/// Build hypothesis edges from an ExperimentDiff.
427pub 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
479/// Generate a verification plan from experiment results and hypotheses.
480pub 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
592/// Compute a deterministic evidence assessment from the bundle.
593pub 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}