1#![allow(deprecated)]
2
3use semantic_memory_forge::{
10 CausalRoleHint, ConstraintSeedKind, ExportAuthority, ExportClaim, ExportEntityAlias,
11 ExportEnvelopeV1, ExportEnvelopeV2, ExportEnvelopeV3, ExportEpisode, ExportEvidenceRef,
12 ExportRecord, ExportRecordSemanticsV3, ExportRecordV3, ExportRelation, ForgeExportMeta,
13 EXPORT_ENVELOPE_V1_SCHEMA, EXPORT_ENVELOPE_V2_SCHEMA, EXPORT_ENVELOPE_V3_SCHEMA,
14};
15use serde::{Deserialize, Serialize};
16use stack_ids::{
17 AssertionGroupId, ClaimFamilyId, ClaimId, ClaimVersionId, DigestBuilder, EntityId, EnvelopeId,
18 EpisodeId, RelationGroupId, RelationVersionId, ScopeKey, TraceCtx,
19};
20
21use crate::error::ForgeResult;
22use crate::lab::evidence::{EffectRelationLineageSource, ExperimentEvidenceBundle};
23use crate::store::ForgeStore;
24
25pub const RENDERING_VERSION: u32 = 3;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct EpisodeExport {
31 pub export_key: String,
33 pub bundle_id: String,
35 pub rendering_version: u32,
37 pub namespace: String,
39 pub meta: serde_json::Value,
41 pub content: String,
43}
44
45struct CanonicalExportEnvelopeMaterial {
46 envelope_id: EnvelopeId,
47 source_authority: String,
48 scope_key: ScopeKey,
49 trace_ctx: Option<TraceCtx>,
50 exported_at: String,
51 records: Vec<ExportRecord>,
52 export_meta: ForgeExportMeta,
53 evidence_bundle: semantic_memory_forge::EvidenceBundle,
54}
55
56impl EpisodeExport {
57 pub fn from_bundle(bundle: &ExperimentEvidenceBundle, namespace: &str) -> Self {
61 let export_key = compute_export_key(&bundle.bundle_id, RENDERING_VERSION, namespace);
62 let meta = bundle.to_episode_meta();
63 let content = bundle.to_episode_content();
64
65 Self {
66 export_key,
67 bundle_id: bundle.bundle_id.clone(),
68 rendering_version: RENDERING_VERSION,
69 namespace: namespace.to_string(),
70 meta,
71 content,
72 }
73 }
74
75 pub fn persist_receipt(
79 &self,
80 store: &ForgeStore,
81 write_through_ok: Option<bool>,
82 ) -> ForgeResult<bool> {
83 store.insert_export_receipt(
84 &self.export_key,
85 &self.bundle_id,
86 self.rendering_version,
87 &self.namespace,
88 write_through_ok,
89 )
90 }
91
92 pub fn already_exported(&self, store: &ForgeStore) -> ForgeResult<bool> {
94 store.has_export_receipt(&self.export_key)
95 }
96
97 #[deprecated(
106 since = "0.1.0",
107 note = "to_export_envelope_v1() is compatibility-only. Use to_export_envelope_v3() for the canonical export lane."
108 )]
109 pub fn to_export_envelope_v1(
110 &self,
111 bundle: &ExperimentEvidenceBundle,
112 ) -> ForgeResult<ExportEnvelopeV1> {
113 let material = self.build_canonical_export_material(bundle)?;
114 let content_digest = ExportEnvelopeV1::compute_digest(
115 &material.source_authority,
116 &material.scope_key,
117 &material.records,
118 )?;
119
120 Ok(ExportEnvelopeV1 {
121 envelope_id: material.envelope_id,
122 schema_version: EXPORT_ENVELOPE_V1_SCHEMA.into(),
123 content_digest,
124 source_authority: material.source_authority,
125 scope_key: material.scope_key,
126 trace_ctx: material.trace_ctx,
127 exported_at: material.exported_at,
128 records: material.records,
129 })
130 }
131
132 #[allow(deprecated)]
141 #[deprecated(
142 since = "0.2.0",
143 note = "to_export_envelope_v2() is compatibility-only. Use to_export_envelope_v3() for the canonical export lane."
144 )]
145 pub fn to_export_envelope_v2(
146 &self,
147 bundle: &ExperimentEvidenceBundle,
148 ) -> ForgeResult<ExportEnvelopeV2> {
149 let material = self.build_canonical_export_material(bundle)?;
150 let content_digest = ExportEnvelopeV2::compute_digest(
151 &material.source_authority,
152 &material.scope_key,
153 &material.records,
154 Some(&material.export_meta),
155 Some(&material.evidence_bundle),
156 )?;
157
158 Ok(ExportEnvelopeV2 {
159 envelope_id: material.envelope_id,
160 schema_version: EXPORT_ENVELOPE_V2_SCHEMA.into(),
161 content_digest,
162 source_authority: material.source_authority,
163 scope_key: material.scope_key,
164 trace_ctx: material.trace_ctx,
165 exported_at: material.exported_at,
166 export_meta: Some(material.export_meta),
167 evidence_bundle: Some(material.evidence_bundle),
168 records: material.records,
169 })
170 }
171
172 pub fn to_export_envelope_v3(
178 &self,
179 bundle: &ExperimentEvidenceBundle,
180 ) -> ForgeResult<ExportEnvelopeV3> {
181 let material = self.build_canonical_export_material(bundle)?;
182 let records = material
183 .records
184 .into_iter()
185 .map(|record| {
186 let semantics = record_semantics_v3(bundle, &record);
187 ExportRecordV3 { record, semantics }
188 })
189 .collect::<Vec<_>>();
190
191 let content_digest = ExportEnvelopeV3::compute_digest(
192 &material.source_authority,
193 &material.scope_key,
194 &records,
195 Some(&material.export_meta),
196 Some(&material.evidence_bundle),
197 )?;
198
199 Ok(ExportEnvelopeV3 {
200 envelope_id: material.envelope_id,
201 schema_version: EXPORT_ENVELOPE_V3_SCHEMA.into(),
202 content_digest,
203 source_authority: material.source_authority,
204 scope_key: material.scope_key,
205 trace_ctx: material.trace_ctx,
206 exported_at: material.exported_at,
207 export_meta: Some(material.export_meta),
208 evidence_bundle: Some(material.evidence_bundle),
209 support_sets: vec![],
210 contradiction_witnesses: vec![],
211 retraction_records: vec![],
212 claim_states_v13: vec![],
213 intervention_bundles_v14: vec![],
214 outcome_schemas_v14: vec![],
215 cohort_contracts_v14: vec![],
216 counterfactual_slices_v14: vec![],
217 experiment_cases_v14: vec![],
218 comparability_matrices_v14: vec![],
219 decision_traces_v14: vec![],
220 refuter_suites_v14: vec![],
221 refuter_results_v14: vec![],
222 experiment_budgets_v14: vec![],
223 rollout_decisions_v14: vec![],
224 rollback_decisions_v14: vec![],
225 attestation_envelopes_v15: vec![],
226 trust_root_sets_v15: vec![],
227 artifact_admission_policies_v15: vec![],
228 transparency_receipts_v15: vec![],
229 attestation_revocations_v15: vec![],
230 attestation_supersessions_v15: vec![],
231 remote_oracle_leases_v15: vec![],
232 remote_slice_requests_v15: vec![],
233 remote_slice_results_v15: vec![],
234 cross_runtime_replay_tickets_v15: vec![],
235 dispute_bundles_v15: vec![],
236 disclosure_policies_v15: vec![],
237 disclosure_budgets_v15: vec![],
238 records,
239 })
240 }
241
242 fn build_canonical_export_material(
243 &self,
244 bundle: &ExperimentEvidenceBundle,
245 ) -> ForgeResult<CanonicalExportEnvelopeMaterial> {
246 let mut canonical_bundle = bundle.to_canonical_evidence_bundle();
247 let scope_key = ScopeKey::from_legacy_namespace(&self.namespace);
248 let claim_id = forge_bundle_claim_id(&self.export_key);
249 let claim_version_id = forge_bundle_claim_version_id(&self.export_key, bundle, &claim_id);
250 let episode_id = EpisodeId::new(format!("forge-bundle-episode:{}", self.export_key));
251 let trace_ctx = bundle
252 .trace_id
253 .as_ref()
254 .map(|trace_id| TraceCtx::from_trace_id(trace_id.as_str()));
255 let exported_at = chrono::Utc::now().to_rfc3339();
256 let mut records = Vec::new();
257
258 records.push(ExportRecord::Claim(ExportClaim {
259 claim_id: Some(claim_id.clone()),
260 claim_version_id: Some(claim_version_id.clone()),
261 subject_entity_id: canonical_subject_entity_id(bundle),
262 predicate: "forge_evidence_bundle".into(),
263 object_anchor: serde_json::json!({
264 "bundle_id": self.bundle_id,
265 "candidate_id": bundle.candidate_id,
266 "eval_id": bundle.eval_id,
267 "rendering_version": self.rendering_version,
268 }),
269 valid_from: bundle_valid_from(bundle),
270 valid_to: None,
271 confidence: cast_confidence(bundle.scores.weighted_total),
272 content: self.content.clone(),
273 projection_family: "forge_verification".into(),
274 supersedes_claim_id: None,
275 supersedes_claim_version_id: bundle.supersedes_claim_version_id.clone(),
276 metadata: Some(bundle_claim_metadata(self, bundle, &canonical_bundle)),
277 }));
278
279 records.extend(
280 bundle_relation_records(bundle, &claim_id, Some(&episode_id), &scope_key)
281 .into_iter()
282 .map(ExportRecord::Relation),
283 );
284
285 records.extend(
286 bundle_verification_trial_records(bundle, &claim_id, Some(&episode_id), &scope_key)
287 .into_iter()
288 .map(ExportRecord::Relation),
289 );
290
291 records.extend(
292 bundle_refutation_artifact_records(bundle, &claim_id, Some(&episode_id), &scope_key)
293 .into_iter()
294 .map(ExportRecord::Relation),
295 );
296
297 records.extend(
298 bundle_alias_records(bundle, &scope_key)
299 .into_iter()
300 .map(ExportRecord::EntityAlias),
301 );
302
303 records.push(ExportRecord::Episode(ExportEpisode {
304 episode_id: Some(episode_id),
305 document_id: bundle
306 .run_id
307 .clone()
308 .unwrap_or_else(|| format!("forge_bundle:{}", self.bundle_id)),
309 cause_ids: bundle_cause_ids(bundle),
310 effect_type: bundle_effect_type(bundle),
311 outcome: bundle
312 .outcome
313 .clone()
314 .unwrap_or_else(|| default_outcome(bundle)),
315 confidence: cast_confidence(bundle.scores.weighted_total),
316 experiment_id: bundle.run_id.clone(),
317 metadata: Some(serde_json::json!({
318 "bundle_id": self.bundle_id,
319 "candidate_id": bundle.candidate_id,
320 "eval_id": bundle.eval_id,
321 "version_id": bundle.version_id,
322 "attempt_id": bundle.attempt_id,
323 "claim_strength": format!("{}", bundle.claim_strength),
324 "assessment": bundle.assessment,
325 "bundle_scope": bundle.bundle_scope,
326 "treatment": bundle.treatment,
327 "covariates": bundle.covariates,
328 "known_threats": bundle.known_threats,
329 "primary_effect": bundle.primary_effect,
330 "warning_count": bundle.warnings.len(),
331 "receipt_count": bundle.receipts.len(),
332 "verification_trial_count": bundle.verification_trials.len(),
333 "refutation_artifact_count": bundle.refutation_artifacts.len(),
334 "verification_trials": bundle
335 .verification_trials
336 .iter()
337 .map(|trial| serde_json::json!({
338 "attempt_id": trial.attempt_id,
339 "trial_id": trial.trial_id,
340 "baseline_or_patch": trial.baseline_or_patch,
341 "completed": trial.completed,
342 }))
343 .collect::<Vec<_>>(),
344 "refutation_artifacts": bundle
345 .refutation_artifacts
346 .iter()
347 .map(|artifact| serde_json::json!({
348 "artifact_id": artifact.artifact_id,
349 "artifact_type": format!("{:?}", artifact.artifact_type),
350 "outcome": format!("{:?}", artifact.outcome),
351 }))
352 .collect::<Vec<_>>(),
353 "experiment_diff_counts": bundle.experiment_diff.as_ref().map(|diff| serde_json::json!({
354 "effects": diff.effects.len(),
355 "regressions": diff.regressions,
356 "improvements": diff.improvements,
357 "stable_failures": diff.stable_failures,
358 "stable_passes": diff.stable_passes,
359 "statistically_meaningful": diff.statistically_meaningful,
360 "sample_warning": diff.sample_warning,
361 })),
362 })),
363 }));
364
365 records.extend(
366 bundle_evidence_refs(bundle, &claim_id, &claim_version_id)
367 .into_iter()
368 .map(ExportRecord::EvidenceRef),
369 );
370
371 canonical_bundle.claim_ids = vec![claim_id];
372
373 Ok(CanonicalExportEnvelopeMaterial {
374 envelope_id: EnvelopeId::new(self.export_key.clone()),
375 source_authority: ExportAuthority::Forge.as_str().into(),
376 scope_key,
377 trace_ctx,
378 exported_at: exported_at.clone(),
379 records,
380 export_meta: ForgeExportMeta {
381 authority: ExportAuthority::Forge,
382 run_id: bundle.run_id.clone(),
383 direct_write: false,
384 comparability_snapshot_version: bundle.bundle_scope.as_ref().map(|scope| {
385 format!(
386 "{}:{}:{}",
387 scope.workload_id, scope.backend_family, scope.timeout_class
388 )
389 }),
390 exported_at,
391 },
392 evidence_bundle: canonical_bundle,
393 })
394 }
395}
396
397fn relation_group_id_for_record(
398 bundle: &ExperimentEvidenceBundle,
399 relation: &ExportRelation,
400) -> RelationGroupId {
401 if let Some(claim_id) = relation.source_claim_id.as_ref() {
402 RelationGroupId::new(format!(
403 "rel-bundle:{}:claim:{}:predicate:{}",
404 bundle.bundle_id, claim_id, relation.predicate
405 ))
406 } else {
407 RelationGroupId::new(format!(
408 "rel-bundle:{}:subject:{}:predicate:{}",
409 bundle.bundle_id, relation.subject_entity_id, relation.predicate
410 ))
411 }
412}
413
414fn constraint_seed_kind_for_predicate(predicate: &str) -> ConstraintSeedKind {
415 if predicate.contains("verification_refutation") || predicate.contains("verification_trial") {
416 ConstraintSeedKind::CausalRefutation
417 } else if predicate.contains("hypothesis_edge") {
418 ConstraintSeedKind::MutualExclusion
419 } else if predicate.contains("temporal") {
420 ConstraintSeedKind::TemporalCoherence
421 } else {
422 ConstraintSeedKind::Hyperedge
423 }
424}
425
426fn record_semantics_v3(
427 bundle: &ExperimentEvidenceBundle,
428 record: &ExportRecord,
429) -> Option<ExportRecordSemanticsV3> {
430 let projection_visibility_class = semantic_memory_forge::ProjectionVisibilityClass::Standard;
431 let export_confidence_class = if bundle.assessment.is_some() {
432 semantic_memory_forge::ExportConfidenceClass::Reviewed
433 } else {
434 semantic_memory_forge::ExportConfidenceClass::ThinExport
435 };
436
437 match record {
438 ExportRecord::Claim(claim) => {
439 let mut derivation_seed_ids = Vec::new();
440 if let Some(claim_version_id) = claim.claim_version_id.as_ref() {
441 derivation_seed_ids.push(format!("claim_version:{claim_version_id}"));
442 }
443 if let Some(supersedes) = claim.supersedes_claim_version_id.as_ref() {
444 derivation_seed_ids.push(format!("supersedes_claim_version:{supersedes}"));
445 }
446 let mut semantics = ExportRecordSemanticsV3 {
447 claim_family_id: Some(ClaimFamilyId::new(bundle.candidate_id.clone())),
448 assertion_group_id: Some(AssertionGroupId::new(format!(
449 "claim:{}",
450 claim.subject_entity_id
451 ))),
452 relation_group_id: None,
453 joint_evidence_group_id: None,
454 constraint_seed_kind: None,
458 treatment_hint: None,
459 outcome_hint: None,
460 confounder_hint: None,
461 instrument_hint: None,
462 effect_modifier_hint: None,
463 contradiction_candidate_group_id: None,
464 mutual_exclusion_group_id: None,
465 comparability_snapshot_version: bundle.bundle_scope.as_ref().map(|scope| {
466 format!(
467 "{}:{}:{}",
468 scope.workload_id, scope.backend_family, scope.timeout_class
469 )
470 }),
471 nuisance_snapshot: None,
472 projection_visibility_class,
473 export_confidence_class,
474 derivation_seed_ids,
475 review_priority_hint: None,
476 };
477
478 if claim.supersedes_claim_version_id.is_some() {
479 semantics.review_priority_hint = Some("supersedes_known_claim_version".into());
480 }
481
482 Some(semantics)
483 }
484 ExportRecord::Relation(relation) => Some(ExportRecordSemanticsV3 {
485 claim_family_id: None,
486 assertion_group_id: None,
487 relation_group_id: Some(relation_group_id_for_record(bundle, relation)),
488 joint_evidence_group_id: None,
489 constraint_seed_kind: Some(constraint_seed_kind_for_predicate(&relation.predicate)),
490 treatment_hint: None,
491 outcome_hint: None,
492 confounder_hint: None,
493 instrument_hint: None,
494 effect_modifier_hint: None,
495 contradiction_candidate_group_id: None,
496 mutual_exclusion_group_id: None,
497 comparability_snapshot_version: None,
498 nuisance_snapshot: None,
499 projection_visibility_class,
500 export_confidence_class,
501 derivation_seed_ids: {
502 let mut derivation_seed_ids = Vec::new();
503 if let Some(relation_version_id) = relation.relation_version_id.as_ref() {
504 derivation_seed_ids.push(format!("relation_version:{relation_version_id}"));
505 }
506 if let Some(supersedes) = relation.supersedes_relation_version_id.as_ref() {
507 derivation_seed_ids.push(format!("supersedes_relation_version:{supersedes}"));
508 }
509 if derivation_seed_ids.is_empty() {
510 derivation_seed_ids.push(relation.predicate.clone());
511 }
512 derivation_seed_ids
513 },
514 review_priority_hint: None,
515 }),
516 ExportRecord::Episode(_) => Some(ExportRecordSemanticsV3 {
517 claim_family_id: None,
518 assertion_group_id: None,
519 relation_group_id: None,
520 joint_evidence_group_id: None,
521 constraint_seed_kind: Some(ConstraintSeedKind::TemporalCoherence),
522 treatment_hint: Some(CausalRoleHint::Treatment),
523 outcome_hint: Some(CausalRoleHint::Outcome),
524 confounder_hint: None,
525 instrument_hint: None,
526 effect_modifier_hint: None,
527 contradiction_candidate_group_id: None,
528 mutual_exclusion_group_id: None,
529 comparability_snapshot_version: None,
530 nuisance_snapshot: None,
531 projection_visibility_class,
532 export_confidence_class,
533 derivation_seed_ids: vec![bundle.bundle_id.clone()],
534 review_priority_hint: None,
535 }),
536 ExportRecord::EntityAlias(_) | ExportRecord::EvidenceRef(_) => None,
537 }
538}
539
540fn bundle_claim_metadata(
541 export: &EpisodeExport,
542 bundle: &ExperimentEvidenceBundle,
543 canonical_bundle: &semantic_memory_forge::EvidenceBundle,
544) -> serde_json::Value {
545 let promotion_state = canonical_bundle
546 .verification_summary
547 .as_ref()
548 .map(|summary| summary.promotion_state.clone())
549 .or_else(|| bundle.promotion_state.clone());
550 serde_json::json!({
551 "forge_bundle_id": export.bundle_id,
552 "forge_rendering_version": export.rendering_version,
553 "forge_bundle_meta": export.meta.clone(),
554 "evidence_bundle_id": canonical_bundle.id.as_str(),
555 "estimator_meta": canonical_bundle.estimator_meta,
556 "comparability_snapshot": canonical_bundle.comparability_snapshot,
557 "comparability_snapshot_version": canonical_bundle.comparability_snapshot_version,
558 "verification_summary": canonical_bundle.verification_summary,
559 "refutation_artifacts": canonical_bundle.refutation_artifacts,
560 "verification_trial_family": canonical_bundle.verification_trials,
561 "promotion_state": promotion_state,
562 })
563}
564
565fn canonical_subject_entity_id(bundle: &ExperimentEvidenceBundle) -> EntityId {
566 EntityId::new(format!("bundle:{}", bundle.candidate_id))
567}
568
569fn forge_bundle_claim_id(export_key: &str) -> ClaimId {
570 ClaimId::new(format!("forge-bundle-claim:{export_key}"))
571}
572
573fn forge_bundle_claim_version_id(
574 export_key: &str,
575 bundle: &ExperimentEvidenceBundle,
576 claim_id: &ClaimId,
577) -> ClaimVersionId {
578 let mut builder = DigestBuilder::new();
579 builder
580 .update_str("forge_bundle_claim_version")
581 .separator()
582 .update_str(export_key)
583 .separator()
584 .update_str(claim_id.as_str())
585 .separator()
586 .update_str(&bundle.version_id)
587 .separator()
588 .update_str(&bundle.created_at);
589 ClaimVersionId::new(format!(
590 "forge-bundle-claim-version:{}",
591 builder.finalize().hex()
592 ))
593}
594
595fn deterministic_relation_version_id(
596 bundle: &ExperimentEvidenceBundle,
597 scope_key: &ScopeKey,
598 relation_kind: &str,
599 predicate: &str,
600 object_anchor: &serde_json::Value,
601) -> RelationVersionId {
602 let mut builder = DigestBuilder::new();
603 builder
604 .update_str("forge_bundle_relation_version")
605 .separator()
606 .update_str(&bundle.bundle_id)
607 .separator()
608 .update_str(&bundle.version_id)
609 .separator()
610 .update_str(&bundle.created_at)
611 .separator()
612 .update_str(relation_kind)
613 .separator()
614 .update_str(predicate)
615 .separator();
616 let scope_json = serde_json::to_string(scope_key).unwrap_or_else(|_| "{}".into());
617 builder.update_str(&scope_json).separator();
618 let object_json = serde_json::to_string(object_anchor).unwrap_or_else(|_| "null".into());
619 builder.update_str(&object_json);
620 RelationVersionId::new(format!(
621 "forge-bundle-relation-version:{}",
622 builder.finalize().hex()
623 ))
624}
625
626fn bundle_valid_from(bundle: &ExperimentEvidenceBundle) -> Option<String> {
627 if bundle.created_at.is_empty() {
628 None
629 } else {
630 Some(bundle.created_at.clone())
631 }
632}
633
634fn bundle_relation_records(
635 bundle: &ExperimentEvidenceBundle,
636 claim_id: &ClaimId,
637 episode_id: Option<&EpisodeId>,
638 scope_key: &ScopeKey,
639) -> Vec<ExportRelation> {
640 let mut records = Vec::new();
641
642 if let Some(effect) = bundle.primary_effect.as_ref() {
643 records.push(export_relation_from_effect(
644 effect,
645 EffectRelationLineageSource::PrimaryEffect,
646 claim_id,
647 episode_id,
648 bundle,
649 scope_key,
650 ));
651 }
652
653 for effect in &bundle.all_effects {
654 if bundle.primary_effect.as_ref().is_some_and(|primary| {
655 primary.kind == effect.kind
656 && primary.message == effect.message
657 && primary.in_baseline == effect.in_baseline
658 && primary.in_patched == effect.in_patched
659 }) {
660 continue;
661 }
662 records.push(export_relation_from_effect(
663 effect,
664 EffectRelationLineageSource::AllEffect,
665 claim_id,
666 episode_id,
667 bundle,
668 scope_key,
669 ));
670 }
671
672 for edge in &bundle.hypothesis_edges {
673 let predicate = format!(
674 "hypothesis_edge_{}",
675 debug_name_to_snake_case(&format!("{:?}", edge.kind))
676 );
677 let object_anchor = serde_json::json!({
678 "edge_id": edge.edge_id,
679 "source_edit": edge.source_edit,
680 "target_effect": edge.target_effect,
681 "source": "hypothesis_edge",
682 "status": format!("{:?}", edge.status),
683 "evidence_ids": edge.evidence_ids,
684 "contradiction_ids": edge.contradiction_ids,
685 "verification_status": format!("{:?}", edge.verification_status),
686 });
687 records.push(ExportRelation {
688 relation_version_id: Some(deterministic_relation_version_id(
689 bundle,
690 scope_key,
691 "hypothesis_edge",
692 &predicate,
693 &object_anchor,
694 )),
695 subject_entity_id: canonical_subject_entity_id(bundle),
696 predicate,
697 object_anchor,
698 valid_from: bundle_valid_from(bundle),
699 valid_to: None,
700 confidence: cast_confidence(edge.confidence),
701 projection_family: "forge_verification".into(),
702 source_claim_id: Some(claim_id.clone()),
703 source_episode_id: episode_id.cloned(),
704 supersedes_relation_version_id: bundle.superseded_hypothesis_relation_version_id(edge),
705 metadata: Some(serde_json::json!({
706 "bundle_id": bundle.bundle_id,
707 "source": "hypothesis_edge",
708 "edge_signature": bundle_signature(bundle, "hypothesis_edge", &edge.edge_id),
709 })),
710 });
711 }
712
713 if let Some(diff) = &bundle.experiment_diff {
714 for effect in &diff.effects {
715 records.push(export_relation_from_effect(
716 effect,
717 EffectRelationLineageSource::ExperimentDiff,
718 claim_id,
719 episode_id,
720 bundle,
721 scope_key,
722 ));
723 }
724 }
725
726 records
727}
728
729fn bundle_verification_trial_records(
730 bundle: &ExperimentEvidenceBundle,
731 claim_id: &ClaimId,
732 episode_id: Option<&EpisodeId>,
733 scope_key: &ScopeKey,
734) -> Vec<ExportRelation> {
735 let mut records = Vec::new();
736
737 for trial in &bundle.verification_trials {
738 let predicate = match trial.baseline_or_patch {
739 crate::lab::evidence::BaselineOrPatch::Baseline => "verification_trial_baseline",
740 crate::lab::evidence::BaselineOrPatch::Patched => "verification_trial_patched",
741 };
742
743 let object_anchor = serde_json::json!({
744 "trial_id": trial.trial_id,
745 "attempt_id": trial.attempt_id,
746 "baseline_or_patch": format!("{:?}", trial.baseline_or_patch),
747 "completed": trial.completed,
748 "receipts": trial.receipts,
749 "bundle_id": bundle.bundle_id,
750 "candidate_id": bundle.candidate_id,
751 });
752
753 records.push(ExportRelation {
754 relation_version_id: Some(deterministic_relation_version_id(
755 bundle,
756 scope_key,
757 predicate,
758 predicate,
759 &object_anchor,
760 )),
761 subject_entity_id: canonical_subject_entity_id(bundle),
762 predicate: predicate.to_string(),
763 object_anchor,
764 valid_from: bundle_valid_from(bundle),
765 valid_to: None,
766 confidence: cast_confidence(bundle.scores.weighted_total),
767 projection_family: "forge_verification".into(),
768 source_claim_id: Some(claim_id.clone()),
769 source_episode_id: episode_id.cloned(),
770 supersedes_relation_version_id: bundle
771 .superseded_verification_trial_relation_version_id(trial),
772 metadata: Some(serde_json::json!({
773 "bundle_id": bundle.bundle_id,
774 "attempt_id": trial.attempt_id,
775 "trial_id": trial.trial_id,
776 "baseline_or_patch": format!("{:?}", trial.baseline_or_patch),
777 })),
778 });
779 }
780
781 records
782}
783
784fn bundle_refutation_artifact_records(
785 bundle: &ExperimentEvidenceBundle,
786 claim_id: &ClaimId,
787 episode_id: Option<&EpisodeId>,
788 scope_key: &ScopeKey,
789) -> Vec<ExportRelation> {
790 let mut records = Vec::new();
791
792 for artifact in &bundle.refutation_artifacts {
793 let predicate = format!(
794 "verification_refutation_{}",
795 debug_name_to_snake_case(&format!("{:?}", artifact.artifact_type))
796 );
797
798 let outcome = match &artifact.outcome {
799 crate::lab::evidence::RefutationArtifactOutcome::Passed => "passed",
800 crate::lab::evidence::RefutationArtifactOutcome::Failed { .. } => "failed",
801 crate::lab::evidence::RefutationArtifactOutcome::Inconclusive { .. } => "inconclusive",
802 crate::lab::evidence::RefutationArtifactOutcome::Skipped { .. } => "skipped",
803 };
804
805 let object_anchor = serde_json::json!({
806 "artifact_id": artifact.artifact_id,
807 "artifact_type": format!("{:?}", artifact.artifact_type),
808 "attempt_id": artifact.attempt_id,
809 "trial_id": artifact.trial_id,
810 "outcome": outcome,
811 "estimate_delta": artifact.estimate_delta,
812 "details": artifact.details,
813 });
814
815 records.push(ExportRelation {
816 relation_version_id: Some(deterministic_relation_version_id(
817 bundle,
818 scope_key,
819 "refutation_artifact",
820 &predicate,
821 &object_anchor,
822 )),
823 subject_entity_id: canonical_subject_entity_id(bundle),
824 predicate,
825 object_anchor,
826 valid_from: bundle_valid_from(bundle),
827 valid_to: None,
828 confidence: cast_confidence(bundle.scores.weighted_total),
829 projection_family: "forge_verification".into(),
830 source_claim_id: Some(claim_id.clone()),
831 source_episode_id: episode_id.cloned(),
832 supersedes_relation_version_id: bundle
833 .superseded_refutation_relation_version_id(artifact),
834 metadata: Some(serde_json::json!({
835 "bundle_id": bundle.bundle_id,
836 "artifact_type": format!("{:?}", artifact.artifact_type),
837 "artifact_id": artifact.artifact_id,
838 "outcome": outcome,
839 })),
840 });
841 }
842
843 records
844}
845
846fn export_relation_from_effect(
847 effect: &crate::experiment::TypedLocatedEffect,
848 source: EffectRelationLineageSource,
849 claim_id: &ClaimId,
850 episode_id: Option<&EpisodeId>,
851 bundle: &ExperimentEvidenceBundle,
852 scope_key: &ScopeKey,
853) -> ExportRelation {
854 let confidence = if effect.in_baseline == effect.in_patched {
855 0.0
856 } else {
857 cast_confidence(bundle.scores.weighted_total)
858 };
859
860 let predicate = format!(
861 "{}_{}",
862 source.export_source_key(),
863 debug_name_to_snake_case(&format!("{:?}", effect.kind))
864 );
865 let object_anchor = serde_json::json!({
866 "message": effect.message,
867 "kind": format!("{:?}", effect.kind),
868 "in_baseline": effect.in_baseline,
869 "in_patched": effect.in_patched,
870 "file": effect.file,
871 "line": effect.line,
872 "source": source.export_source_key(),
873 });
874
875 ExportRelation {
876 relation_version_id: Some(deterministic_relation_version_id(
877 bundle,
878 scope_key,
879 source.export_source_key(),
880 &predicate,
881 &object_anchor,
882 )),
883 subject_entity_id: canonical_subject_entity_id(bundle),
884 predicate,
885 object_anchor,
886 valid_from: bundle_valid_from(bundle),
887 valid_to: None,
888 confidence,
889 projection_family: "forge_verification".into(),
890 source_claim_id: Some(claim_id.clone()),
891 source_episode_id: episode_id.cloned(),
892 supersedes_relation_version_id: bundle
893 .superseded_effect_relation_version_id(source, effect),
894 metadata: Some(serde_json::json!({
895 "bundle_id": bundle.bundle_id,
896 "source": source.export_source_key(),
897 "effect_signature": bundle_signature(bundle, source.export_source_key(), &effect.message),
898 })),
899 }
900}
901
902fn bundle_signature(bundle: &ExperimentEvidenceBundle, source: &str, detail: &str) -> String {
903 let payload = serde_json::json!({
904 "source": source,
905 "bundle_id": bundle.bundle_id,
906 "candidate_id": bundle.candidate_id,
907 "detail": detail,
908 "attempt_id": bundle.attempt_id,
909 });
910
911 blake3::hash(payload.to_string().as_bytes())
912 .to_hex()
913 .to_string()
914}
915
916fn bundle_alias_records(
917 bundle: &ExperimentEvidenceBundle,
918 scope: &ScopeKey,
919) -> Vec<ExportEntityAlias> {
920 let mut aliases = Vec::new();
921
922 if bundle.candidate_id.is_empty() {
923 return aliases;
924 }
925
926 let canonical = canonical_subject_entity_id(bundle);
927 aliases.push(ExportEntityAlias {
928 canonical_entity_id: canonical.clone(),
929 alias_text: bundle.candidate_id.clone(),
930 alias_source: "forge_bundle_candidate".into(),
931 match_evidence: Some(serde_json::json!({
932 "bundle_id": bundle.bundle_id,
933 "eval_id": bundle.eval_id,
934 "candidate_id": bundle.candidate_id,
935 "attempt_id": bundle.attempt_id,
936 })),
937 confidence: cast_confidence(bundle.scores.weighted_total),
938 scope: Some(scope.clone()),
939 superseded_by_entity_id: None,
940 split_from_entity_id: None,
941 });
942
943 if let Some(patch_hash) = &bundle.patch_hash {
944 aliases.push(ExportEntityAlias {
945 canonical_entity_id: canonical.clone(),
946 alias_text: patch_hash.clone(),
947 alias_source: "forge_patch_hash".into(),
948 match_evidence: Some(serde_json::json!({
949 "bundle_id": bundle.bundle_id,
950 "patch_hash_source": "bundle.patch_hash",
951 })),
952 confidence: cast_confidence(bundle.scores.weighted_total),
953 scope: Some(scope.clone()),
954 superseded_by_entity_id: None,
955 split_from_entity_id: None,
956 });
957 }
958
959 aliases
960}
961
962fn bundle_evidence_refs(
963 bundle: &ExperimentEvidenceBundle,
964 claim_id: &ClaimId,
965 claim_version_id: &ClaimVersionId,
966) -> Vec<ExportEvidenceRef> {
967 let mut refs = Vec::new();
968
969 refs.push(ExportEvidenceRef {
970 claim_id: claim_id.clone(),
971 claim_version_id: Some(claim_version_id.clone()),
972 fetch_handle: format!("forge:bundle:{}", bundle.bundle_id),
973 source_authority: ExportAuthority::Forge.as_str().into(),
974 metadata: Some(serde_json::json!({
975 "bundle_id": bundle.bundle_id,
976 "candidate_id": bundle.candidate_id,
977 "eval_id": bundle.eval_id,
978 "version_id": bundle.version_id,
979 })),
980 });
981
982 if let Some(attempt_id) = &bundle.attempt_id {
983 refs.push(ExportEvidenceRef {
984 claim_id: claim_id.clone(),
985 claim_version_id: Some(claim_version_id.clone()),
986 fetch_handle: format!("forge:attempt:{}", attempt_id),
987 source_authority: ExportAuthority::Forge.as_str().into(),
988 metadata: Some(serde_json::json!({
989 "bundle_id": bundle.bundle_id,
990 "attempt_id": attempt_id,
991 "evidence_kind": "attempt",
992 })),
993 });
994 }
995
996 if let Some(run_id) = &bundle.run_id {
997 refs.push(ExportEvidenceRef {
998 claim_id: claim_id.clone(),
999 claim_version_id: Some(claim_version_id.clone()),
1000 fetch_handle: format!("forge:run:{}", run_id),
1001 source_authority: ExportAuthority::Forge.as_str().into(),
1002 metadata: Some(serde_json::json!({
1003 "bundle_id": bundle.bundle_id,
1004 "run_id": run_id,
1005 "evidence_kind": "run",
1006 })),
1007 });
1008 }
1009
1010 for receipt in &bundle.receipts {
1011 refs.push(ExportEvidenceRef {
1012 claim_id: claim_id.clone(),
1013 claim_version_id: Some(claim_version_id.clone()),
1014 fetch_handle: format!("forge:receipt:{}", receipt.receipt_id),
1015 source_authority: ExportAuthority::Forge.as_str().into(),
1016 metadata: Some(serde_json::json!({
1017 "receipt_id": receipt.receipt_id,
1018 "receipt_kind": format!("{:?}", receipt.kind),
1019 "storage": format!("{:?}", receipt.storage),
1020 })),
1021 });
1022 }
1023
1024 refs
1025}
1026
1027fn cast_confidence(source: f64) -> f32 {
1028 source.clamp(0.0, 1.0) as f32
1029}
1030
1031fn bundle_cause_ids(bundle: &ExperimentEvidenceBundle) -> Vec<String> {
1032 [
1033 bundle
1034 .attempt_id
1035 .as_ref()
1036 .map(|attempt_id| format!("attempt:{attempt_id}")),
1037 Some(format!("candidate:{}", bundle.candidate_id)),
1038 Some(format!("eval:{}", bundle.eval_id)),
1039 ]
1040 .into_iter()
1041 .flatten()
1042 .collect()
1043}
1044
1045fn bundle_effect_type(bundle: &ExperimentEvidenceBundle) -> String {
1046 if let Some(effect) = &bundle.primary_effect {
1047 return debug_name_to_snake_case(&format!("{:?}", effect.kind));
1048 }
1049
1050 if let Some(effect) = bundle.all_effects.first() {
1051 return debug_name_to_snake_case(&format!("{:?}", effect.kind));
1052 }
1053
1054 bundle
1055 .experiment_diff
1056 .as_ref()
1057 .and_then(|diff| diff.effects.first())
1058 .map(|effect| debug_name_to_snake_case(&format!("{:?}", effect.kind)))
1059 .unwrap_or_else(|| "verification_bundle".into())
1060}
1061
1062fn default_outcome(bundle: &ExperimentEvidenceBundle) -> String {
1063 if let Some(diff) = &bundle.experiment_diff {
1064 return format!(
1065 "diff regressions={} improvements={} stable_failures={} stable_passes={}",
1066 diff.regressions, diff.improvements, diff.stable_failures, diff.stable_passes
1067 );
1068 }
1069 "verification_bundle".into()
1070}
1071
1072fn debug_name_to_snake_case(name: &str) -> String {
1073 let mut rendered = String::with_capacity(name.len() + 4);
1074 for (idx, ch) in name.chars().enumerate() {
1075 if ch.is_uppercase() {
1076 if idx > 0 {
1077 rendered.push('_');
1078 }
1079 for lower in ch.to_lowercase() {
1080 rendered.push(lower);
1081 }
1082 } else {
1083 rendered.push(ch);
1084 }
1085 }
1086 rendered
1087}
1088
1089pub fn compute_export_key(bundle_id: &str, rendering_version: u32, namespace: &str) -> String {
1091 let input = format!("{bundle_id}:{rendering_version}:{namespace}");
1092 blake3::hash(input.as_bytes()).to_hex().to_string()
1093}
1094
1095pub async fn export_bundle(
1100 bundle: &ExperimentEvidenceBundle,
1101 namespace: &str,
1102 store: &ForgeStore,
1103) -> ForgeResult<ExportEnvelopeV3> {
1104 let export_bundle = bundle_with_store_promotion_state(bundle, store)?;
1105 let export = EpisodeExport::from_bundle(&export_bundle, namespace);
1106 let envelope = export.to_export_envelope_v3(&export_bundle)?;
1107
1108 if export.already_exported(store)? {
1109 tracing::info!(
1110 export_key = %export.export_key,
1111 bundle_id = %export.bundle_id,
1112 "export already receipted, skipping"
1113 );
1114 return Ok(envelope);
1115 }
1116
1117 export.persist_receipt(store, None)?;
1118 Ok(envelope)
1119}
1120
1121#[cfg(feature = "danger-sm-write")]
1126pub async fn export_bundle_with_memory_write_through_compat(
1127 bundle: &ExperimentEvidenceBundle,
1128 namespace: &str,
1129 store: &ForgeStore,
1130 memory: &semantic_memory::MemoryStore,
1131) -> ForgeResult<ExportEnvelopeV3> {
1132 let export_bundle = bundle_with_store_promotion_state(bundle, store)?;
1133 let export = EpisodeExport::from_bundle(&export_bundle, namespace);
1134 let envelope = export.to_export_envelope_v3(&export_bundle)?;
1135
1136 if export.already_exported(store)? {
1137 tracing::info!(
1138 export_key = %export.export_key,
1139 bundle_id = %export.bundle_id,
1140 "compat write-through export already receipted, skipping"
1141 );
1142 return Ok(envelope);
1143 }
1144
1145 let write_through_ok = match forge_memory_bridge::transform_envelope_v3(&envelope) {
1146 Ok(batch) => match memory.import_projection_batch(&batch).await {
1147 Ok(_) => {
1148 tracing::warn!(
1149 export_key = %export.export_key,
1150 "compat direct memory import executed via canonical bridge batch"
1151 );
1152 Some(true)
1153 }
1154 Err(err) => {
1155 tracing::warn!(
1156 export_key = %export.export_key,
1157 error = %err,
1158 "compat direct memory import failed"
1159 );
1160 Some(false)
1161 }
1162 },
1163 Err(err) => {
1164 tracing::warn!(
1165 export_key = %export.export_key,
1166 error = %err,
1167 "compat direct memory import failed before memory write"
1168 );
1169 Some(false)
1170 }
1171 };
1172
1173 export.persist_receipt(store, write_through_ok)?;
1174 Ok(envelope)
1175}
1176
1177#[cfg(not(feature = "danger-sm-write"))]
1179pub async fn export_bundle_with_memory_write_through_compat(
1180 _bundle: &ExperimentEvidenceBundle,
1181 _namespace: &str,
1182 _store: &ForgeStore,
1183 _memory: &semantic_memory::MemoryStore,
1184) -> ForgeResult<ExportEnvelopeV3> {
1185 Err(crate::error::ForgeError::WriteThroughBlocked)
1186}
1187
1188fn bundle_with_store_promotion_state(
1189 bundle: &ExperimentEvidenceBundle,
1190 store: &ForgeStore,
1191) -> ForgeResult<ExperimentEvidenceBundle> {
1192 let mut enriched = bundle.clone();
1193 if enriched.promotion_state.is_none() {
1194 if let Some(promotion) = store.get_latest_promotion()? {
1195 if promotion.candidate_id == enriched.candidate_id {
1196 enriched.promotion_state = Some(semantic_memory_forge::PromotionState::Promoted {
1197 version_id: Some(promotion.version_id),
1198 promoted_at: Some(promotion.promoted_at),
1199 });
1200 }
1201 }
1202 }
1203 Ok(enriched)
1204}