use super::{ExperimentEvidenceBundle, HypothesisEdge, HypothesisEdgeKind, VerificationState};
use crate::config::ForgeLimits;
use crate::error::ForgeResult;
use crate::experiment::ExperimentDiff;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CausalHypothesis {
pub hypothesis_id: String,
pub cause_signature: String,
pub effect_signature: String,
pub confidence: f64,
pub status: HypothesisStatus,
pub support_count: u64,
pub contradiction_count: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HypothesisStatus {
Proposed,
Supported,
#[serde(alias = "Refuted")]
Contradicted,
#[serde(alias = "Confirmed")]
Neutral,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationPlan {
pub plan_id: String,
pub target_hypotheses: Vec<String>,
pub steps: Vec<VerificationStep>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget: Option<PlanBudget>,
#[serde(default)]
pub dropped_steps: Vec<DroppedStep>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanBudget {
pub max_steps: usize,
pub estimated_duration_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DroppedStep {
pub step: VerificationStep,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationStep {
pub verification_type: VerificationType,
pub description: String,
pub expected_outcome: String,
#[serde(default = "default_informational")]
pub requirement: StepRequirement,
}
fn default_informational() -> StepRequirement {
StepRequirement::Informational
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerificationType {
Reproduce,
Generalize,
Negate,
CrossProject,
RunTest,
CheckInvariant,
CompareBaseline,
ManualReview,
Ablation { edit_op_signatures: Vec<String> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepRequirement {
Required,
Informational,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationPolicy {
#[serde(default = "default_max_ablation")]
pub max_ablation_combinations: u32,
#[serde(default)]
pub generate_manual_review: bool,
#[serde(default = "default_true")]
pub generate_ablation_for_mixed_effects: bool,
}
fn default_max_ablation() -> u32 {
16
}
fn default_true() -> bool {
true
}
impl Default for VerificationPolicy {
fn default() -> Self {
Self {
max_ablation_combinations: default_max_ablation(),
generate_manual_review: false,
generate_ablation_for_mixed_effects: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceAssessment {
pub reproducibility: AssessmentCategory,
pub isolation: AssessmentCategory,
pub contradiction_state: ContradictionState,
pub sample_support: SampleSupport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssessmentCategory {
Strong,
Adequate,
Weak,
Insufficient,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContradictionState {
Clean,
HasContradictions,
Undetermined,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SampleSupport {
Sufficient,
Marginal,
Insufficient,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairComparability {
pub valid: bool,
pub violations: Vec<String>,
}
impl PairComparability {
#[allow(clippy::too_many_arguments)]
pub fn check(
baseline_workload: &str,
patched_workload: &str,
baseline_checks: &[String],
patched_checks: &[String],
baseline_timeout_class: &str,
patched_timeout_class: &str,
baseline_backend: &str,
patched_backend: &str,
baseline_config_flags: &[String],
patched_config_flags: &[String],
) -> Self {
let mut violations = Vec::new();
if baseline_workload != patched_workload {
violations.push(format!(
"workload mismatch: baseline={baseline_workload}, patched={patched_workload}"
));
}
if baseline_checks != patched_checks {
violations.push(format!(
"selected_checks mismatch: baseline={baseline_checks:?}, patched={patched_checks:?}"
));
}
if baseline_timeout_class != patched_timeout_class {
violations.push(format!(
"timeout_class mismatch: baseline={baseline_timeout_class}, patched={patched_timeout_class}"
));
}
if baseline_backend != patched_backend {
violations.push(format!(
"backend mismatch: baseline={baseline_backend}, patched={patched_backend}"
));
}
if baseline_config_flags != patched_config_flags {
violations.push(format!(
"config_flags mismatch: baseline={baseline_config_flags:?}, patched={patched_config_flags:?}"
));
}
Self {
valid: violations.is_empty(),
violations,
}
}
pub fn check_timeout_asymmetry(
baseline_timed_out: bool,
patched_timed_out: bool,
) -> Option<String> {
if baseline_timed_out && !patched_timed_out {
Some("baseline timed out but patched succeeded: pair invalid for attribution".into())
} else {
None
}
}
}
#[async_trait::async_trait]
pub trait ExperimentRunner: Send + Sync {
async fn run_plan(
&self,
plan: &VerificationPlan,
bundle: &ExperimentEvidenceBundle,
) -> ForgeResult<Vec<CausalHypothesis>>;
}
pub struct LocalExperimentRunner {
pub workspace_dir: PathBuf,
pub max_parallelism: usize,
}
impl LocalExperimentRunner {
pub fn new(workspace_dir: PathBuf) -> Self {
Self {
workspace_dir,
max_parallelism: 4,
}
}
pub fn with_max_parallelism(mut self, max: usize) -> Self {
self.max_parallelism = max.clamp(1, 32);
self
}
}
#[async_trait::async_trait]
impl ExperimentRunner for LocalExperimentRunner {
async fn run_plan(
&self,
plan: &VerificationPlan,
bundle: &ExperimentEvidenceBundle,
) -> ForgeResult<Vec<CausalHypothesis>> {
tracing::info!(
plan_id = %plan.plan_id,
bundle_id = %bundle.bundle_id,
steps = plan.steps.len(),
workspace = %self.workspace_dir.display(),
"recording verification plan (Phase 5: plan-only, no execution)"
);
let updated = bundle.hypotheses.clone();
for step in &plan.steps {
tracing::info!(
verification_type = ?step.verification_type,
description = %step.description,
"recorded verification step (not executed in Phase 5)"
);
}
Ok(updated)
}
}
pub fn derive_status(support: u64, contradictions: u64) -> HypothesisStatus {
if support == 0 && contradictions == 0 {
HypothesisStatus::Proposed
} else if contradictions > support {
HypothesisStatus::Contradicted
} else if support > contradictions {
HypothesisStatus::Supported
} else {
HypothesisStatus::Neutral
}
}
pub fn local_hypothesis_support_confidence(support: u64, contradictions: u64) -> f64 {
let prior = 1.0_f64;
let total = support as f64 + contradictions as f64 + prior;
(support as f64 / total).clamp(0.0, 1.0)
}
#[deprecated(note = "Use `local_hypothesis_support_confidence` for explicit local semantics")]
pub fn compute_confidence(support: u64, contradictions: u64) -> f64 {
local_hypothesis_support_confidence(support, contradictions)
}
pub fn update_hypotheses_from_diff(hypotheses: &mut [CausalHypothesis], diff: &ExperimentDiff) {
for effect in &diff.effects {
for hypothesis in hypotheses.iter_mut() {
let matches = effect.message.contains(&hypothesis.effect_signature)
|| hypothesis.effect_signature.contains(&effect.message);
if !matches {
continue;
}
if effect.in_patched != effect.in_baseline {
hypothesis.support_count += 1;
}
hypothesis.status =
derive_status(hypothesis.support_count, hypothesis.contradiction_count);
hypothesis.confidence = local_hypothesis_support_confidence(
hypothesis.support_count,
hypothesis.contradiction_count,
);
}
}
}
pub fn build_hypothesis_edges(diff: &ExperimentDiff, bundle_id: &str) -> Vec<HypothesisEdge> {
let mut edges = Vec::new();
for effect in &diff.effects {
let kind = if effect.in_patched && !effect.in_baseline {
HypothesisEdgeKind::CausesRegression
} else if effect.in_baseline && !effect.in_patched {
HypothesisEdgeKind::FixesFailure
} else if effect.in_baseline && effect.in_patched {
HypothesisEdgeKind::AssociatedWithStableFailure
} else {
continue;
};
let status = match kind {
HypothesisEdgeKind::CausesRegression | HypothesisEdgeKind::FixesFailure => {
HypothesisStatus::Supported
}
HypothesisEdgeKind::AssociatedWithStableFailure => HypothesisStatus::Neutral,
};
let confidence = match kind {
HypothesisEdgeKind::CausesRegression | HypothesisEdgeKind::FixesFailure => {
local_hypothesis_support_confidence(1, 0)
}
HypothesisEdgeKind::AssociatedWithStableFailure => 0.0,
};
let edge_id = format!(
"edge-{}-{}",
bundle_id,
blake3::hash(effect.message.as_bytes())
.to_hex()
.to_string()
.get(..8)
.unwrap_or("00000000")
);
edges.push(HypothesisEdge {
edge_id,
source_edit: String::new(),
target_effect: serde_json::to_string(effect).unwrap_or_default(),
kind,
status,
confidence,
evidence_ids: vec![bundle_id.to_string()],
contradiction_ids: vec![],
verification_status: VerificationState::Unverified,
});
}
edges
}
pub fn generate_verification_plan(
bundle: &ExperimentEvidenceBundle,
edit_op_signatures: &[String],
policy: &VerificationPolicy,
limits: &ForgeLimits,
) -> VerificationPlan {
let plan_id = uuid::Uuid::new_v4().to_string();
let target_hypotheses = bundle
.hypotheses
.iter()
.map(|hypothesis| hypothesis.hypothesis_id.clone())
.collect();
let mut all_steps = Vec::new();
let mut dropped_steps = Vec::new();
if let Some(diff) = bundle.experiment_diff.as_ref() {
for effect in &diff.effects {
if effect.in_patched && !effect.in_baseline {
all_steps.push(VerificationStep {
verification_type: VerificationType::RunTest,
description: format!("Re-run test for new effect: {}", effect.message),
expected_outcome: "Effect reproduces consistently".to_string(),
requirement: StepRequirement::Required,
});
}
}
}
all_steps.push(VerificationStep {
verification_type: VerificationType::CompareBaseline,
description: "Verify baseline results are stable".to_string(),
expected_outcome: "Baseline unchanged".to_string(),
requirement: StepRequirement::Required,
});
all_steps.push(VerificationStep {
verification_type: VerificationType::CheckInvariant,
description: "Verify no invariant violations".to_string(),
expected_outcome: "Zero violations".to_string(),
requirement: StepRequirement::Required,
});
if bundle
.hypotheses
.iter()
.any(|hypothesis| hypothesis.confidence < 0.3)
{
all_steps.push(VerificationStep {
verification_type: VerificationType::ManualReview,
description: "Manual review for low-confidence hypotheses".to_string(),
expected_outcome: "Reviewer confirms or rejects".to_string(),
requirement: StepRequirement::Informational,
});
}
if policy.generate_ablation_for_mixed_effects {
if let Some(diff) = bundle.experiment_diff.as_ref() {
if diff.regressions > 0 && diff.improvements > 0 && !edit_op_signatures.is_empty() {
let max_ablations = policy
.max_ablation_combinations
.min(edit_op_signatures.len() as u32);
for (index, signature) in edit_op_signatures.iter().enumerate() {
if index >= max_ablations as usize {
break;
}
all_steps.push(VerificationStep {
verification_type: VerificationType::Ablation {
edit_op_signatures: vec![signature.clone()],
},
description: format!("Ablation: remove edit op {index} and re-run"),
expected_outcome: "Identify which edit caused each effect".to_string(),
requirement: StepRequirement::Informational,
});
}
}
}
}
if policy.generate_manual_review {
all_steps.push(VerificationStep {
verification_type: VerificationType::ManualReview,
description: "Manual review of patch and effects".to_string(),
expected_outcome: "Reviewer approves".to_string(),
requirement: StepRequirement::Informational,
});
}
let max_steps = limits.max_verification_steps;
let mut steps = Vec::new();
for step in all_steps {
if steps.len() >= max_steps {
dropped_steps.push(DroppedStep {
step,
reason: format!("exceeded max_verification_steps={max_steps}"),
});
} else {
steps.push(step);
}
}
VerificationPlan {
plan_id,
target_hypotheses,
budget: Some(PlanBudget {
max_steps,
estimated_duration_secs: steps.len() as u64 * 60,
}),
steps,
dropped_steps,
}
}
pub fn compute_assessment(
bundle: &ExperimentEvidenceBundle,
trial_count: u32,
isolated: bool,
min_trials: u32,
) -> EvidenceAssessment {
let reproducibility = if trial_count >= min_trials {
AssessmentCategory::Strong
} else if trial_count >= 2 {
AssessmentCategory::Adequate
} else if trial_count == 1 {
AssessmentCategory::Weak
} else {
AssessmentCategory::Insufficient
};
let isolation = if isolated {
AssessmentCategory::Strong
} else {
AssessmentCategory::Weak
};
let has_contradictions = bundle
.hypotheses
.iter()
.any(|hypothesis| hypothesis.contradiction_count > 0);
let contradiction_state = if bundle.hypotheses.is_empty() {
ContradictionState::Undetermined
} else if has_contradictions {
ContradictionState::HasContradictions
} else {
ContradictionState::Clean
};
let total_observations: u64 = bundle
.hypotheses
.iter()
.map(|hypothesis| hypothesis.support_count + hypothesis.contradiction_count)
.sum();
let sample_support = if total_observations >= 10 {
SampleSupport::Sufficient
} else if total_observations >= 3 {
SampleSupport::Marginal
} else {
SampleSupport::Insufficient
};
EvidenceAssessment {
reproducibility,
isolation,
contradiction_state,
sample_support,
}
}