1use std::path::{Path, PathBuf};
2use std::sync::Mutex;
3use std::time::Instant;
4
5use rusqlite::{Connection, OptionalExtension};
6
7use crate::error::{ForgeError, ForgeResult};
8use crate::invariants;
9use crate::lab::evaluate::ScoreVector;
10use crate::lab::evidence::{
11 CausalHypothesis, ClaimStrength, EvidenceAssessment, ExperimentEvidenceBundle, VerificationPlan,
12};
13use crate::store::schema;
14
15const FORGE_LOCK_WARN_THRESHOLD_MS: u128 = 50;
16
17fn stats_from_persisted(alpha: f64, beta: f64, count: i64) -> cea_core::EdgeStats {
18 cea_core::EdgeStats {
19 alpha,
20 beta,
21 observations: count.max(0) as u64,
22 }
23}
24
25fn positive_stats(weight_delta: f64) -> cea_core::EdgeStats {
26 let mut stats = cea_core::EdgeStats::default();
27 stats.observe_positive(weight_delta);
28 stats
29}
30
31pub struct ForgeStore {
35 conn: Mutex<Connection>,
36 path: PathBuf,
37}
38
39impl std::fmt::Debug for ForgeStore {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.debug_struct("ForgeStore")
43 .field("path", &self.path)
44 .finish()
45 }
46}
47
48impl ForgeStore {
49 pub fn open(path: &Path) -> ForgeResult<Self> {
54 invariants::refuse_to_open_db(path)?;
56
57 let is_new = !path.exists();
58
59 let conn = Connection::open(path)?;
60
61 conn.pragma_update(None, "journal_mode", "wal")?;
63 conn.pragma_update(None, "busy_timeout", "5000")?;
65
66 if is_new {
67 Self::run_initial_migration(&conn)?;
68 }
69
70 Self::run_migration_v2(&conn)?;
72 Self::run_migration_v3(&conn)?;
73 Self::run_migration_v4(&conn)?;
74 Self::run_migration_v5(&conn)?;
75
76 Ok(Self {
77 conn: Mutex::new(conn),
78 path: path.to_path_buf(),
79 })
80 }
81
82 fn run_initial_migration(conn: &Connection) -> ForgeResult<()> {
87 conn.execute_batch(&format!(
88 "PRAGMA user_version = {user_version};",
89 user_version = schema::FORGE_CURRENT_USER_VERSION,
90 ))?;
91
92 for stmt in schema::CREATE_STATEMENTS {
95 conn.execute(stmt, [])?;
96 }
97
98 conn.execute_batch(&format!(
100 r#"
101INSERT INTO forge_meta VALUES ('schema_hash', '{schema_hash}');
102INSERT INTO forge_meta VALUES ('schema_version', '1');
103INSERT INTO forge_meta VALUES ('created_at', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));
104"#,
105 schema_hash = schema::forge_schema_hash(),
106 ))?;
107
108 Ok(())
109 }
110
111 fn run_migration_v2(conn: &Connection) -> ForgeResult<()> {
116 for stmt in schema::MIGRATION_V2_STATEMENTS {
117 conn.execute(stmt, [])?;
118 }
119
120 let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
122 if current < schema::FORGE_V2_USER_VERSION {
123 conn.pragma_update(None, "user_version", schema::FORGE_V2_USER_VERSION)?;
124 }
125
126 Ok(())
127 }
128
129 fn run_migration_v3(conn: &Connection) -> ForgeResult<()> {
130 for stmt in schema::MIGRATION_V3_STATEMENTS {
131 conn.execute(stmt, [])?;
132 }
133
134 let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
135 if current < schema::FORGE_V3_USER_VERSION {
136 conn.pragma_update(None, "user_version", schema::FORGE_V3_USER_VERSION)?;
137 }
138
139 Ok(())
140 }
141
142 fn run_migration_v4(conn: &Connection) -> ForgeResult<()> {
143 let has_alpha = conn
144 .query_row(
145 "SELECT 1 FROM pragma_table_info('cea_edges') WHERE name = 'alpha' LIMIT 1",
146 [],
147 |_| Ok(()),
148 )
149 .optional()?
150 .is_some();
151 let has_beta = conn
152 .query_row(
153 "SELECT 1 FROM pragma_table_info('cea_edges') WHERE name = 'beta' LIMIT 1",
154 [],
155 |_| Ok(()),
156 )
157 .optional()?
158 .is_some();
159
160 if !has_alpha {
161 conn.execute(
162 "ALTER TABLE cea_edges ADD COLUMN alpha REAL NOT NULL DEFAULT 1.0",
163 [],
164 )?;
165 }
166 if !has_beta {
167 conn.execute(
168 "ALTER TABLE cea_edges ADD COLUMN beta REAL NOT NULL DEFAULT 1.0",
169 [],
170 )?;
171 }
172
173 let mut stmt = conn.prepare("SELECT edge_id, weight, count, alpha, beta FROM cea_edges")?;
174 let rows = stmt.query_map([], |row| {
175 Ok((
176 row.get::<_, String>(0)?,
177 row.get::<_, f64>(1)?,
178 row.get::<_, i64>(2)?,
179 row.get::<_, f64>(3)?,
180 row.get::<_, f64>(4)?,
181 ))
182 })?;
183
184 for row in rows {
185 let (edge_id, weight, count, alpha, beta) = row?;
186 let stats = if has_alpha && has_beta {
187 stats_from_persisted(alpha, beta, count)
188 } else {
189 cea_core::EdgeStats {
190 alpha: 1.0 + weight.max(0.0),
191 beta: 1.0,
192 observations: count.max(0) as u64,
193 }
194 };
195 conn.execute(
196 "UPDATE cea_edges SET alpha = ?1, beta = ?2, confidence = ?3 WHERE edge_id = ?4",
197 rusqlite::params![stats.alpha, stats.beta, stats.confidence(), edge_id],
198 )?;
199 }
200
201 let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
202 if current < schema::FORGE_V4_USER_VERSION {
203 conn.pragma_update(None, "user_version", schema::FORGE_V4_USER_VERSION)?;
204 }
205
206 Ok(())
207 }
208
209 fn run_migration_v5(conn: &Connection) -> ForgeResult<()> {
210 let has_canonical_bundle_json = conn
211 .query_row(
212 "SELECT 1 FROM pragma_table_info('evidence_bundles') WHERE name = 'canonical_bundle_json' LIMIT 1",
213 [],
214 |_| Ok(()),
215 )
216 .optional()?
217 .is_some();
218
219 if !has_canonical_bundle_json {
220 conn.execute(
221 "ALTER TABLE evidence_bundles ADD COLUMN canonical_bundle_json TEXT",
222 [],
223 )?;
224 }
225
226 let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
227 if current < schema::FORGE_V5_USER_VERSION {
228 conn.pragma_update(None, "user_version", schema::FORGE_V5_USER_VERSION)?;
229 }
230
231 Ok(())
232 }
233
234 fn lock_conn(&self) -> ForgeResult<std::sync::MutexGuard<'_, Connection>> {
236 let started_at = Instant::now();
237 let guard = self
238 .conn
239 .lock()
240 .map_err(|e| ForgeError::Other(format!("ForgeStore lock poisoned: {e}")))?;
241 let waited_ms = started_at.elapsed().as_millis();
242 if waited_ms >= FORGE_LOCK_WARN_THRESHOLD_MS {
243 tracing::warn!(
244 path = %self.path.display(),
245 waited_ms,
246 threshold_ms = FORGE_LOCK_WARN_THRESHOLD_MS,
247 "ForgeStore connection lock acquisition exceeded threshold"
248 );
249 }
250 Ok(guard)
251 }
252
253 pub fn with_transaction<F, T>(&self, f: F) -> ForgeResult<T>
255 where
256 F: FnOnce(&Connection) -> ForgeResult<T>,
257 {
258 let mut conn = self.lock_conn()?;
259 let tx = conn.transaction().map_err(ForgeError::Database)?;
260 let result = f(&tx)?;
261 tx.commit().map_err(ForgeError::Database)?;
262 Ok(result)
263 }
264
265 pub fn with_transaction_conn<F, T>(&self, f: F) -> ForgeResult<T>
268 where
269 F: for<'tx> FnOnce(&rusqlite::Transaction<'tx>) -> ForgeResult<T>,
270 {
271 let mut conn = self.lock_conn()?;
272 let tx = conn.transaction()?;
273 let result = f(&tx)?;
274 tx.commit().map_err(ForgeError::Database)?;
275 Ok(result)
276 }
277
278 pub fn with_conn<F, T>(&self, f: F) -> ForgeResult<T>
280 where
281 F: FnOnce(&Connection) -> ForgeResult<T>,
282 {
283 let conn = self.lock_conn()?;
284 f(&conn)
285 }
286
287 pub fn path(&self) -> &Path {
289 &self.path
290 }
291
292 pub fn insert_candidate(
295 &self,
296 candidate_id: &str,
297 spec_json: &str,
298 parents_json: &str,
299 status: &str,
300 ) -> ForgeResult<()> {
301 let now = chrono::Utc::now().to_rfc3339();
302 self.lock_conn()?.execute(
303 "INSERT INTO candidates (candidate_id, spec_json, parents_json, created_at, status) VALUES (?1, ?2, ?3, ?4, ?5)",
304 rusqlite::params![candidate_id, spec_json, parents_json, now, status],
305 )?;
306 Ok(())
307 }
308
309 pub fn get_candidate_spec(&self, candidate_id: &str) -> ForgeResult<String> {
310 self.lock_conn()?
311 .query_row(
312 "SELECT spec_json FROM candidates WHERE candidate_id = ?1",
313 [candidate_id],
314 |row| row.get(0),
315 )
316 .map_err(|_| ForgeError::NotFound(format!("candidate {candidate_id}")))
317 }
318
319 pub fn update_candidate_status(&self, candidate_id: &str, status: &str) -> ForgeResult<()> {
320 self.lock_conn()?.execute(
321 "UPDATE candidates SET status = ?1 WHERE candidate_id = ?2",
322 rusqlite::params![status, candidate_id],
323 )?;
324 Ok(())
325 }
326
327 pub fn insert_task(
330 &self,
331 task_id: &str,
332 suite_name: &str,
333 fixture_ref: &str,
334 prompt: &str,
335 constraints_json: &str,
336 weights_json: &str,
337 ) -> ForgeResult<()> {
338 let now = chrono::Utc::now().to_rfc3339();
339 self.lock_conn()?.execute(
340 "INSERT INTO tasks (task_id, suite_name, fixture_ref, prompt, constraints_json, weights_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
341 rusqlite::params![task_id, suite_name, fixture_ref, prompt, constraints_json, weights_json, now],
342 )?;
343 Ok(())
344 }
345
346 #[allow(clippy::too_many_arguments)]
349 pub fn insert_eval_run(
350 &self,
351 eval_id: &str,
352 candidate_id: &str,
353 task_id: &str,
354 backend: &str,
355 seed: i64,
356 mindstate_hash: &str,
357 patch_hash: &str,
358 structural_sig: &str,
359 scores_json: &str,
360 violations_json: &str,
361 logs_ref: &str,
362 cea_run_hash: Option<&str>,
363 ) -> ForgeResult<()> {
364 let now = chrono::Utc::now().to_rfc3339();
365 self.lock_conn()?.execute(
366 "INSERT INTO eval_runs (eval_id, candidate_id, task_id, backend, seed, mindstate_hash, patch_hash, structural_sig, scores_json, violations_json, logs_ref, cea_run_hash, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
367 rusqlite::params![eval_id, candidate_id, task_id, backend, seed, mindstate_hash, patch_hash, structural_sig, scores_json, violations_json, logs_ref, cea_run_hash, now],
368 )?;
369 Ok(())
370 }
371
372 pub fn get_eval_runs_for_candidate(&self, candidate_id: &str) -> ForgeResult<Vec<EvalRunRow>> {
373 let conn = self.lock_conn()?;
374 let mut stmt = conn.prepare(
375 "SELECT eval_id, candidate_id, task_id, backend, seed, mindstate_hash, patch_hash, structural_sig, scores_json, violations_json, logs_ref, cea_run_hash, created_at FROM eval_runs WHERE candidate_id = ?1"
376 )?;
377 let rows = stmt.query_map([candidate_id], |row| {
378 Ok(EvalRunRow {
379 eval_id: row.get(0)?,
380 candidate_id: row.get(1)?,
381 task_id: row.get(2)?,
382 backend: row.get(3)?,
383 seed: row.get(4)?,
384 mindstate_hash: row.get(5)?,
385 patch_hash: row.get(6)?,
386 structural_sig: row.get(7)?,
387 scores_json: row.get(8)?,
388 violations_json: row.get(9)?,
389 logs_ref: row.get(10)?,
390 cea_run_hash: row.get(11)?,
391 created_at: row.get(12)?,
392 })
393 })?;
394 let mut result = Vec::new();
395 for row in rows {
396 result.push(row?);
397 }
398 Ok(result)
399 }
400
401 pub fn upsert_archive_cell(
404 &self,
405 cell_key: &str,
406 candidate_id: &str,
407 score_summary_json: &str,
408 cea_fingerprint: Option<&str>,
409 ) -> ForgeResult<()> {
410 let now = chrono::Utc::now().to_rfc3339();
411 self.lock_conn()?.execute(
412 "INSERT INTO archive_cells (cell_key, candidate_id, score_summary_json, cea_fingerprint, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(cell_key) DO UPDATE SET candidate_id = ?2, score_summary_json = ?3, cea_fingerprint = ?4, updated_at = ?5",
413 rusqlite::params![cell_key, candidate_id, score_summary_json, cea_fingerprint, now],
414 )?;
415 Ok(())
416 }
417
418 pub fn get_archive_cell(&self, cell_key: &str) -> ForgeResult<Option<ArchiveCellRow>> {
419 match self.lock_conn()?.query_row(
420 "SELECT cell_key, candidate_id, score_summary_json, cea_fingerprint, updated_at FROM archive_cells WHERE cell_key = ?1",
421 [cell_key],
422 |row| {
423 Ok(ArchiveCellRow {
424 cell_key: row.get(0)?,
425 candidate_id: row.get(1)?,
426 score_summary_json: row.get(2)?,
427 cea_fingerprint: row.get(3)?,
428 updated_at: row.get(4)?,
429 })
430 },
431 ) {
432 Ok(r) => Ok(Some(r)),
433 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
434 Err(e) => Err(e.into()),
435 }
436 }
437
438 #[allow(clippy::too_many_arguments)]
441 pub fn insert_promotion(
442 &self,
443 version_id: &str,
444 candidate_id: &str,
445 frozen_spec_json: &str,
446 bounds_json: &str,
447 invariants_json: &str,
448 checksum: &str,
449 cea_fingerprint_json: Option<&str>,
450 ) -> ForgeResult<()> {
451 let now = chrono::Utc::now().to_rfc3339();
452 self.lock_conn()?.execute(
453 "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)",
454 rusqlite::params![version_id, candidate_id, frozen_spec_json, bounds_json, invariants_json, checksum, cea_fingerprint_json, now],
455 )?;
456 Ok(())
457 }
458
459 pub fn get_latest_promotion(&self) -> ForgeResult<Option<PromotionRow>> {
460 match self.lock_conn()?.query_row(
461 "SELECT version_id, candidate_id, frozen_spec_json, bounds_json, invariants_json, checksum, cea_fingerprint_json, promoted_at FROM promotions ORDER BY version_id DESC LIMIT 1",
462 [],
463 |row| {
464 Ok(PromotionRow {
465 version_id: row.get(0)?,
466 candidate_id: row.get(1)?,
467 frozen_spec_json: row.get(2)?,
468 bounds_json: row.get(3)?,
469 invariants_json: row.get(4)?,
470 checksum: row.get(5)?,
471 cea_fingerprint_json: row.get(6)?,
472 promoted_at: row.get(7)?,
473 })
474 },
475 ) {
476 Ok(r) => Ok(Some(r)),
477 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
478 Err(e) => Err(e.into()),
479 }
480 }
481
482 pub fn count_promotions(&self) -> ForgeResult<usize> {
483 let count: i64 =
484 self.lock_conn()?
485 .query_row("SELECT COUNT(*) FROM promotions", [], |row| row.get(0))?;
486 Ok(count as usize)
487 }
488
489 #[allow(clippy::too_many_arguments)]
492 pub fn insert_answer_trace(
493 &self,
494 trace_id: &str,
495 question_sig: &str,
496 version_id: &str,
497 strategy_tags_json: &str,
498 patch_hash: &str,
499 structural_sig: &str,
500 score_json: &str,
501 ) -> ForgeResult<()> {
502 let now = chrono::Utc::now().to_rfc3339();
503 self.lock_conn()?.execute(
504 "INSERT INTO answer_traces (trace_id, question_sig, version_id, strategy_tags_json, patch_hash, structural_sig, score_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
505 rusqlite::params![trace_id, question_sig, version_id, strategy_tags_json, patch_hash, structural_sig, score_json, now],
506 )?;
507 Ok(())
508 }
509
510 pub fn get_recent_traces_for_question(
511 &self,
512 question_sig: &str,
513 limit: usize,
514 ) -> ForgeResult<Vec<AnswerTraceRow>> {
515 let conn = self.lock_conn()?;
516 let mut stmt = conn.prepare(
517 "SELECT trace_id, question_sig, version_id, strategy_tags_json, patch_hash, structural_sig, score_json, created_at FROM answer_traces WHERE question_sig = ?1 ORDER BY created_at DESC LIMIT ?2",
518 )?;
519 let rows = stmt.query_map(rusqlite::params![question_sig, limit as i64], |row| {
520 Ok(AnswerTraceRow {
521 trace_id: row.get(0)?,
522 question_sig: row.get(1)?,
523 version_id: row.get(2)?,
524 strategy_tags_json: row.get(3)?,
525 patch_hash: row.get(4)?,
526 structural_sig: row.get(5)?,
527 score_json: row.get(6)?,
528 created_at: row.get(7)?,
529 })
530 })?;
531 let mut result = Vec::new();
532 for row in rows {
533 result.push(row?);
534 }
535 Ok(result)
536 }
537
538 pub fn upsert_cea_node(
541 &self,
542 node_id: &str,
543 node_kind: &str,
544 sig_json: &str,
545 ) -> ForgeResult<()> {
546 let now = chrono::Utc::now().to_rfc3339();
547 self.lock_conn()?.execute(
548 "INSERT INTO cea_nodes (node_id, node_kind, sig_json, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?4) ON CONFLICT(node_id) DO UPDATE SET last_seen = ?4",
549 rusqlite::params![node_id, node_kind, sig_json, now],
550 )?;
551 Ok(())
552 }
553
554 pub fn upsert_cea_edge(
555 &self,
556 edge_id: &str,
557 cause_node_id: &str,
558 effect_node_id: &str,
559 weight_delta: f64,
560 version_id: &str,
561 ) -> ForgeResult<bool> {
562 let now = chrono::Utc::now().to_rfc3339();
563 let conn = self.lock_conn()?;
564 let weight_delta = weight_delta.max(0.0);
565 let existing = conn
566 .query_row(
567 "SELECT weight, count, alpha, beta FROM cea_edges WHERE cause_node_id = ?1 AND effect_node_id = ?2 AND version_id = ?3",
568 rusqlite::params![cause_node_id, effect_node_id, version_id],
569 |row| {
570 Ok((
571 row.get::<_, f64>(0)?,
572 row.get::<_, i64>(1)?,
573 row.get::<_, f64>(2)?,
574 row.get::<_, f64>(3)?,
575 ))
576 },
577 )
578 .optional()?;
579 if let Some((weight, count, alpha, beta)) = existing {
580 let mut stats = stats_from_persisted(alpha, beta, count);
581 stats.observe_positive(weight_delta);
582 conn.execute(
583 "UPDATE cea_edges SET weight = ?1, count = ?2, confidence = ?3, alpha = ?4, beta = ?5, last_seen = ?6 WHERE cause_node_id = ?7 AND effect_node_id = ?8 AND version_id = ?9",
584 rusqlite::params![
585 weight + weight_delta,
586 stats.observations as i64,
587 stats.confidence(),
588 stats.alpha,
589 stats.beta,
590 now,
591 cause_node_id,
592 effect_node_id,
593 version_id
594 ],
595 )?;
596 return Ok(false);
597 }
598 let stats = positive_stats(weight_delta);
599 conn.execute(
600 "INSERT INTO cea_edges (edge_id, cause_node_id, effect_node_id, weight, count, confidence, alpha, beta, version_id, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
601 rusqlite::params![
602 edge_id,
603 cause_node_id,
604 effect_node_id,
605 weight_delta,
606 stats.observations as i64,
607 stats.confidence(),
608 stats.alpha,
609 stats.beta,
610 version_id,
611 now
612 ],
613 )?;
614 Ok(true)
615 }
616
617 pub fn check_cea_run_log(&self, run_hash: &str) -> ForgeResult<bool> {
618 let count: i64 = self.lock_conn()?.query_row(
619 "SELECT COUNT(*) FROM cea_run_log WHERE run_hash = ?1",
620 [run_hash],
621 |row| row.get(0),
622 )?;
623 Ok(count > 0)
624 }
625
626 pub fn insert_cea_run_log(
627 &self,
628 run_hash: &str,
629 eval_id: &str,
630 edges_added: i64,
631 edges_updated: i64,
632 ) -> ForgeResult<()> {
633 let now = chrono::Utc::now().to_rfc3339();
634 self.lock_conn()?.execute(
635 "INSERT INTO cea_run_log (run_hash, eval_id, edges_added, edges_updated, processed_at) VALUES (?1, ?2, ?3, ?4, ?5)",
636 rusqlite::params![run_hash, eval_id, edges_added, edges_updated, now],
637 )?;
638 Ok(())
639 }
640
641 pub fn get_cea_edges_for_cause(&self, cause_node_id: &str) -> ForgeResult<Vec<CeaEdgeRow>> {
642 let conn = self.lock_conn()?;
643 let mut stmt = conn.prepare(
644 "SELECT edge_id, cause_node_id, effect_node_id, weight, count, confidence, alpha, beta, version_id, last_seen FROM cea_edges WHERE cause_node_id = ?1",
645 )?;
646 let rows = stmt.query_map([cause_node_id], |row| {
647 let count: i64 = row.get(4)?;
648 let alpha: f64 = row.get(6)?;
649 let beta: f64 = row.get(7)?;
650 let stats = stats_from_persisted(alpha, beta, count);
651 Ok(CeaEdgeRow {
652 edge_id: row.get(0)?,
653 cause_node_id: row.get(1)?,
654 effect_node_id: row.get(2)?,
655 weight: row.get(3)?,
656 alpha,
657 beta,
658 count,
659 confidence: stats.confidence(),
660 version_id: row.get(8)?,
661 last_seen: row.get(9)?,
662 })
663 })?;
664 let mut result = Vec::new();
665 for row in rows {
666 result.push(row?);
667 }
668 Ok(result)
669 }
670
671 pub fn get_all_cea_nodes_and_edges(
673 &self,
674 version_id: Option<&str>,
675 ) -> ForgeResult<(Vec<CeaNodeRow>, Vec<CeaEdgeRow>)> {
676 let conn = self.lock_conn()?;
677
678 let mut node_stmt = conn.prepare("SELECT node_id, node_kind, sig_json FROM cea_nodes")?;
680 let node_rows = node_stmt.query_map([], |row| {
681 Ok(CeaNodeRow {
682 node_id: row.get(0)?,
683 node_kind: row.get(1)?,
684 sig_json: row.get(2)?,
685 })
686 })?;
687 let mut nodes = Vec::new();
688 for row in node_rows {
689 nodes.push(row?);
690 }
691
692 let edges = if let Some(vid) = version_id {
694 let mut edge_stmt = conn.prepare(
695 "SELECT edge_id, cause_node_id, effect_node_id, weight, count, confidence, alpha, beta, version_id, last_seen FROM cea_edges WHERE version_id = ?1",
696 )?;
697 let edge_rows = edge_stmt.query_map([vid], |row| {
698 let count: i64 = row.get(4)?;
699 let alpha: f64 = row.get(6)?;
700 let beta: f64 = row.get(7)?;
701 let stats = stats_from_persisted(alpha, beta, count);
702 Ok(CeaEdgeRow {
703 edge_id: row.get(0)?,
704 cause_node_id: row.get(1)?,
705 effect_node_id: row.get(2)?,
706 weight: row.get(3)?,
707 alpha,
708 beta,
709 count,
710 confidence: stats.confidence(),
711 version_id: row.get(8)?,
712 last_seen: row.get(9)?,
713 })
714 })?;
715 let mut result = Vec::new();
716 for row in edge_rows {
717 result.push(row?);
718 }
719 result
720 } else {
721 let mut edge_stmt = conn.prepare(
722 "SELECT edge_id, cause_node_id, effect_node_id, weight, count, confidence, alpha, beta, version_id, last_seen FROM cea_edges",
723 )?;
724 let edge_rows = edge_stmt.query_map([], |row| {
725 let count: i64 = row.get(4)?;
726 let alpha: f64 = row.get(6)?;
727 let beta: f64 = row.get(7)?;
728 let stats = stats_from_persisted(alpha, beta, count);
729 Ok(CeaEdgeRow {
730 edge_id: row.get(0)?,
731 cause_node_id: row.get(1)?,
732 effect_node_id: row.get(2)?,
733 weight: row.get(3)?,
734 alpha,
735 beta,
736 count,
737 confidence: stats.confidence(),
738 version_id: row.get(8)?,
739 last_seen: row.get(9)?,
740 })
741 })?;
742 let mut result = Vec::new();
743 for row in edge_rows {
744 result.push(row?);
745 }
746 result
747 };
748
749 Ok((nodes, edges))
750 }
751
752 pub fn get_cea_node_sig(&self, node_id: &str) -> ForgeResult<Option<String>> {
753 match self.lock_conn()?.query_row(
754 "SELECT sig_json FROM cea_nodes WHERE node_id = ?1",
755 [node_id],
756 |row| row.get(0),
757 ) {
758 Ok(s) => Ok(Some(s)),
759 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
760 Err(e) => Err(e.into()),
761 }
762 }
763
764 #[allow(clippy::too_many_arguments)]
767 pub fn insert_evidence_bundle(
768 &self,
769 bundle_id: &str,
770 candidate_id: &str,
771 eval_id: &str,
772 version_id: &str,
773 trace_id: &str,
774 scores_json: &str,
775 hypotheses_json: &str,
776 verification_plan_json: Option<&str>,
777 diff_json: Option<&str>,
778 assessment_json: Option<&str>,
779 warnings_json: &str,
780 ) -> ForgeResult<()> {
781 let now = chrono::Utc::now().to_rfc3339();
782 let canonical_bundle_json = canonical_bundle_json_from_legacy_parts(
783 bundle_id,
784 candidate_id,
785 eval_id,
786 version_id,
787 trace_id,
788 scores_json,
789 hypotheses_json,
790 verification_plan_json,
791 diff_json,
792 assessment_json,
793 warnings_json,
794 &now,
795 )?;
796 self.lock_conn()?.execute(
797 "INSERT OR REPLACE INTO evidence_bundles (bundle_id, candidate_id, eval_id, version_id, trace_id, canonical_bundle_json, scores_json, hypotheses_json, verification_plan_json, diff_json, assessment_json, warnings_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
798 rusqlite::params![bundle_id, candidate_id, eval_id, version_id, trace_id, canonical_bundle_json, scores_json, hypotheses_json, verification_plan_json, diff_json, assessment_json, warnings_json, now],
799 )?;
800 Ok(())
801 }
802
803 pub fn get_evidence_bundle(&self, bundle_id: &str) -> ForgeResult<Option<EvidenceBundleRow>> {
804 match self.lock_conn()?.query_row(
805 "SELECT bundle_id, candidate_id, eval_id, version_id, trace_id, canonical_bundle_json, scores_json, hypotheses_json, verification_plan_json, diff_json, assessment_json, warnings_json, created_at FROM evidence_bundles WHERE bundle_id = ?1",
806 [bundle_id],
807 |row| {
808 Ok(EvidenceBundleRow {
809 bundle_id: row.get(0)?,
810 candidate_id: row.get(1)?,
811 eval_id: row.get(2)?,
812 version_id: row.get(3)?,
813 trace_id: row.get(4)?,
814 canonical_bundle_json: row.get(5)?,
815 scores_json: row.get(6)?,
816 hypotheses_json: row.get(7)?,
817 verification_plan_json: row.get(8)?,
818 diff_json: row.get(9)?,
819 assessment_json: row.get(10)?,
820 warnings_json: row.get(11)?,
821 created_at: row.get(12)?,
822 })
823 },
824 ) {
825 Ok(r) => Ok(Some(r)),
826 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
827 Err(e) => Err(e.into()),
828 }
829 }
830
831 pub fn get_latest_evidence_bundle_for_candidate(
832 &self,
833 candidate_id: &str,
834 ) -> ForgeResult<Option<EvidenceBundleRow>> {
835 match self.lock_conn()?.query_row(
836 "SELECT bundle_id, candidate_id, eval_id, version_id, trace_id, canonical_bundle_json, scores_json, hypotheses_json, verification_plan_json, diff_json, assessment_json, warnings_json, created_at
837 FROM evidence_bundles
838 WHERE candidate_id = ?1
839 ORDER BY created_at DESC, bundle_id DESC
840 LIMIT 1",
841 [candidate_id],
842 |row| {
843 Ok(EvidenceBundleRow {
844 bundle_id: row.get(0)?,
845 candidate_id: row.get(1)?,
846 eval_id: row.get(2)?,
847 version_id: row.get(3)?,
848 trace_id: row.get(4)?,
849 canonical_bundle_json: row.get(5)?,
850 scores_json: row.get(6)?,
851 hypotheses_json: row.get(7)?,
852 verification_plan_json: row.get(8)?,
853 diff_json: row.get(9)?,
854 assessment_json: row.get(10)?,
855 warnings_json: row.get(11)?,
856 created_at: row.get(12)?,
857 })
858 },
859 ) {
860 Ok(r) => Ok(Some(r)),
861 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
862 Err(e) => Err(e.into()),
863 }
864 }
865
866 pub fn count_evidence_bundles_for_candidate(&self, candidate_id: &str) -> ForgeResult<usize> {
867 let count: i64 = self.lock_conn()?.query_row(
868 "SELECT COUNT(*) FROM evidence_bundles WHERE candidate_id = ?1",
869 [candidate_id],
870 |row| row.get(0),
871 )?;
872 Ok(count as usize)
873 }
874
875 pub fn count_evidence_bundles(&self) -> ForgeResult<usize> {
876 let count: i64 =
877 self.lock_conn()?
878 .query_row("SELECT COUNT(*) FROM evidence_bundles", [], |row| {
879 row.get(0)
880 })?;
881 Ok(count as usize)
882 }
883
884 pub fn list_recent_evidence_bundle_ids(&self, limit: usize) -> ForgeResult<Vec<String>> {
885 let conn = self.lock_conn()?;
886 let mut stmt = conn.prepare(
887 "SELECT bundle_id
888 FROM evidence_bundles
889 ORDER BY created_at DESC, bundle_id DESC
890 LIMIT ?1",
891 )?;
892 let rows = stmt.query_map([limit as i64], |row| row.get::<_, String>(0))?;
893 let mut result = Vec::new();
894 for row in rows {
895 result.push(row?);
896 }
897 Ok(result)
898 }
899
900 #[allow(clippy::too_many_arguments)]
903 pub fn insert_experiment_run(
904 &self,
905 run_id: &str,
906 candidate_id: &str,
907 task_id: &str,
908 trace_id: &str,
909 mode: &str,
910 baseline_json: &str,
911 patched_json: &str,
912 diff_json: &str,
913 baseline_descriptor_json: &str,
914 trials_json: &str,
915 ) -> ForgeResult<()> {
916 let now = chrono::Utc::now().to_rfc3339();
917 self.lock_conn()?.execute(
918 "INSERT INTO experiment_runs (run_id, candidate_id, task_id, trace_id, mode, baseline_json, patched_json, diff_json, baseline_descriptor_json, trials_json, completed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
919 rusqlite::params![run_id, candidate_id, task_id, trace_id, mode, baseline_json, patched_json, diff_json, baseline_descriptor_json, trials_json, now],
920 )?;
921 Ok(())
922 }
923
924 pub fn insert_export_receipt(
927 &self,
928 export_key: &str,
929 bundle_id: &str,
930 rendering_version: u32,
931 namespace: &str,
932 write_through_ok: Option<bool>,
933 ) -> ForgeResult<bool> {
934 let now = chrono::Utc::now().to_rfc3339();
935 let wt = write_through_ok.map(|b| if b { 1i64 } else { 0 });
936 let result = self.lock_conn()?.execute(
937 "INSERT OR IGNORE INTO export_receipts (export_key, bundle_id, rendering_version, namespace, write_through_ok, exported_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
938 rusqlite::params![export_key, bundle_id, rendering_version as i64, namespace, wt, now],
939 )?;
940 Ok(result > 0)
941 }
942
943 pub fn has_export_receipt(&self, export_key: &str) -> ForgeResult<bool> {
944 let count: i64 = self.lock_conn()?.query_row(
945 "SELECT COUNT(*) FROM export_receipts WHERE export_key = ?1",
946 [export_key],
947 |row| row.get(0),
948 )?;
949 Ok(count > 0)
950 }
951
952 #[allow(clippy::too_many_arguments)]
955 pub fn insert_run_failure(
956 &self,
957 failure_id: &str,
958 run_id: &str,
959 class: &str,
960 message: &str,
961 phase: &str,
962 retriable: bool,
963 retry_count: u32,
964 ) -> ForgeResult<()> {
965 let now = chrono::Utc::now().to_rfc3339();
966 self.lock_conn()?.execute(
967 "INSERT INTO run_failures (failure_id, run_id, class, message, phase, retriable, retry_count, occurred_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
968 rusqlite::params![failure_id, run_id, class, message, phase, retriable as i64, retry_count as i64, now],
969 )?;
970 Ok(())
971 }
972
973 pub fn get_failures_for_run(&self, run_id: &str) -> ForgeResult<Vec<RunFailureRow>> {
974 let conn = self.lock_conn()?;
975 let mut stmt = conn.prepare(
976 "SELECT failure_id, run_id, class, message, phase, retriable, retry_count, occurred_at FROM run_failures WHERE run_id = ?1 ORDER BY occurred_at",
977 )?;
978 let rows = stmt.query_map([run_id], |row| {
979 Ok(RunFailureRow {
980 failure_id: row.get(0)?,
981 run_id: row.get(1)?,
982 class: row.get(2)?,
983 message: row.get(3)?,
984 phase: row.get(4)?,
985 retriable: row.get::<_, i64>(5)? != 0,
986 retry_count: row.get::<_, i64>(6)? as u32,
987 occurred_at: row.get(7)?,
988 })
989 })?;
990 let mut result = Vec::new();
991 for row in rows {
992 result.push(row?);
993 }
994 Ok(result)
995 }
996
997 pub fn insert_verification_plan(
1000 &self,
1001 plan_id: &str,
1002 bundle_id: &str,
1003 target_hypotheses_json: &str,
1004 steps_json: &str,
1005 policy_json: Option<&str>,
1006 ) -> ForgeResult<()> {
1007 let now = chrono::Utc::now().to_rfc3339();
1008 self.lock_conn()?.execute(
1009 "INSERT INTO verification_plans (plan_id, bundle_id, target_hypotheses_json, steps_json, policy_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1010 rusqlite::params![plan_id, bundle_id, target_hypotheses_json, steps_json, policy_json, now],
1011 )?;
1012 Ok(())
1013 }
1014
1015 pub fn insert_tool_receipt(&self, row: &ToolReceiptRow) -> ForgeResult<bool> {
1018 let result = self.lock_conn()?.execute(
1019 "INSERT OR REPLACE INTO tool_receipts (
1020 receipt_id, tool_run_id, tool_name, tool_version, backend_kind,
1021 input_digest, output_digest_or_refs_json, policy_hash, approval_state,
1022 host_identity, started_at, finished_at, trace_id, trace_ctx_json,
1023 attempt_id, trial_id, error_class, retry_owner, replay_link,
1024 provider_call_id, raw_payload_json, recorded_at
1025 ) VALUES (
1026 ?1, ?2, ?3, ?4, ?5,
1027 ?6, ?7, ?8, ?9,
1028 ?10, ?11, ?12, ?13, ?14,
1029 ?15, ?16, ?17, ?18, ?19,
1030 ?20, ?21, ?22
1031 )",
1032 rusqlite::params![
1033 row.receipt_id,
1034 row.tool_run_id,
1035 row.tool_name,
1036 row.tool_version,
1037 row.backend_kind,
1038 row.input_digest,
1039 row.output_digest_or_refs_json,
1040 row.policy_hash,
1041 row.approval_state,
1042 row.host_identity,
1043 row.started_at,
1044 row.finished_at,
1045 row.trace_id,
1046 row.trace_ctx_json,
1047 row.attempt_id,
1048 row.trial_id,
1049 row.error_class,
1050 row.retry_owner,
1051 row.replay_link,
1052 row.provider_call_id,
1053 row.raw_payload_json,
1054 row.recorded_at,
1055 ],
1056 )?;
1057 Ok(result > 0)
1058 }
1059
1060 pub fn get_tool_receipt(&self, receipt_id: &str) -> ForgeResult<Option<ToolReceiptRow>> {
1061 match self.lock_conn()?.query_row(
1062 "SELECT receipt_id, tool_run_id, tool_name, tool_version, backend_kind,
1063 input_digest, output_digest_or_refs_json, policy_hash, approval_state,
1064 host_identity, started_at, finished_at, trace_id, trace_ctx_json,
1065 attempt_id, trial_id, error_class, retry_owner, replay_link,
1066 provider_call_id, raw_payload_json, recorded_at
1067 FROM tool_receipts
1068 WHERE receipt_id = ?1",
1069 [receipt_id],
1070 |row| {
1071 Ok(ToolReceiptRow {
1072 receipt_id: row.get(0)?,
1073 tool_run_id: row.get(1)?,
1074 tool_name: row.get(2)?,
1075 tool_version: row.get(3)?,
1076 backend_kind: row.get(4)?,
1077 input_digest: row.get(5)?,
1078 output_digest_or_refs_json: row.get(6)?,
1079 policy_hash: row.get(7)?,
1080 approval_state: row.get(8)?,
1081 host_identity: row.get(9)?,
1082 started_at: row.get(10)?,
1083 finished_at: row.get(11)?,
1084 trace_id: row.get(12)?,
1085 trace_ctx_json: row.get(13)?,
1086 attempt_id: row.get(14)?,
1087 trial_id: row.get(15)?,
1088 error_class: row.get(16)?,
1089 retry_owner: row.get(17)?,
1090 replay_link: row.get(18)?,
1091 provider_call_id: row.get(19)?,
1092 raw_payload_json: row.get(20)?,
1093 recorded_at: row.get(21)?,
1094 })
1095 },
1096 ) {
1097 Ok(row) => Ok(Some(row)),
1098 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1099 Err(err) => Err(err.into()),
1100 }
1101 }
1102}
1103
1104impl Drop for ForgeStore {
1105 fn drop(&mut self) {
1106 match self.conn.lock() {
1108 Ok(conn) => {
1109 if let Err(e) = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)") {
1110 tracing::warn!(error = %e, path = %self.path.display(), "ForgeStore: WAL checkpoint on drop failed");
1111 }
1112 }
1113 Err(e) => {
1114 tracing::warn!(error = %e, "ForgeStore: mutex poisoned during drop, skipping WAL checkpoint");
1115 }
1116 }
1117 }
1118}
1119
1120#[derive(Debug, Clone)]
1123pub struct EvalRunRow {
1124 pub eval_id: String,
1125 pub candidate_id: String,
1126 pub task_id: String,
1127 pub backend: String,
1128 pub seed: i64,
1129 pub mindstate_hash: String,
1130 pub patch_hash: String,
1131 pub structural_sig: String,
1132 pub scores_json: String,
1133 pub violations_json: String,
1134 pub logs_ref: String,
1135 pub cea_run_hash: Option<String>,
1136 pub created_at: String,
1137}
1138
1139#[derive(Debug, Clone)]
1140pub struct ArchiveCellRow {
1141 pub cell_key: String,
1142 pub candidate_id: String,
1143 pub score_summary_json: String,
1144 pub cea_fingerprint: Option<String>,
1145 pub updated_at: String,
1146}
1147
1148#[derive(Debug, Clone)]
1149pub struct PromotionRow {
1150 pub version_id: String,
1151 pub candidate_id: String,
1152 pub frozen_spec_json: String,
1153 pub bounds_json: String,
1154 pub invariants_json: String,
1155 pub checksum: String,
1156 pub cea_fingerprint_json: Option<String>,
1157 pub promoted_at: String,
1158}
1159
1160#[derive(Debug, Clone)]
1161pub struct AnswerTraceRow {
1162 pub trace_id: String,
1163 pub question_sig: String,
1164 pub version_id: String,
1165 pub strategy_tags_json: String,
1166 pub patch_hash: String,
1167 pub structural_sig: String,
1168 pub score_json: String,
1169 pub created_at: String,
1170}
1171
1172#[derive(Debug, Clone)]
1173pub struct CeaNodeRow {
1174 pub node_id: String,
1175 pub node_kind: String,
1176 pub sig_json: String,
1177}
1178
1179#[derive(Debug, Clone)]
1180pub struct CeaEdgeRow {
1181 pub edge_id: String,
1182 pub cause_node_id: String,
1183 pub effect_node_id: String,
1184 pub weight: f64,
1185 pub alpha: f64,
1186 pub beta: f64,
1187 pub count: i64,
1188 pub confidence: f64,
1189 pub version_id: String,
1190 pub last_seen: String,
1191}
1192
1193#[derive(Debug, Clone)]
1196pub struct EvidenceBundleRow {
1197 pub bundle_id: String,
1198 pub candidate_id: String,
1199 pub eval_id: String,
1200 pub version_id: String,
1201 pub trace_id: String,
1202 pub canonical_bundle_json: Option<String>,
1203 pub scores_json: String,
1204 pub hypotheses_json: String,
1205 pub verification_plan_json: Option<String>,
1206 pub diff_json: Option<String>,
1207 pub assessment_json: Option<String>,
1208 pub warnings_json: String,
1209 pub created_at: String,
1210}
1211
1212impl EvidenceBundleRow {
1213 pub fn canonical_bundle(&self) -> ForgeResult<semantic_memory_forge::EvidenceBundle> {
1214 if let Some(canonical_bundle_json) = &self.canonical_bundle_json {
1215 return Ok(serde_json::from_str(canonical_bundle_json)?);
1216 }
1217
1218 Ok(legacy_row_to_local_bundle(self)?.to_canonical_evidence_bundle())
1219 }
1220
1221 pub fn local_bundle(&self) -> ForgeResult<ExperimentEvidenceBundle> {
1222 let canonical = self.canonical_bundle()?;
1223 ExperimentEvidenceBundle::from_canonical_evidence_bundle(&canonical)
1224 }
1225}
1226
1227#[derive(Debug, Clone)]
1228pub struct RunFailureRow {
1229 pub failure_id: String,
1230 pub run_id: String,
1231 pub class: String,
1232 pub message: String,
1233 pub phase: String,
1234 pub retriable: bool,
1235 pub retry_count: u32,
1236 pub occurred_at: String,
1237}
1238
1239#[derive(Debug, Clone)]
1240pub struct ToolReceiptRow {
1241 pub receipt_id: String,
1242 pub tool_run_id: String,
1243 pub tool_name: String,
1244 pub tool_version: String,
1245 pub backend_kind: String,
1246 pub input_digest: String,
1247 pub output_digest_or_refs_json: String,
1248 pub policy_hash: String,
1249 pub approval_state: String,
1250 pub host_identity: String,
1251 pub started_at: String,
1252 pub finished_at: String,
1253 pub trace_id: String,
1254 pub trace_ctx_json: String,
1255 pub attempt_id: String,
1256 pub trial_id: String,
1257 pub error_class: Option<String>,
1258 pub retry_owner: String,
1259 pub replay_link: Option<String>,
1260 pub provider_call_id: Option<String>,
1261 pub raw_payload_json: String,
1262 pub recorded_at: String,
1263}
1264
1265#[allow(clippy::too_many_arguments)]
1266fn canonical_bundle_json_from_legacy_parts(
1267 bundle_id: &str,
1268 candidate_id: &str,
1269 eval_id: &str,
1270 version_id: &str,
1271 trace_id: &str,
1272 scores_json: &str,
1273 hypotheses_json: &str,
1274 verification_plan_json: Option<&str>,
1275 diff_json: Option<&str>,
1276 assessment_json: Option<&str>,
1277 warnings_json: &str,
1278 created_at: &str,
1279) -> ForgeResult<String> {
1280 Ok(serde_json::to_string(
1281 &legacy_bundle_from_parts(
1282 bundle_id,
1283 candidate_id,
1284 eval_id,
1285 version_id,
1286 trace_id,
1287 scores_json,
1288 hypotheses_json,
1289 verification_plan_json,
1290 diff_json,
1291 assessment_json,
1292 warnings_json,
1293 created_at,
1294 )?
1295 .to_canonical_evidence_bundle(),
1296 )?)
1297}
1298
1299fn legacy_row_to_local_bundle(row: &EvidenceBundleRow) -> ForgeResult<ExperimentEvidenceBundle> {
1300 legacy_bundle_from_parts(
1301 &row.bundle_id,
1302 &row.candidate_id,
1303 &row.eval_id,
1304 &row.version_id,
1305 &row.trace_id,
1306 &row.scores_json,
1307 &row.hypotheses_json,
1308 row.verification_plan_json.as_deref(),
1309 row.diff_json.as_deref(),
1310 row.assessment_json.as_deref(),
1311 &row.warnings_json,
1312 &row.created_at,
1313 )
1314}
1315
1316#[allow(clippy::too_many_arguments)]
1317fn legacy_bundle_from_parts(
1318 bundle_id: &str,
1319 candidate_id: &str,
1320 eval_id: &str,
1321 version_id: &str,
1322 trace_id: &str,
1323 scores_json: &str,
1324 hypotheses_json: &str,
1325 verification_plan_json: Option<&str>,
1326 diff_json: Option<&str>,
1327 assessment_json: Option<&str>,
1328 warnings_json: &str,
1329 created_at: &str,
1330) -> ForgeResult<ExperimentEvidenceBundle> {
1331 Ok(ExperimentEvidenceBundle {
1332 bundle_id: bundle_id.to_string(),
1333 candidate_id: candidate_id.to_string(),
1334 eval_id: eval_id.to_string(),
1335 version_id: version_id.to_string(),
1336 supersedes_claim_version_id: None,
1337 relation_lineage_hints: Default::default(),
1338 scores: parse_legacy_score_vector(scores_json)?,
1339 hypotheses: serde_json::from_str::<Vec<CausalHypothesis>>(hypotheses_json)?,
1340 verification: verification_plan_json
1341 .map(serde_json::from_str::<VerificationPlan>)
1342 .transpose()?,
1343 trace_id: Some(trace_id.to_string()),
1344 experiment_diff: parse_legacy_experiment_diff(diff_json),
1345 attribution_json: None,
1346 assessment: assessment_json
1347 .map(serde_json::from_str::<EvidenceAssessment>)
1348 .transpose()?,
1349 warnings: serde_json::from_str::<Vec<String>>(warnings_json)?,
1350 created_at: created_at.to_string(),
1351 run_id: None,
1352 attempt_id: None,
1353 causal_question: None,
1354 unit_definition: None,
1355 bundle_scope: None,
1356 pair_comparability: None,
1357 claim_strength: ClaimStrength::ProvisionalSinglePair,
1358 identification_rationale: None,
1359 known_threats: Vec::new(),
1360 patch_hash: None,
1361 treatment: None,
1362 outcome: None,
1363 covariates: None,
1364 promotion_state: None,
1365 primary_effect: None,
1366 all_effects: Vec::new(),
1367 hypothesis_edges: Vec::new(),
1368 receipts: Vec::new(),
1369 verification_trials: Vec::new(),
1370 refutation_artifacts: Vec::new(),
1371 sealed: false,
1372 })
1373}
1374
1375fn parse_legacy_score_vector(scores_json: &str) -> ForgeResult<ScoreVector> {
1376 let value: serde_json::Value = serde_json::from_str(scores_json)?;
1377 if let Ok(scores) = serde_json::from_value::<ScoreVector>(value.clone()) {
1378 return Ok(scores);
1379 }
1380
1381 let object = value.as_object().ok_or_else(|| {
1382 ForgeError::Other("legacy evidence scores must deserialize to a JSON object".into())
1383 })?;
1384
1385 Ok(ScoreVector {
1386 correctness: object
1387 .get("correctness")
1388 .and_then(serde_json::Value::as_f64)
1389 .unwrap_or(0.0),
1390 novelty: object
1391 .get("novelty")
1392 .and_then(serde_json::Value::as_f64)
1393 .unwrap_or(0.0),
1394 stability: object
1395 .get("stability")
1396 .and_then(serde_json::Value::as_f64)
1397 .unwrap_or(0.0),
1398 weighted_total: object
1399 .get("weighted_total")
1400 .and_then(serde_json::Value::as_f64)
1401 .unwrap_or(0.0),
1402 cea_confidence: object
1403 .get("cea_confidence")
1404 .and_then(serde_json::Value::as_f64),
1405 cea_predicted_correctness: object
1406 .get("cea_predicted_correctness")
1407 .and_then(serde_json::Value::as_f64),
1408 })
1409}
1410
1411fn parse_legacy_experiment_diff(
1412 diff_json: Option<&str>,
1413) -> Option<crate::experiment::ExperimentDiff> {
1414 diff_json.and_then(|diff_json| serde_json::from_str(diff_json).ok())
1415}