Skip to main content

forge_engine/
export.rs

1#![allow(deprecated)]
2
3//! Bundle export seam for the canonical Forge -> bridge -> memory lane.
4//!
5//! Forge is not a durable knowledge store. It emits Forge-owned export
6//! envelopes, persists export receipts for idempotency, and leaves projection
7//! import semantics to `forge-memory-bridge` and `semantic-memory`.
8
9use 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
25/// Current rendering version for bundle exports.
26pub const RENDERING_VERSION: u32 = 3;
27
28/// Deterministic rendered representation of an `ExperimentEvidenceBundle`.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct EpisodeExport {
31    /// Deterministic key for this export receipt and envelope identity.
32    pub export_key: String,
33    /// The bundle ID this export was derived from.
34    pub bundle_id: String,
35    /// Rendering version used.
36    pub rendering_version: u32,
37    /// Legacy namespace routing input.
38    pub namespace: String,
39    /// Bundle metadata (JSON-encodable).
40    pub meta: serde_json::Value,
41    /// Bundle content text for embedding or audit trails.
42    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    /// Create an export rendering from an `ExperimentEvidenceBundle`.
58    ///
59    /// The `export_key` is deterministic: blake3(bundle_id + rendering_version + namespace).
60    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    /// Persist an export receipt to the store.
76    ///
77    /// This is idempotent: exporting the same bundle twice produces no change.
78    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    /// Check if this export has already been receipted.
93    pub fn already_exported(&self, store: &ForgeStore) -> ForgeResult<bool> {
94        store.has_export_receipt(&self.export_key)
95    }
96
97    /// Convert this rendering into Forge's compatibility export envelope.
98    ///
99    /// The compatibility path is:
100    /// `ExportEnvelopeV1 -> forge-memory-bridge -> ProjectionImportBatchV1
101    /// -> semantic-memory import transaction`.
102    ///
103    /// Phase status: migration-only
104    /// Removal condition: remove when all consumers have migrated to `to_export_envelope_v3()`
105    #[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    /// Convert this rendering into Forge's compatibility-only V2 export envelope.
133    ///
134    /// This lane is retained for legacy consumers and tests only. New users
135    /// should call [`to_export_envelope_v3`](Self::to_export_envelope_v3) to
136    /// get canonical kernel-export semantics.
137    ///
138    /// Phase status: migration-only
139    /// Removal condition: remove when all consumers have migrated to `to_export_envelope_v3()`
140    #[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    /// Convert this rendering into Forge's canonical V3 export envelope.
173    ///
174    /// This is the production lane. It derives `ExportEnvelopeV3` directly
175    /// from the local bundle model and enriches records with concrete
176    /// semantics where the source bundle supplies them.
177    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                // Claims are grouped through claim/assertion identities. Constraint
455                // seeds are emitted by relation/episode records, so absence here is
456                // deliberate rather than accidental.
457                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
1089/// Compute a deterministic export key.
1090pub 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
1095/// Export an `ExperimentEvidenceBundle` through Forge's canonical export schema.
1096///
1097/// This is the normative path for the stack. It emits `ExportEnvelopeV3`,
1098/// persists an export receipt, and is the canonical path into the bridge.
1099pub 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/// Compatibility-only escape hatch: export and immediately import into memory.
1122///
1123/// The normal path is still caller-orchestrated:
1124/// `ExportEnvelopeV3 -> forge-memory-bridge -> semantic-memory::import_projection_batch()`.
1125#[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/// Compatibility-only escape hatch blocked unless `danger-sm-write` is enabled.
1178#[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}