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#[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#[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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47pub struct CausalFingerprint {
48 pub dominant_edge_hashes: Vec<String>,
49 pub checksum: String,
50}
51
52pub 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
82pub 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 let spec_json = store.get_candidate_spec(candidate_id)?;
94 let spec: AlgebraSpec = serde_json::from_str(&spec_json)?;
95
96 let bounds = ParameterBounds::default();
98 let bounds_json = serde_json::to_string(&bounds)?;
99 let invariants_json = "{}"; let content = format!("{spec_json}{bounds_json}{invariants_json}");
102 let checksum = blake3::hash(content.as_bytes()).to_hex().to_string();
103
104 let count = store.count_promotions()?;
106 let version_id = format!("v{:04}", count + 1);
107
108 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 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}