Skip to main content

forge_engine/lab/
promote.rs

1use crate::config::ForgeConfig;
2use crate::error::{ForgeError, ForgeResult};
3use crate::lab::emitters::AlgebraSpec;
4use crate::lab::evaluate::ScoreVector;
5use crate::lab::evidence::{AssessmentCategory, ContradictionState, SampleSupport};
6use crate::store::db::EvalRunRow;
7use crate::store::ForgeStore;
8
9/// BasisVersion — a promoted, immutable algebra version.
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct BasisVersion {
12    pub version_id: String,
13    pub candidate_id: String,
14    pub frozen_spec: AlgebraSpec,
15    pub bounds: ParameterBounds,
16    pub checksum: String,
17    pub cea_fingerprint: Option<CausalFingerprint>,
18    pub promoted_at: String,
19}
20
21/// Locked min/max per parameter.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct ParameterBounds {
24    pub k_min: f64,
25    pub k_max: f64,
26    pub evidence_budget_min: usize,
27    pub evidence_budget_max: usize,
28    pub token_budget_min: usize,
29    pub token_budget_max: usize,
30}
31
32impl Default for ParameterBounds {
33    fn default() -> Self {
34        Self {
35            k_min: 1.0,
36            k_max: 20.0,
37            evidence_budget_min: 1,
38            evidence_budget_max: 20,
39            token_budget_min: 500,
40            token_budget_max: 4000,
41        }
42    }
43}
44
45/// Causal fingerprint — frozen CEA fingerprint for drift detection.
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47pub struct CausalFingerprint {
48    pub dominant_edge_hashes: Vec<String>,
49    pub checksum: String,
50}
51
52/// Graduation contract criteria.
53pub struct GraduationContract {
54    pub min_suite_pass_rate: f64,
55    pub min_weighted_improvement: f64,
56    pub max_stability_variance: f64,
57    pub max_invariant_violations: usize,
58    pub max_causal_drift: f64,
59}
60
61impl GraduationContract {
62    pub fn from_config(config: &ForgeConfig) -> Self {
63        Self {
64            min_suite_pass_rate: config.lab.promotion_min_suite_pass_rate,
65            min_weighted_improvement: config.lab.promotion_min_weighted_improvement,
66            max_stability_variance: config.lab.archive.stability_variance_threshold,
67            max_invariant_violations: 0,
68            max_causal_drift: config.cea.causal_drift_warning_threshold,
69        }
70    }
71}
72
73#[derive(Debug)]
74struct CandidatePromotionSnapshot {
75    average_suite_pass_rate: f64,
76    average_weighted_total: f64,
77    stability_variance: f64,
78    invariant_violations: usize,
79    cea_fingerprint: Option<CausalFingerprint>,
80}
81
82/// Promote a candidate to a BasisVersion.
83pub fn promote(
84    store: &ForgeStore,
85    candidate_id: &str,
86    config: &ForgeConfig,
87) -> ForgeResult<BasisVersion> {
88    let contract = GraduationContract::from_config(config);
89    let snapshot = candidate_promotion_snapshot(store, candidate_id, config)?;
90    enforce_graduation_contract(store, candidate_id, &contract, &snapshot, config)?;
91
92    // Get candidate spec
93    let spec_json = store.get_candidate_spec(candidate_id)?;
94    let spec: AlgebraSpec = serde_json::from_str(&spec_json)?;
95
96    // Compute checksum
97    let bounds = ParameterBounds::default();
98    let bounds_json = serde_json::to_string(&bounds)?;
99    let invariants_json = "{}"; // invariants are code-enforced, not data-enforced
100
101    let content = format!("{spec_json}{bounds_json}{invariants_json}");
102    let checksum = blake3::hash(content.as_bytes()).to_hex().to_string();
103
104    // Assign version_id
105    let count = store.count_promotions()?;
106    let version_id = format!("v{:04}", count + 1);
107
108    // Compute CEA fingerprint if CEA is enabled
109    let cea_fingerprint = snapshot.cea_fingerprint.clone();
110
111    let cea_fingerprint_json = cea_fingerprint
112        .as_ref()
113        .map(serde_json::to_string)
114        .transpose()?;
115
116    let now = chrono::Utc::now().to_rfc3339();
117
118    // Transaction: insert promotion + update candidate status atomically.
119    // If either step fails, neither takes effect.
120    store.with_transaction(|tx| {
121        tx.execute(
122            "INSERT INTO promotions (version_id, candidate_id, frozen_spec_json, bounds_json, invariants_json, checksum, cea_fingerprint_json, promoted_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
123            rusqlite::params![&version_id, candidate_id, &spec_json, &bounds_json, invariants_json, &checksum, cea_fingerprint_json.as_deref(), &now],
124        )?;
125
126        tx.execute(
127            "UPDATE candidates SET status = ?1 WHERE candidate_id = ?2",
128            rusqlite::params!["promoted", candidate_id],
129        )?;
130
131        Ok(())
132    })?;
133
134    Ok(BasisVersion {
135        version_id,
136        candidate_id: candidate_id.to_string(),
137        frozen_spec: spec,
138        bounds,
139        checksum,
140        cea_fingerprint,
141        promoted_at: now,
142    })
143}
144
145fn enforce_graduation_contract(
146    store: &ForgeStore,
147    candidate_id: &str,
148    contract: &GraduationContract,
149    snapshot: &CandidatePromotionSnapshot,
150    config: &ForgeConfig,
151) -> ForgeResult<()> {
152    if snapshot.average_suite_pass_rate < contract.min_suite_pass_rate {
153        return Err(ForgeError::PromotionFailed {
154            criterion: "suite_pass_rate".into(),
155            value: format!(
156                "{:.4} < {:.4}",
157                snapshot.average_suite_pass_rate, contract.min_suite_pass_rate
158            ),
159        });
160    }
161
162    let baseline_average = latest_baseline_weighted_total(store, candidate_id, config)?;
163    let weighted_improvement = snapshot.average_weighted_total - baseline_average;
164    if weighted_improvement < contract.min_weighted_improvement {
165        return Err(ForgeError::PromotionFailed {
166            criterion: "weighted_improvement".into(),
167            value: format!(
168                "{:.4} < {:.4} (candidate_avg={:.4}, baseline_avg={:.4})",
169                weighted_improvement,
170                contract.min_weighted_improvement,
171                snapshot.average_weighted_total,
172                baseline_average
173            ),
174        });
175    }
176
177    if snapshot.stability_variance > contract.max_stability_variance {
178        return Err(ForgeError::PromotionFailed {
179            criterion: "stability_variance".into(),
180            value: format!(
181                "{:.4} > {:.4}",
182                snapshot.stability_variance, contract.max_stability_variance
183            ),
184        });
185    }
186
187    if snapshot.invariant_violations > contract.max_invariant_violations {
188        return Err(ForgeError::PromotionFailed {
189            criterion: "invariant_violations".into(),
190            value: snapshot.invariant_violations.to_string(),
191        });
192    }
193
194    enforce_verification_evidence(store, candidate_id)?;
195
196    if config.cea.enabled {
197        enforce_causal_drift(store, contract, snapshot)?;
198    }
199
200    Ok(())
201}
202
203fn candidate_promotion_snapshot(
204    store: &ForgeStore,
205    candidate_id: &str,
206    config: &ForgeConfig,
207) -> ForgeResult<CandidatePromotionSnapshot> {
208    let runs = store.get_eval_runs_for_candidate(candidate_id)?;
209    if runs.is_empty() {
210        return Err(ForgeError::PromotionFailed {
211            criterion: "eval_runs".into(),
212            value: "0".into(),
213        });
214    }
215
216    let mut suite_sum = 0.0_f64;
217    let mut weighted_scores = Vec::with_capacity(runs.len());
218    let mut invariant_violations = 0usize;
219
220    for run in &runs {
221        let scores: ScoreVector = serde_json::from_str(&run.scores_json)?;
222        suite_sum += scores.correctness;
223        weighted_scores.push(scores.weighted_total);
224        invariant_violations += violation_count(run)?;
225    }
226
227    let average_suite_pass_rate = suite_sum / runs.len() as f64;
228    let average_weighted_total = weighted_scores.iter().sum::<f64>() / weighted_scores.len() as f64;
229    let stability_variance = variance(&weighted_scores);
230    let cea_fingerprint = if config.cea.enabled {
231        compute_causal_fingerprint(&runs)
232    } else {
233        None
234    };
235
236    Ok(CandidatePromotionSnapshot {
237        average_suite_pass_rate,
238        average_weighted_total,
239        stability_variance,
240        invariant_violations,
241        cea_fingerprint,
242    })
243}
244
245fn latest_baseline_weighted_total(
246    store: &ForgeStore,
247    candidate_id: &str,
248    config: &ForgeConfig,
249) -> ForgeResult<f64> {
250    let Some(baseline) = store.get_latest_promotion()? else {
251        return Ok(0.0);
252    };
253
254    if baseline.candidate_id == candidate_id {
255        return Ok(0.0);
256    }
257
258    let baseline_snapshot = candidate_promotion_snapshot(store, &baseline.candidate_id, config)?;
259    Ok(baseline_snapshot.average_weighted_total)
260}
261
262fn enforce_verification_evidence(store: &ForgeStore, candidate_id: &str) -> ForgeResult<()> {
263    let Some(bundle) = store.get_latest_evidence_bundle_for_candidate(candidate_id)? else {
264        return Err(ForgeError::PromotionFailed {
265            criterion: "verification_evidence".into(),
266            value: "missing".into(),
267        });
268    };
269
270    let bundle = bundle.local_bundle()?;
271
272    let Some(assessment) = bundle.assessment else {
273        return Err(ForgeError::PromotionFailed {
274            criterion: "verification_assessment".into(),
275            value: "missing".into(),
276        });
277    };
278
279    if !matches!(
280        assessment.reproducibility,
281        AssessmentCategory::Strong | AssessmentCategory::Adequate
282    ) {
283        return Err(ForgeError::PromotionFailed {
284            criterion: "reproducibility".into(),
285            value: format!("{:?}", assessment.reproducibility),
286        });
287    }
288
289    if assessment.isolation != AssessmentCategory::Strong {
290        return Err(ForgeError::PromotionFailed {
291            criterion: "isolation".into(),
292            value: format!("{:?}", assessment.isolation),
293        });
294    }
295
296    if assessment.contradiction_state != ContradictionState::Clean {
297        return Err(ForgeError::PromotionFailed {
298            criterion: "contradiction_state".into(),
299            value: format!("{:?}", assessment.contradiction_state),
300        });
301    }
302
303    if assessment.sample_support == SampleSupport::Insufficient {
304        return Err(ForgeError::PromotionFailed {
305            criterion: "sample_support".into(),
306            value: format!("{:?}", assessment.sample_support),
307        });
308    }
309
310    Ok(())
311}
312
313fn enforce_causal_drift(
314    store: &ForgeStore,
315    contract: &GraduationContract,
316    snapshot: &CandidatePromotionSnapshot,
317) -> ForgeResult<()> {
318    let Some(candidate) = snapshot.cea_fingerprint.as_ref() else {
319        return Ok(());
320    };
321    let Some(baseline) = store.get_latest_promotion()? else {
322        return Ok(());
323    };
324    let Some(baseline_json) = baseline.cea_fingerprint_json.as_deref() else {
325        return Ok(());
326    };
327    let baseline_fingerprint: CausalFingerprint = serde_json::from_str(baseline_json)?;
328    let drift = causal_drift(candidate, &baseline_fingerprint);
329    if drift > contract.max_causal_drift {
330        return Err(ForgeError::PromotionFailed {
331            criterion: "causal_drift".into(),
332            value: format!("{:.4} > {:.4}", drift, contract.max_causal_drift),
333        });
334    }
335    Ok(())
336}
337
338fn compute_causal_fingerprint(runs: &[EvalRunRow]) -> Option<CausalFingerprint> {
339    let mut edge_hashes: Vec<String> = runs.iter().filter_map(|r| r.cea_run_hash.clone()).collect();
340    if edge_hashes.is_empty() {
341        return None;
342    }
343
344    edge_hashes.sort();
345    edge_hashes.truncate(20);
346
347    let fp_content = edge_hashes.join(",");
348    let fp_checksum = blake3::hash(fp_content.as_bytes()).to_hex().to_string();
349
350    Some(CausalFingerprint {
351        dominant_edge_hashes: edge_hashes,
352        checksum: fp_checksum,
353    })
354}
355
356fn causal_drift(candidate: &CausalFingerprint, baseline: &CausalFingerprint) -> f64 {
357    let candidate: std::collections::BTreeSet<_> = candidate
358        .dominant_edge_hashes
359        .iter()
360        .map(String::as_str)
361        .collect();
362    let baseline: std::collections::BTreeSet<_> = baseline
363        .dominant_edge_hashes
364        .iter()
365        .map(String::as_str)
366        .collect();
367
368    if candidate.is_empty() && baseline.is_empty() {
369        return 0.0;
370    }
371
372    let intersection = candidate.intersection(&baseline).count() as f64;
373    let denominator = candidate.len().max(baseline.len()) as f64;
374    if denominator == 0.0 {
375        0.0
376    } else {
377        1.0 - (intersection / denominator)
378    }
379}
380
381fn violation_count(run: &EvalRunRow) -> ForgeResult<usize> {
382    let value: serde_json::Value = serde_json::from_str(&run.violations_json)?;
383    Ok(value.as_array().map_or(0, Vec::len))
384}
385
386fn variance(values: &[f64]) -> f64 {
387    if values.len() <= 1 {
388        return 0.0;
389    }
390
391    let mean = values.iter().sum::<f64>() / values.len() as f64;
392    values
393        .iter()
394        .map(|value| {
395            let delta = value - mean;
396            delta * delta
397        })
398        .sum::<f64>()
399        / values.len() as f64
400}