Skip to main content

forge_engine/lab/
evidence.rs

1//! Evidence bundles, hypothesis edges, receipts, and verification plans.
2//!
3//! Phase 5 claim policy: this module produces PROVISIONAL LOCAL ATTRIBUTIONS
4//! from one paired baseline/patched run on one fixed workload slice.
5//! It does NOT produce robust causal confirmation, generalized effect claims,
6//! or production-level statistical guarantees, but it does model first-class
7//! verification trials and named falsification artifacts.
8
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12use crate::error::ForgeResult;
13use crate::experiment::{ExperimentDiff, TypedLocatedEffect};
14use crate::lab::evaluate::ScoreVector;
15use stack_ids::{AttemptId, ClaimVersionId, RelationVersionId, TrialId};
16
17const LOCAL_AUTHORING_METADATA_KEY: &str = "living_memory_authoring";
18
19// ── Claim strength ──
20
21/// How strong a causal claim this bundle makes.
22///
23/// Phase 5 only supports `ProvisionalSinglePair`.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ClaimStrength {
27    /// Provisional local attribution from one paired intervention on one fixed workload slice.
28    #[default]
29    ProvisionalSinglePair,
30}
31
32impl std::fmt::Display for ClaimStrength {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::ProvisionalSinglePair => {
36                write!(f, "provisional local attribution from one paired intervention on one fixed workload slice")
37            }
38        }
39    }
40}
41
42// ── Bundle scope ──
43
44/// Scope of the evidence bundle.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct BundleScope {
47    /// Which workload was used.
48    pub workload_id: String,
49    /// Backend family used for execution.
50    pub backend_family: String,
51    /// Ordered list of checks executed.
52    pub selected_checks: Vec<String>,
53    /// Effective timeout class for the run.
54    pub timeout_class: String,
55    /// Sorted config-flag projection affecting execution.
56    pub config_flags: Vec<String>,
57}
58
59// ── Covariates ──
60
61/// Measured covariates for a paired experiment.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct Covariates {
64    /// Hash of backend kind + OS/runtime/toolchain versions + allowed env vars.
65    pub env_fingerprint: String,
66    /// Hash of lockfile or dependency manifest snapshot.
67    pub dependency_fingerprint: Option<String>,
68    /// Sorted projection of execution-affecting config flags.
69    pub config_flags: Vec<String>,
70    /// Hash of test/benchmark names + input corpus id + selection parameters.
71    pub workload_id: String,
72    /// Canonical ordered list of executed checks.
73    pub selected_checks: Vec<String>,
74    /// Whether non-primary edits were present in the patched workspace.
75    pub adjacent_edits: bool,
76    /// Optional list of non-primary edit signatures.
77    #[serde(default)]
78    pub adjacent_edit_signatures: Vec<String>,
79}
80
81// ── Treatment ──
82
83/// The treatment applied in the experiment.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Treatment {
86    /// "baseline" or "patch_applied".
87    pub kind: String,
88    /// Content hash of the patch.
89    pub patch_hash: String,
90    /// Summary of the patch (files touched, edit count).
91    pub patch_summary: String,
92}
93
94// ── Receipt ──
95
96/// A receipt proving the existence and integrity of a trial artifact.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ReceiptRef {
99    /// Unique receipt identifier.
100    pub receipt_id: String,
101    /// What kind of artifact this receipt covers.
102    pub kind: ReceiptKind,
103    /// Where the artifact is stored.
104    pub storage: ReceiptStorage,
105    /// Blake3 hash of the artifact content.
106    pub content_hash: String,
107    /// Cross-crate trace ID, if available.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub trace_id: Option<String>,
110    /// Handle for replaying the artifact, if available.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub replay_handle: Option<String>,
113}
114
115impl ReceiptRef {
116    /// Verify that the content hash matches the given content.
117    pub fn verify_content(&self, content: &[u8]) -> bool {
118        let hash = blake3::hash(content).to_hex().to_string();
119        hash == self.content_hash
120    }
121}
122
123/// Kind of artifact a receipt covers.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum ReceiptKind {
127    TrialLog,
128    TrialMetrics,
129    CheckResult,
130    PatchApplicationRecord,
131}
132
133/// Where a receipt's artifact is stored.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum ReceiptStorage {
137    /// Stored inline in the receipt itself.
138    Inline(String),
139    /// Stored in the forge DB.
140    StoreRow { table: String, key: String },
141    /// Stored at a filesystem path.
142    ArtifactPath(String),
143}
144
145// ── Hypothesis edge ──
146
147/// A typed edge linking an edit operation to an observed effect.
148///
149/// This is the primary hypothesis structure for Phase 5.
150/// Edge: EditOpSignature -> LocatedEffect with a typed kind.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct HypothesisEdge {
153    /// Unique edge identifier.
154    pub edge_id: String,
155    /// Source: the edit operation signature (serialized).
156    pub source_edit: String,
157    /// Target: the observed effect (serialized).
158    pub target_effect: String,
159    /// What kind of causal relationship this edge represents.
160    pub kind: HypothesisEdgeKind,
161    /// Current status of this edge.
162    pub status: HypothesisStatus,
163    /// Confidence in this edge (0.0 to 1.0, heuristic for Phase 5).
164    pub confidence: f64,
165    /// Bundle IDs that provide evidence for this edge.
166    pub evidence_ids: Vec<String>,
167    /// Bundle IDs that contradict this edge.
168    pub contradiction_ids: Vec<String>,
169    /// Whether this edge has been verified by a follow-up experiment.
170    pub verification_status: VerificationState,
171}
172
173/// What kind of causal relationship a hypothesis edge represents.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum HypothesisEdgeKind {
177    /// The edit causes a regression (new failure in patched).
178    CausesRegression,
179    /// The edit fixes a failure (failure in baseline, absent in patched).
180    FixesFailure,
181    /// The edit is associated with a failure that is stable across both.
182    AssociatedWithStableFailure,
183}
184
185/// Verification state for an edge.
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum VerificationState {
189    #[default]
190    Unverified,
191    PlanGenerated,
192    VerificationPending,
193    Verified,
194    VerificationFailed,
195}
196
197// ── Evidence bundle ──
198
199/// Which execution context the trial belongs to.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum BaselineOrPatch {
203    /// Trial ran on the baseline workspace.
204    Baseline,
205    /// Trial ran on the patched workspace.
206    Patched,
207}
208
209/// Canonical execution trial for one side of a verification pair.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct VerificationTrial {
212    /// Unique trial identifier in one attempt.
213    pub trial_id: TrialId,
214    /// Logical retry family for related retry attempts.
215    pub attempt_id: AttemptId,
216    /// Baseline vs patched execution lane.
217    pub baseline_or_patch: BaselineOrPatch,
218    /// Whether this trial completed successfully.
219    pub completed: bool,
220    /// Artifact receipts tied to this trial.
221    #[serde(default)]
222    pub receipts: Vec<String>,
223}
224
225/// Outcome of a named refutation artifact.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum RefutationArtifactOutcome {
229    /// Refutation did not weaken the original claim.
230    Passed,
231    /// Refutation produced an explicit failure signal.
232    Failed {
233        /// Why the refutation was failed (invariant name, numeric counterexample, etc).
234        reason: String,
235    },
236    /// Refutation could not complete.
237    Inconclusive {
238        /// Why no definitive result was available.
239        reason: String,
240    },
241    /// Refutation was intentionally skipped.
242    Skipped {
243        /// Skip reason.
244        reason: String,
245    },
246}
247
248/// Type of named falsification artifact emitted by verification infrastructure.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum RefutationArtifactType {
252    /// Inert control treatment/no-op on treatment assignment.
253    Placebo,
254    /// Outcome intentionally randomized/nullified.
255    DummyOutcome,
256    /// Subsample split stability check.
257    SubsampleStability,
258}
259
260/// Falsification artifact carried by the evidence bundle.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct RefutationArtifact {
263    /// Artifact identity for traceability.
264    pub artifact_id: String,
265    /// Artifact type.
266    pub artifact_type: RefutationArtifactType,
267    /// Trial that emitted this artifact, when available.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub trial_id: Option<TrialId>,
270    /// Attempt that emitted this artifact, when available.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub attempt_id: Option<AttemptId>,
273    /// Refutation outcome, including fail/pass path.
274    pub outcome: RefutationArtifactOutcome,
275    /// Numeric stability or effect delta when available.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub estimate_delta: Option<f64>,
278    /// Optional structured details for durable debugging.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub details: Option<String>,
281}
282
283/// Which effect-derived export relation a lineage hint applies to.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub enum EffectRelationLineageSource {
287    /// The `primary_effect_*` export relation for the bundle.
288    PrimaryEffect,
289    /// One `all_effect_*` export relation for the bundle.
290    AllEffect,
291    /// One `experiment_diff_*` export relation for the bundle.
292    ExperimentDiff,
293}
294
295impl EffectRelationLineageSource {
296    /// Stable export source label used in relation predicates and metadata.
297    pub fn export_source_key(self) -> &'static str {
298        match self {
299            Self::PrimaryEffect => "primary_effect",
300            Self::AllEffect => "all_effect",
301            Self::ExperimentDiff => "experiment_diff",
302        }
303    }
304}
305
306/// Explicit relation-lineage hint for an effect-derived export relation.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct EffectRelationLineageHint {
309    /// Which effect export family this hint applies to.
310    pub source: EffectRelationLineageSource,
311    /// Effect kind to match.
312    pub kind: crate::experiment::EffectKind,
313    /// Effect file to match.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub file: Option<PathBuf>,
316    /// Effect line to match.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub line: Option<u32>,
319    /// Effect message to match.
320    pub message: String,
321    /// Whether the effect was present in the baseline execution.
322    pub in_baseline: bool,
323    /// Whether the effect was present in the patched execution.
324    pub in_patched: bool,
325    /// Real prior relation version when known.
326    pub supersedes_relation_version_id: RelationVersionId,
327}
328
329impl EffectRelationLineageHint {
330    fn matches(
331        &self,
332        source: EffectRelationLineageSource,
333        effect: &crate::experiment::TypedLocatedEffect,
334    ) -> bool {
335        self.source == source
336            && self.kind == effect.kind
337            && self.file == effect.file
338            && self.line == effect.line
339            && self.message == effect.message
340            && self.in_baseline == effect.in_baseline
341            && self.in_patched == effect.in_patched
342    }
343}
344
345/// Explicit relation-lineage hint for a hypothesis-edge export relation.
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct HypothesisRelationLineageHint {
348    /// Edge identity to match.
349    pub edge_id: String,
350    /// Real prior relation version when known.
351    pub supersedes_relation_version_id: RelationVersionId,
352}
353
354impl HypothesisRelationLineageHint {
355    fn matches(&self, edge: &HypothesisEdge) -> bool {
356        self.edge_id == edge.edge_id
357    }
358}
359
360/// Explicit relation-lineage hint for a verification-trial export relation.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct VerificationTrialRelationLineageHint {
363    /// Trial identity to match.
364    pub trial_id: TrialId,
365    /// Trial lane to match.
366    pub baseline_or_patch: BaselineOrPatch,
367    /// Attempt identity, when known and needed to disambiguate.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub attempt_id: Option<AttemptId>,
370    /// Real prior relation version when known.
371    pub supersedes_relation_version_id: RelationVersionId,
372}
373
374impl VerificationTrialRelationLineageHint {
375    fn matches(&self, trial: &VerificationTrial) -> bool {
376        self.trial_id == trial.trial_id
377            && self.baseline_or_patch == trial.baseline_or_patch
378            && match self.attempt_id.as_ref() {
379                Some(attempt_id) => attempt_id == &trial.attempt_id,
380                None => true,
381            }
382    }
383}
384
385/// Explicit relation-lineage hint for a refutation-artifact export relation.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387pub struct RefutationRelationLineageHint {
388    /// Artifact identity to match.
389    pub artifact_id: String,
390    /// Artifact type, when known and needed to disambiguate.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub artifact_type: Option<RefutationArtifactType>,
393    /// Real prior relation version when known.
394    pub supersedes_relation_version_id: RelationVersionId,
395}
396
397impl RefutationRelationLineageHint {
398    fn matches(&self, artifact: &RefutationArtifact) -> bool {
399        self.artifact_id == artifact.artifact_id
400            && match self.artifact_type {
401                Some(artifact_type) => artifact_type == artifact.artifact_type,
402                None => true,
403            }
404    }
405}
406
407/// Optional sidecar carrying known relation-version lineage for bundle export.
408#[derive(Debug, Clone, Default, Serialize, Deserialize)]
409pub struct RelationLineageHints {
410    /// Hints for effect-derived relations.
411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
412    pub effect_relations: Vec<EffectRelationLineageHint>,
413    /// Hints for hypothesis-edge relations.
414    #[serde(default, skip_serializing_if = "Vec::is_empty")]
415    pub hypothesis_relations: Vec<HypothesisRelationLineageHint>,
416    /// Hints for verification-trial relations.
417    #[serde(default, skip_serializing_if = "Vec::is_empty")]
418    pub verification_trial_relations: Vec<VerificationTrialRelationLineageHint>,
419    /// Hints for refutation-artifact relations.
420    #[serde(default, skip_serializing_if = "Vec::is_empty")]
421    pub refutation_relations: Vec<RefutationRelationLineageHint>,
422}
423
424impl RelationLineageHints {
425    /// Returns `true` when no relation-lineage hints are present.
426    pub fn is_empty(&self) -> bool {
427        self.effect_relations.is_empty()
428            && self.hypothesis_relations.is_empty()
429            && self.verification_trial_relations.is_empty()
430            && self.refutation_relations.is_empty()
431    }
432
433    fn effect_supersedes_relation_version_id(
434        &self,
435        source: EffectRelationLineageSource,
436        effect: &crate::experiment::TypedLocatedEffect,
437    ) -> Option<RelationVersionId> {
438        self.effect_relations
439            .iter()
440            .find(|hint| hint.matches(source, effect))
441            .map(|hint| hint.supersedes_relation_version_id.clone())
442    }
443
444    fn hypothesis_supersedes_relation_version_id(
445        &self,
446        edge: &HypothesisEdge,
447    ) -> Option<RelationVersionId> {
448        self.hypothesis_relations
449            .iter()
450            .find(|hint| hint.matches(edge))
451            .map(|hint| hint.supersedes_relation_version_id.clone())
452    }
453
454    fn verification_trial_supersedes_relation_version_id(
455        &self,
456        trial: &VerificationTrial,
457    ) -> Option<RelationVersionId> {
458        self.verification_trial_relations
459            .iter()
460            .find(|hint| hint.matches(trial))
461            .map(|hint| hint.supersedes_relation_version_id.clone())
462    }
463
464    fn refutation_supersedes_relation_version_id(
465        &self,
466        artifact: &RefutationArtifact,
467    ) -> Option<RelationVersionId> {
468        self.refutation_relations
469            .iter()
470            .find(|hint| hint.matches(artifact))
471            .map(|hint| hint.supersedes_relation_version_id.clone())
472    }
473}
474
475/// A bundle of evidence collected during a forge evaluation run.
476///
477/// One ExperimentEvidenceBundle = one paired experiment on one fixed workload slice.
478/// One bundle may contain multiple effects but names exactly one primary effect.
479/// Claim strength for Phase 5 is always `ProvisionalSinglePair`.
480///
481/// `semantic_memory_forge::EvidenceBundle` is the authoritative cross-crate
482/// evidence contract. This local type is an authoring wrapper that carries
483/// forge-engine-only working fields and round-trips them explicitly through the
484/// canonical bundle metadata under [`LOCAL_AUTHORING_METADATA_KEY`].
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct ExperimentEvidenceBundle {
487    /// Unique identifier for this bundle.
488    pub bundle_id: String,
489    /// The candidate that produced this evidence.
490    pub candidate_id: String,
491    /// Eval run that generated the evidence.
492    pub eval_id: String,
493    /// Version of the algebra spec used.
494    pub version_id: String,
495    /// Real prior claim-lineage version when known.
496    ///
497    /// This is optional and carried through to the export envelope so that
498    /// downstream projection imports can preserve version-aware supersession.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub supersedes_claim_version_id: Option<ClaimVersionId>,
501    /// Explicit relation-version lineage hints for exportable relations, when known.
502    ///
503    /// These hints are advisory only. They do not mint lineage, and export leaves
504    /// `supersedes_relation_version_id` unset when no exact hint is present.
505    #[serde(default, skip_serializing_if = "RelationLineageHints::is_empty")]
506    pub relation_lineage_hints: RelationLineageHints,
507    /// Computed scores.
508    pub scores: ScoreVector,
509    /// Causal hypotheses derived from this run (legacy field, prefer hypothesis_edges).
510    pub hypotheses: Vec<CausalHypothesis>,
511    /// Verification plan for follow-up validation.
512    pub verification: Option<VerificationPlan>,
513    /// Trace ID for cross-crate correlation.
514    pub trace_id: Option<String>,
515    /// Typed experiment diff (baseline vs patched), if from a paired experiment.
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub experiment_diff: Option<ExperimentDiff>,
518    /// Attribution result from CEA, if available.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub attribution_json: Option<String>,
521    /// Deterministic evidence assessment.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub assessment: Option<EvidenceAssessment>,
524    /// Warnings generated during evidence collection.
525    #[serde(default)]
526    pub warnings: Vec<String>,
527    /// When this bundle was recorded by Forge.
528    #[serde(default = "default_bundle_created_at")]
529    pub created_at: String,
530
531    // ── Phase 5 fields (all have serde defaults for backward compat) ──
532    /// Forge domain ID for the experiment run.
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub run_id: Option<String>,
535    /// Forge domain ID for the patch attempt.
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub attempt_id: Option<String>,
538    /// The causal question this bundle answers.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub causal_question: Option<String>,
541    /// Explicit description of the unit of observation.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub unit_definition: Option<String>,
544    /// Scope of this bundle.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub bundle_scope: Option<BundleScope>,
547    /// Explicit paired comparability verdict when the source captured it.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub pair_comparability: Option<PairComparability>,
550    /// How strong a claim this bundle makes.
551    #[serde(default)]
552    pub claim_strength: ClaimStrength,
553    /// Why we think confounding is controlled.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub identification_rationale: Option<String>,
556    /// Known threats to validity.
557    #[serde(default)]
558    pub known_threats: Vec<String>,
559    /// Content hash of the patch.
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub patch_hash: Option<String>,
562    /// The treatment applied.
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub treatment: Option<Treatment>,
565    /// Outcome summary.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub outcome: Option<String>,
568    /// Measured covariates.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub covariates: Option<Covariates>,
571    /// Promotion state for the claim represented by this bundle, when known.
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub promotion_state: Option<semantic_memory_forge::PromotionState>,
574    /// The primary effect attributed to the patch.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub primary_effect: Option<TypedLocatedEffect>,
577    /// All effects observed in the experiment.
578    #[serde(default)]
579    pub all_effects: Vec<TypedLocatedEffect>,
580    /// Typed hypothesis edges (Phase 5 edge model).
581    #[serde(default)]
582    pub hypothesis_edges: Vec<HypothesisEdge>,
583    /// Receipts proving artifact integrity.
584    #[serde(default)]
585    pub receipts: Vec<ReceiptRef>,
586    /// Canonical per-side verification trials for this causal claim.
587    #[serde(default)]
588    pub verification_trials: Vec<VerificationTrial>,
589    /// Refutation artifacts generated by verification infrastructure.
590    #[serde(default)]
591    pub refutation_artifacts: Vec<RefutationArtifact>,
592    /// Whether this bundle is sealed (immutable after creation).
593    #[serde(default)]
594    pub sealed: bool,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
598struct LocalEvidenceBundleAuthoring {
599    candidate_id: String,
600    eval_id: String,
601    version_id: String,
602    supersedes_claim_version_id: Option<ClaimVersionId>,
603    relation_lineage_hints: RelationLineageHints,
604    scores: ScoreVector,
605    hypotheses: Vec<CausalHypothesis>,
606    verification: Option<VerificationPlan>,
607    experiment_diff: Option<ExperimentDiff>,
608    attribution_json: Option<String>,
609    assessment: Option<EvidenceAssessment>,
610    warnings: Vec<String>,
611    run_id: Option<String>,
612    attempt_id: Option<String>,
613    pair_comparability: Option<PairComparability>,
614    claim_strength: ClaimStrength,
615    known_threats: Vec<String>,
616    patch_hash: Option<String>,
617    treatment: Option<Treatment>,
618    covariates: Option<Covariates>,
619    primary_effect: Option<TypedLocatedEffect>,
620    all_effects: Vec<TypedLocatedEffect>,
621    hypothesis_edges: Vec<HypothesisEdge>,
622    receipts: Vec<ReceiptRef>,
623    verification_trials: Vec<VerificationTrial>,
624    refutation_artifacts: Vec<RefutationArtifact>,
625    sealed: bool,
626}
627
628impl LocalEvidenceBundleAuthoring {
629    fn from_bundle(bundle: &ExperimentEvidenceBundle) -> Self {
630        Self {
631            candidate_id: bundle.candidate_id.clone(),
632            eval_id: bundle.eval_id.clone(),
633            version_id: bundle.version_id.clone(),
634            supersedes_claim_version_id: bundle.supersedes_claim_version_id.clone(),
635            relation_lineage_hints: bundle.relation_lineage_hints.clone(),
636            scores: bundle.scores.clone(),
637            hypotheses: bundle.hypotheses.clone(),
638            verification: bundle.verification.clone(),
639            experiment_diff: bundle.experiment_diff.clone(),
640            attribution_json: bundle.attribution_json.clone(),
641            assessment: bundle.assessment.clone(),
642            warnings: bundle.warnings.clone(),
643            run_id: bundle.run_id.clone(),
644            attempt_id: bundle.attempt_id.clone(),
645            pair_comparability: bundle.pair_comparability.clone(),
646            claim_strength: bundle.claim_strength,
647            known_threats: bundle.known_threats.clone(),
648            patch_hash: bundle.patch_hash.clone(),
649            treatment: bundle.treatment.clone(),
650            covariates: bundle.covariates.clone(),
651            primary_effect: bundle.primary_effect.clone(),
652            all_effects: bundle.all_effects.clone(),
653            hypothesis_edges: bundle.hypothesis_edges.clone(),
654            receipts: bundle.receipts.clone(),
655            verification_trials: bundle.verification_trials.clone(),
656            refutation_artifacts: bundle.refutation_artifacts.clone(),
657            sealed: bundle.sealed,
658        }
659    }
660}
661
662impl ExperimentEvidenceBundle {
663    /// Seal the bundle, making it logically immutable.
664    /// Returns an error if the bundle is already sealed.
665    pub fn seal(&mut self) -> ForgeResult<()> {
666        if self.sealed {
667            return Err(crate::error::ForgeError::Other(
668                "bundle is already sealed".into(),
669            ));
670        }
671        self.sealed = true;
672        Ok(())
673    }
674
675    /// Returns an error if the bundle is sealed and mutation is attempted.
676    pub fn check_not_sealed(&self) -> ForgeResult<()> {
677        if self.sealed {
678            return Err(crate::error::ForgeError::Other(
679                "cannot mutate sealed bundle".into(),
680            ));
681        }
682        Ok(())
683    }
684
685    /// Project this local bundle into the canonical Forge evidence contract.
686    ///
687    /// The canonical `semantic_memory_forge::EvidenceBundle` remains the
688    /// authoritative cross-crate contract. Local forge-engine-only authoring
689    /// fields are preserved explicitly inside canonical metadata rather than
690    /// becoming a second drifting wire contract.
691    pub fn to_canonical_evidence_bundle(&self) -> semantic_memory_forge::EvidenceBundle {
692        let question = semantic_memory_forge::CausalQuestion {
693            description: self.causal_question.clone().unwrap_or_else(|| {
694                let treatment = self
695                    .treatment
696                    .as_ref()
697                    .map(|t| t.patch_summary.as_str())
698                    .unwrap_or("applied patch");
699                let outcome = self.outcome.as_deref().unwrap_or("recorded outcome");
700                let workload = self
701                    .bundle_scope
702                    .as_ref()
703                    .map(|scope| scope.workload_id.as_str())
704                    .unwrap_or("unspecified workload");
705                let scope_summary = self
706                    .bundle_scope
707                    .as_ref()
708                    .map(|scope| scope.backend_family.as_str())
709                    .unwrap_or("unspecified scope");
710                Self::render_causal_question(treatment, outcome, workload, scope_summary)
711            }),
712            unit_definition: self
713                .unit_definition
714                .clone()
715                .unwrap_or_else(|| "paired experiment run".into()),
716        };
717
718        let treatment = semantic_memory_forge::TreatmentSpec {
719            description: self
720                .treatment
721                .as_ref()
722                .map(|t| format!("{} [{}]", t.patch_summary, t.patch_hash))
723                .unwrap_or_else(|| "patch applied".into()),
724            baseline_description: "baseline workspace without patch".into(),
725            paired_trials: self.experiment_diff.is_some() || !self.verification_trials.is_empty(),
726        };
727
728        let outcome = semantic_memory_forge::OutcomeSpec {
729            description: self
730                .outcome
731                .clone()
732                .unwrap_or_else(|| "bundle outcome summary".into()),
733            measurement_method: if self.verification_trials.is_empty() {
734                "single_pair_execution_observation".into()
735            } else {
736                "paired_verification_trials".into()
737            },
738            outcome_type: if self.primary_effect.is_some() {
739                "typed_effect".into()
740            } else {
741                "bundle_summary".into()
742            },
743        };
744
745        let mut covariates = Vec::new();
746        if let Some(bundle_scope) = &self.bundle_scope {
747            covariates.push(format!("workload:{}", bundle_scope.workload_id));
748            covariates.push(format!("backend:{}", bundle_scope.backend_family));
749            covariates.push(format!("timeout_class:{}", bundle_scope.timeout_class));
750            covariates.extend(
751                bundle_scope
752                    .selected_checks
753                    .iter()
754                    .map(|check| format!("check:{check}")),
755            );
756            covariates.extend(
757                bundle_scope
758                    .config_flags
759                    .iter()
760                    .map(|flag| format!("config_flag:{flag}")),
761            );
762        }
763        if let Some(covariates_meta) = &self.covariates {
764            covariates.push(format!(
765                "env_fingerprint:{}",
766                covariates_meta.env_fingerprint
767            ));
768            covariates.push(format!("workload:{}", covariates_meta.workload_id));
769            covariates.extend(
770                covariates_meta
771                    .selected_checks
772                    .iter()
773                    .map(|check| format!("selected_check:{check}")),
774            );
775            covariates.extend(
776                covariates_meta
777                    .config_flags
778                    .iter()
779                    .map(|flag| format!("covariate_flag:{flag}")),
780            );
781            if let Some(dependency_fingerprint) = &covariates_meta.dependency_fingerprint {
782                covariates.push(format!("dependency_fingerprint:{dependency_fingerprint}"));
783            }
784            if covariates_meta.adjacent_edits {
785                covariates.push("adjacent_edits:true".into());
786            }
787            covariates.extend(
788                covariates_meta
789                    .adjacent_edit_signatures
790                    .iter()
791                    .map(|sig| format!("adjacent_edit:{sig}")),
792            );
793        }
794        covariates.extend(
795            self.known_threats
796                .iter()
797                .map(|threat| format!("known_threat:{threat}")),
798        );
799
800        let refutations = self
801            .refutation_artifacts
802            .iter()
803            .map(|artifact| semantic_memory_forge::RefutationAttempt {
804                method: format!("{:?}", artifact.artifact_type).to_lowercase(),
805                result: match &artifact.outcome {
806                    RefutationArtifactOutcome::Passed => {
807                        semantic_memory_forge::RefutationResult::Passed {
808                            estimate_change: artifact.estimate_delta,
809                        }
810                    }
811                    RefutationArtifactOutcome::Failed { reason } => {
812                        semantic_memory_forge::RefutationResult::Failed {
813                            reason: reason.clone(),
814                            estimate_change: artifact.estimate_delta,
815                        }
816                    }
817                    RefutationArtifactOutcome::Inconclusive { reason } => {
818                        semantic_memory_forge::RefutationResult::Inconclusive {
819                            reason: reason.clone(),
820                        }
821                    }
822                    RefutationArtifactOutcome::Skipped { reason } => {
823                        semantic_memory_forge::RefutationResult::Skipped {
824                            reason: reason.clone(),
825                        }
826                    }
827                },
828                estimator_kind: Some("living_memory_phase5_scorevector".into()),
829                parameters: artifact.details.as_ref().map(|details| {
830                    serde_json::json!({
831                        "artifact_id": artifact.artifact_id,
832                        "details": details,
833                    })
834                }),
835            })
836            .collect();
837
838        let refutation_artifacts = self
839            .refutation_artifacts
840            .iter()
841            .map(|artifact| semantic_memory_forge::RefutationArtifactRecord {
842                artifact_id: artifact.artifact_id.clone(),
843                artifact_type: Self::debug_name_to_snake_case(&format!(
844                    "{:?}",
845                    artifact.artifact_type
846                )),
847                trial_id: artifact.trial_id.clone(),
848                attempt_id: artifact.attempt_id.clone(),
849                result: match &artifact.outcome {
850                    RefutationArtifactOutcome::Passed => {
851                        semantic_memory_forge::RefutationResult::Passed {
852                            estimate_change: artifact.estimate_delta,
853                        }
854                    }
855                    RefutationArtifactOutcome::Failed { reason } => {
856                        semantic_memory_forge::RefutationResult::Failed {
857                            reason: reason.clone(),
858                            estimate_change: artifact.estimate_delta,
859                        }
860                    }
861                    RefutationArtifactOutcome::Inconclusive { reason } => {
862                        semantic_memory_forge::RefutationResult::Inconclusive {
863                            reason: reason.clone(),
864                        }
865                    }
866                    RefutationArtifactOutcome::Skipped { reason } => {
867                        semantic_memory_forge::RefutationResult::Skipped {
868                            reason: reason.clone(),
869                        }
870                    }
871                },
872                estimate_delta: artifact.estimate_delta,
873                details: artifact.details.clone(),
874            })
875            .collect::<Vec<_>>();
876
877        let verification_trials = self
878            .verification_trials
879            .iter()
880            .map(|trial| semantic_memory_forge::VerificationTrialRecord {
881                trial_id: trial.trial_id.clone(),
882                attempt_id: trial.attempt_id.clone(),
883                side: match trial.baseline_or_patch {
884                    BaselineOrPatch::Baseline => {
885                        semantic_memory_forge::VerificationTrialSide::Baseline
886                    }
887                    BaselineOrPatch::Patched => {
888                        semantic_memory_forge::VerificationTrialSide::Patched
889                    }
890                },
891                completed: trial.completed,
892                receipt_handles: trial.receipts.clone(),
893            })
894            .collect::<Vec<_>>();
895
896        let comparability_snapshot =
897            self.bundle_scope
898                .as_ref()
899                .map(|scope| semantic_memory_forge::ComparabilitySnapshot {
900                    workload_id: scope.workload_id.clone(),
901                    backend_family: scope.backend_family.clone(),
902                    selected_checks: scope.selected_checks.clone(),
903                    timeout_class: scope.timeout_class.clone(),
904                    config_flags: scope.config_flags.clone(),
905                    comparable: self.pair_comparability.as_ref().map(|pair| pair.valid),
906                    violations: self
907                        .pair_comparability
908                        .as_ref()
909                        .map(|pair| pair.violations.clone())
910                        .unwrap_or_default(),
911                });
912
913        let raw_receipt_handle = self.receipts.first().map(|receipt| match &receipt.storage {
914            ReceiptStorage::Inline(_) => format!("receipt:inline:{}", receipt.receipt_id),
915            ReceiptStorage::StoreRow { table, key } => {
916                format!("receipt:store:{table}:{key}")
917            }
918            ReceiptStorage::ArtifactPath(path) => format!("receipt:path:{path}"),
919        });
920
921        let confidence = self
922            .assessment
923            .as_ref()
924            .map(|assessment| match assessment.sample_support {
925                SampleSupport::Sufficient => 0.9,
926                SampleSupport::Marginal => 0.6,
927                SampleSupport::Insufficient => 0.3,
928            })
929            .unwrap_or_else(|| self.scores.weighted_total.clamp(0.0, 1.0) as f32);
930
931        let completed_trial_count = self
932            .verification_trials
933            .iter()
934            .filter(|trial| trial.completed)
935            .count() as u32;
936        let passed_refutation_count = self
937            .refutation_artifacts
938            .iter()
939            .filter(|artifact| matches!(artifact.outcome, RefutationArtifactOutcome::Passed))
940            .count() as u32;
941        let failed_refutation_count = self
942            .refutation_artifacts
943            .iter()
944            .filter(|artifact| matches!(artifact.outcome, RefutationArtifactOutcome::Failed { .. }))
945            .count() as u32;
946        let lifecycle_state = if self.assessment.as_ref().is_some_and(|assessment| {
947            assessment.contradiction_state == ContradictionState::HasContradictions
948        }) || failed_refutation_count > 0
949        {
950            semantic_memory_forge::VerificationLifecycleState::Contradicted
951        } else if completed_trial_count > 0
952            || self.assessment.as_ref().is_some_and(|assessment| {
953                matches!(
954                    assessment.reproducibility,
955                    AssessmentCategory::Strong | AssessmentCategory::Adequate
956                )
957            })
958        {
959            semantic_memory_forge::VerificationLifecycleState::Verified
960        } else {
961            semantic_memory_forge::VerificationLifecycleState::Unverified
962        };
963
964        let derived_promotion_state = self.promotion_state.clone().unwrap_or_else(|| {
965            if let Some(assessment) = self.assessment.as_ref() {
966                if assessment.contradiction_state == ContradictionState::HasContradictions {
967                    semantic_memory_forge::PromotionState::Blocked {
968                        reason: "contradictions_present".into(),
969                    }
970                } else if assessment.sample_support == SampleSupport::Insufficient {
971                    semantic_memory_forge::PromotionState::Blocked {
972                        reason: "sample_support_insufficient".into(),
973                    }
974                } else if assessment.isolation != AssessmentCategory::Strong {
975                    semantic_memory_forge::PromotionState::Blocked {
976                        reason: "isolation_not_strong".into(),
977                    }
978                } else if matches!(
979                    assessment.reproducibility,
980                    AssessmentCategory::Strong | AssessmentCategory::Adequate
981                ) {
982                    semantic_memory_forge::PromotionState::Eligible
983                } else {
984                    semantic_memory_forge::PromotionState::NotPromoted
985                }
986            } else {
987                semantic_memory_forge::PromotionState::NotPromoted
988            }
989        });
990
991        let mut verification_notes = self.warnings.clone();
992        if self.supersedes_claim_version_id.is_some() {
993            verification_notes.push("supersedes_prior_claim_version".into());
994        }
995
996        let estimator_meta = Some(semantic_memory_forge::EstimatorMeta {
997            kind: semantic_memory_forge::EstimatorKind::Custom(
998                "living_memory_phase5_scorevector".into(),
999            ),
1000            version: self.version_id.clone(),
1001            parameters: serde_json::json!({
1002                "weighted_total": self.scores.weighted_total,
1003                "correctness": self.scores.correctness,
1004                "novelty": self.scores.novelty,
1005                "stability": self.scores.stability,
1006                "claim_strength": format!("{}", self.claim_strength),
1007            }),
1008            random_seed: None,
1009            environment: self.covariates.as_ref().map(|covariates| {
1010                semantic_memory_forge::EnvironmentFingerprint {
1011                    python_version: None,
1012                    package_versions: serde_json::json!({
1013                        "selected_checks": covariates.selected_checks,
1014                        "config_flags": covariates.config_flags,
1015                    }),
1016                    platform: None,
1017                    env_hash: Some(covariates.env_fingerprint.clone()),
1018                }
1019            }),
1020            timeout_secs: None,
1021            failure_mode: None,
1022            request_schema_version: Some("living_memory.phase5.bundle.v1".into()),
1023            response_schema_version: Some("semantic_memory_forge.evidence_bundle.v3".into()),
1024        });
1025
1026        semantic_memory_forge::EvidenceBundle {
1027            id: semantic_memory_forge::EvidenceBundleId::new(self.bundle_id.clone()),
1028            question,
1029            treatment,
1030            outcome,
1031            covariates,
1032            identification_rationale: self
1033                .identification_rationale
1034                .clone()
1035                .unwrap_or_else(|| "phase5_local_bundle_adapter".into()),
1036            estimator_kind: "living_memory_phase5_scorevector".into(),
1037            estimator_version: self.version_id.clone(),
1038            estimator_meta,
1039            estimate: self.scores.weighted_total,
1040            estimate_uncertainty: None,
1041            confidence,
1042            trial_count: self.verification_trials.len().max(1) as u32,
1043            variance_aware: self.verification_trials.len() > 1,
1044            verification_trials,
1045            comparability_snapshot,
1046            refutations,
1047            refutation_artifacts,
1048            verification_summary: Some(semantic_memory_forge::VerificationSummary {
1049                lifecycle_state,
1050                promotion_state: derived_promotion_state.clone(),
1051                completed_trial_count,
1052                passed_refutation_count,
1053                failed_refutation_count,
1054                comparability_snapshot_version: self.bundle_scope.as_ref().map(|scope| {
1055                    format!(
1056                        "{}:{}:{}",
1057                        scope.workload_id, scope.backend_family, scope.timeout_class
1058                    )
1059                }),
1060                notes: verification_notes,
1061            }),
1062            raw_receipt_handle,
1063            trace_ctx: self
1064                .trace_id
1065                .as_ref()
1066                .map(|trace_id| stack_ids::TraceCtx::from_trace_id(trace_id.as_str())),
1067            attempt_id: self
1068                .verification_trials
1069                .first()
1070                .map(|trial| trial.attempt_id.clone())
1071                .or_else(|| {
1072                    self.attempt_id
1073                        .as_ref()
1074                        .map(|attempt| AttemptId::new(attempt.clone()))
1075                }),
1076            trial_id: self
1077                .verification_trials
1078                .first()
1079                .map(|trial| trial.trial_id.clone()),
1080            replay_handle: self
1081                .receipts
1082                .iter()
1083                .find_map(|receipt| receipt.replay_handle.clone()),
1084            source_envelope_id: None,
1085            claim_ids: Vec::new(),
1086            created_at: self.created_at.clone(),
1087            comparability_snapshot_version: self.bundle_scope.as_ref().map(|scope| {
1088                format!(
1089                    "{}:{}:{}",
1090                    scope.workload_id, scope.backend_family, scope.timeout_class
1091                )
1092            }),
1093            metadata: Some(serde_json::json!({
1094                "bundle_id": self.bundle_id,
1095                "candidate_id": self.candidate_id,
1096                "eval_id": self.eval_id,
1097                "version_id": self.version_id,
1098                "claim_strength": format!("{}", self.claim_strength),
1099                "assessment": self.assessment,
1100                "pair_comparability": self.pair_comparability,
1101                "promotion_state": derived_promotion_state,
1102                "receipt_count": self.receipts.len(),
1103                "verification_trial_count": self.verification_trials.len(),
1104                "refutation_artifact_count": self.refutation_artifacts.len(),
1105                "canonical_owner": "semantic_memory_forge::EvidenceBundle",
1106                LOCAL_AUTHORING_METADATA_KEY: LocalEvidenceBundleAuthoring::from_bundle(self),
1107            })),
1108        }
1109    }
1110
1111    /// Rebuild the local authoring wrapper from the canonical Forge evidence contract.
1112    ///
1113    /// The canonical bundle is authoritative. When local authoring metadata is
1114    /// present, it is used only to recover forge-engine-specific working fields
1115    /// that are intentionally outside the canonical contract.
1116    pub fn from_canonical_evidence_bundle(
1117        canonical: &semantic_memory_forge::EvidenceBundle,
1118    ) -> ForgeResult<Self> {
1119        let authoring = canonical
1120            .metadata
1121            .as_ref()
1122            .and_then(|metadata| metadata.get(LOCAL_AUTHORING_METADATA_KEY))
1123            .cloned()
1124            .map(serde_json::from_value::<LocalEvidenceBundleAuthoring>)
1125            .transpose()?;
1126
1127        let bundle_scope = canonical
1128            .comparability_snapshot
1129            .as_ref()
1130            .map(|snapshot| BundleScope {
1131                workload_id: snapshot.workload_id.clone(),
1132                backend_family: snapshot.backend_family.clone(),
1133                selected_checks: snapshot.selected_checks.clone(),
1134                timeout_class: snapshot.timeout_class.clone(),
1135                config_flags: snapshot.config_flags.clone(),
1136            });
1137
1138        let pair_comparability = authoring
1139            .as_ref()
1140            .and_then(|authoring| authoring.pair_comparability.clone())
1141            .or_else(|| {
1142                canonical
1143                    .comparability_snapshot
1144                    .as_ref()
1145                    .map(|snapshot| PairComparability {
1146                        valid: snapshot.comparable.unwrap_or(false),
1147                        violations: snapshot.violations.clone(),
1148                    })
1149            });
1150
1151        let treatment = authoring
1152            .as_ref()
1153            .and_then(|authoring| authoring.treatment.clone())
1154            .or_else(|| {
1155                if canonical.treatment.description.is_empty() {
1156                    None
1157                } else {
1158                    Some(Treatment {
1159                        kind: "patch_applied".into(),
1160                        patch_hash: String::new(),
1161                        patch_summary: canonical.treatment.description.clone(),
1162                    })
1163                }
1164            });
1165
1166        let covariates = authoring
1167            .as_ref()
1168            .and_then(|authoring| authoring.covariates.clone());
1169
1170        let score_fallback = ScoreVector {
1171            correctness: canonical.estimate,
1172            novelty: 0.0,
1173            stability: canonical.confidence as f64,
1174            weighted_total: canonical.estimate,
1175            cea_confidence: None,
1176            cea_predicted_correctness: None,
1177        };
1178
1179        Ok(Self {
1180            bundle_id: canonical.id.as_str().to_string(),
1181            candidate_id: authoring
1182                .as_ref()
1183                .map(|authoring| authoring.candidate_id.clone())
1184                .or_else(|| {
1185                    canonical.metadata.as_ref().and_then(|metadata| {
1186                        metadata
1187                            .get("candidate_id")
1188                            .and_then(serde_json::Value::as_str)
1189                            .map(ToOwned::to_owned)
1190                    })
1191                })
1192                .unwrap_or_else(|| "unknown_candidate".into()),
1193            eval_id: authoring
1194                .as_ref()
1195                .map(|authoring| authoring.eval_id.clone())
1196                .or_else(|| {
1197                    canonical.metadata.as_ref().and_then(|metadata| {
1198                        metadata
1199                            .get("eval_id")
1200                            .and_then(serde_json::Value::as_str)
1201                            .map(ToOwned::to_owned)
1202                    })
1203                })
1204                .unwrap_or_else(|| "unknown_eval".into()),
1205            version_id: authoring
1206                .as_ref()
1207                .map(|authoring| authoring.version_id.clone())
1208                .or_else(|| {
1209                    canonical.metadata.as_ref().and_then(|metadata| {
1210                        metadata
1211                            .get("version_id")
1212                            .and_then(serde_json::Value::as_str)
1213                            .map(ToOwned::to_owned)
1214                    })
1215                })
1216                .unwrap_or_else(|| canonical.estimator_version.clone()),
1217            supersedes_claim_version_id: authoring
1218                .as_ref()
1219                .and_then(|authoring| authoring.supersedes_claim_version_id.clone()),
1220            relation_lineage_hints: authoring
1221                .as_ref()
1222                .map(|authoring| authoring.relation_lineage_hints.clone())
1223                .unwrap_or_default(),
1224            scores: authoring
1225                .as_ref()
1226                .map(|authoring| authoring.scores.clone())
1227                .unwrap_or(score_fallback),
1228            hypotheses: authoring
1229                .as_ref()
1230                .map(|authoring| authoring.hypotheses.clone())
1231                .unwrap_or_default(),
1232            verification: authoring
1233                .as_ref()
1234                .and_then(|authoring| authoring.verification.clone()),
1235            trace_id: canonical
1236                .trace_ctx
1237                .as_ref()
1238                .map(|trace_ctx| trace_ctx.trace_id.clone()),
1239            experiment_diff: authoring
1240                .as_ref()
1241                .and_then(|authoring| authoring.experiment_diff.clone()),
1242            attribution_json: authoring
1243                .as_ref()
1244                .and_then(|authoring| authoring.attribution_json.clone()),
1245            assessment: authoring
1246                .as_ref()
1247                .and_then(|authoring| authoring.assessment.clone()),
1248            warnings: authoring
1249                .as_ref()
1250                .map(|authoring| authoring.warnings.clone())
1251                .unwrap_or_default(),
1252            created_at: canonical.created_at.clone(),
1253            run_id: authoring
1254                .as_ref()
1255                .and_then(|authoring| authoring.run_id.clone()),
1256            attempt_id: canonical
1257                .attempt_id
1258                .as_ref()
1259                .map(|attempt_id| attempt_id.as_str().to_string())
1260                .or_else(|| {
1261                    authoring
1262                        .as_ref()
1263                        .and_then(|authoring| authoring.attempt_id.clone())
1264                }),
1265            causal_question: Some(canonical.question.description.clone()),
1266            unit_definition: Some(canonical.question.unit_definition.clone()),
1267            bundle_scope,
1268            pair_comparability,
1269            claim_strength: authoring
1270                .as_ref()
1271                .map(|authoring| authoring.claim_strength)
1272                .unwrap_or_default(),
1273            identification_rationale: Some(canonical.identification_rationale.clone()),
1274            known_threats: authoring
1275                .as_ref()
1276                .map(|authoring| authoring.known_threats.clone())
1277                .unwrap_or_default(),
1278            patch_hash: authoring
1279                .as_ref()
1280                .and_then(|authoring| authoring.patch_hash.clone()),
1281            treatment,
1282            outcome: Some(canonical.outcome.description.clone()),
1283            covariates,
1284            promotion_state: canonical
1285                .verification_summary
1286                .as_ref()
1287                .map(|summary| summary.promotion_state.clone()),
1288            primary_effect: authoring
1289                .as_ref()
1290                .and_then(|authoring| authoring.primary_effect.clone()),
1291            all_effects: authoring
1292                .as_ref()
1293                .map(|authoring| authoring.all_effects.clone())
1294                .unwrap_or_default(),
1295            hypothesis_edges: authoring
1296                .as_ref()
1297                .map(|authoring| authoring.hypothesis_edges.clone())
1298                .unwrap_or_default(),
1299            receipts: authoring
1300                .as_ref()
1301                .map(|authoring| authoring.receipts.clone())
1302                .unwrap_or_default(),
1303            verification_trials: if let Some(authoring) = authoring.as_ref() {
1304                authoring.verification_trials.clone()
1305            } else {
1306                canonical
1307                    .verification_trials
1308                    .iter()
1309                    .map(|trial| VerificationTrial {
1310                        trial_id: trial.trial_id.clone(),
1311                        attempt_id: trial.attempt_id.clone(),
1312                        baseline_or_patch: match trial.side {
1313                            semantic_memory_forge::VerificationTrialSide::Baseline => {
1314                                BaselineOrPatch::Baseline
1315                            }
1316                            semantic_memory_forge::VerificationTrialSide::Patched => {
1317                                BaselineOrPatch::Patched
1318                            }
1319                        },
1320                        completed: trial.completed,
1321                        receipts: trial.receipt_handles.clone(),
1322                    })
1323                    .collect()
1324            },
1325            refutation_artifacts: if let Some(authoring) = authoring.as_ref() {
1326                authoring.refutation_artifacts.clone()
1327            } else {
1328                canonical
1329                    .refutation_artifacts
1330                    .iter()
1331                    .map(|artifact| RefutationArtifact {
1332                        artifact_id: artifact.artifact_id.clone(),
1333                        artifact_type: match artifact.artifact_type.as_str() {
1334                            "placebo" => RefutationArtifactType::Placebo,
1335                            "dummy_outcome" => RefutationArtifactType::DummyOutcome,
1336                            _ => RefutationArtifactType::SubsampleStability,
1337                        },
1338                        trial_id: artifact.trial_id.clone(),
1339                        attempt_id: artifact.attempt_id.clone(),
1340                        outcome: match &artifact.result {
1341                            semantic_memory_forge::RefutationResult::Passed { .. } => {
1342                                RefutationArtifactOutcome::Passed
1343                            }
1344                            semantic_memory_forge::RefutationResult::Failed { reason, .. } => {
1345                                RefutationArtifactOutcome::Failed {
1346                                    reason: reason.clone(),
1347                                }
1348                            }
1349                            semantic_memory_forge::RefutationResult::Inconclusive { reason } => {
1350                                RefutationArtifactOutcome::Inconclusive {
1351                                    reason: reason.clone(),
1352                                }
1353                            }
1354                            semantic_memory_forge::RefutationResult::Skipped { reason } => {
1355                                RefutationArtifactOutcome::Skipped {
1356                                    reason: reason.clone(),
1357                                }
1358                            }
1359                        },
1360                        estimate_delta: artifact.estimate_delta,
1361                        details: artifact.details.clone(),
1362                    })
1363                    .collect()
1364            },
1365            sealed: authoring
1366                .as_ref()
1367                .map(|authoring| authoring.sealed)
1368                .unwrap_or(false),
1369        })
1370    }
1371
1372    /// Render the causal question for this bundle.
1373    pub fn render_causal_question(
1374        patch_summary: &str,
1375        outcome_summary: &str,
1376        workload_id: &str,
1377        scope_summary: &str,
1378    ) -> String {
1379        format!(
1380            "Did patch {} change outcome {} on workload {} under scope {}?",
1381            patch_summary, outcome_summary, workload_id, scope_summary
1382        )
1383    }
1384
1385    fn debug_name_to_snake_case(name: &str) -> String {
1386        let mut rendered = String::with_capacity(name.len() + 4);
1387        for (idx, ch) in name.chars().enumerate() {
1388            if ch.is_uppercase() {
1389                if idx > 0 {
1390                    rendered.push('_');
1391                }
1392                for lower in ch.to_lowercase() {
1393                    rendered.push(lower);
1394                }
1395            } else {
1396                rendered.push(ch);
1397            }
1398        }
1399        rendered
1400    }
1401
1402    /// Return the prior relation version for an effect-derived export relation, when known.
1403    pub fn superseded_effect_relation_version_id(
1404        &self,
1405        source: EffectRelationLineageSource,
1406        effect: &crate::experiment::TypedLocatedEffect,
1407    ) -> Option<RelationVersionId> {
1408        self.relation_lineage_hints
1409            .effect_supersedes_relation_version_id(source, effect)
1410    }
1411
1412    /// Return the prior relation version for a hypothesis-edge export relation, when known.
1413    pub fn superseded_hypothesis_relation_version_id(
1414        &self,
1415        edge: &HypothesisEdge,
1416    ) -> Option<RelationVersionId> {
1417        self.relation_lineage_hints
1418            .hypothesis_supersedes_relation_version_id(edge)
1419    }
1420
1421    /// Return the prior relation version for a verification-trial export relation, when known.
1422    pub fn superseded_verification_trial_relation_version_id(
1423        &self,
1424        trial: &VerificationTrial,
1425    ) -> Option<RelationVersionId> {
1426        self.relation_lineage_hints
1427            .verification_trial_supersedes_relation_version_id(trial)
1428    }
1429
1430    /// Return the prior relation version for a refutation-artifact export relation, when known.
1431    pub fn superseded_refutation_relation_version_id(
1432        &self,
1433        artifact: &RefutationArtifact,
1434    ) -> Option<RelationVersionId> {
1435        self.relation_lineage_hints
1436            .refutation_supersedes_relation_version_id(artifact)
1437    }
1438
1439    /// Convert the bundle into episode metadata suitable for semantic-memory storage.
1440    pub fn to_episode_meta(&self) -> serde_json::Value {
1441        serde_json::json!({
1442            "type": "forge_evidence",
1443            "bundle_id": self.bundle_id,
1444            "candidate_id": self.candidate_id,
1445            "eval_id": self.eval_id,
1446            "version_id": self.version_id,
1447            "supersedes_claim_version_id": self.supersedes_claim_version_id,
1448            "trace_id": self.trace_id,
1449            "hypothesis_count": self.hypotheses.len(),
1450            "hypothesis_edge_count": self.hypothesis_edges.len(),
1451            "has_verification": self.verification.is_some(),
1452            "verification_trial_count": self.verification_trials.len(),
1453            "refutation_artifact_count": self.refutation_artifacts.len(),
1454            "claim_strength": format!("{}", self.claim_strength),
1455            "run_id": self.run_id,
1456            "attempt_id": self.attempt_id,
1457            "treatment": self.treatment,
1458            "outcome": self.outcome,
1459            "confounders": self.known_threats,
1460            "identification_rationale": self.identification_rationale,
1461            "patch_hash": self.patch_hash,
1462            "estimator_metadata": serde_json::json!({
1463                "weighted_total": self.scores.weighted_total,
1464                "cea_confidence": self.scores.cea_confidence,
1465                "cea_predicted_correctness": self.scores.cea_predicted_correctness,
1466            }),
1467            "sealed": self.sealed,
1468        })
1469    }
1470
1471    /// Convert the bundle into episode content text for embedding.
1472    ///
1473    /// Includes: patch summary, primary causal question, primary effect summary,
1474    /// key covariates, claim strength, and verification recommendations.
1475    /// Does NOT include: giant raw logs, unstable formatting, or missing identifiers.
1476    pub fn to_episode_content(&self) -> String {
1477        let mut parts = Vec::new();
1478
1479        // Primary identifiers
1480        parts.push(format!(
1481            "Evidence bundle {} for candidate {} (eval {})",
1482            self.bundle_id, self.candidate_id, self.eval_id
1483        ));
1484
1485        // Claim strength
1486        parts.push(format!("Claim strength: {}", self.claim_strength));
1487
1488        if let Some(ref outcome) = self.outcome {
1489            parts.push(format!("Outcome: {outcome}"));
1490        }
1491
1492        if let Some(ref rationale) = self.identification_rationale {
1493            parts.push(format!("Identification rationale: {rationale}"));
1494        }
1495
1496        // Causal question
1497        if let Some(ref q) = self.causal_question {
1498            parts.push(format!("Causal question: {q}"));
1499        }
1500
1501        // Patch summary
1502        if let Some(ref t) = self.treatment {
1503            parts.push(format!(
1504                "Treatment: kind={kind}, patch_hash={patch_hash}, summary={summary}",
1505                kind = t.kind,
1506                patch_hash = t.patch_hash,
1507                summary = t.patch_summary
1508            ));
1509        }
1510
1511        // Primary effect
1512        if let Some(ref eff) = self.primary_effect {
1513            parts.push(format!("Primary effect: {:?} - {}", eff.kind, eff.message));
1514        }
1515
1516        // Scores
1517        parts.push(format!(
1518            "Scores: correctness={:.2}, novelty={:.2}, stability={:.2}, total={:.2}",
1519            self.scores.correctness,
1520            self.scores.novelty,
1521            self.scores.stability,
1522            self.scores.weighted_total,
1523        ));
1524
1525        // Key covariates
1526        if let Some(ref cov) = self.covariates {
1527            parts.push(format!("Workload: {}", cov.workload_id));
1528            parts.push(format!("Checks: {}", cov.selected_checks.join(", ")));
1529            if cov.adjacent_edits {
1530                parts.push("Adjacent edits present".to_string());
1531            }
1532            if !cov.adjacent_edit_signatures.is_empty() {
1533                parts.push(format!(
1534                    "Adjacent edits: {}",
1535                    cov.adjacent_edit_signatures.join(", ")
1536                ));
1537            }
1538        }
1539
1540        if !self.known_threats.is_empty() {
1541            parts.push(format!(
1542                "Confounders / threats: {}",
1543                self.known_threats.join(", ")
1544            ));
1545        }
1546
1547        // Hypothesis edges
1548        for edge in &self.hypothesis_edges {
1549            parts.push(format!(
1550                "Edge {}: {:?} {:?} (confidence={:.2})",
1551                edge.edge_id, edge.kind, edge.status, edge.confidence
1552            ));
1553        }
1554
1555        // Legacy hypotheses (for backward compat)
1556        for h in &self.hypotheses {
1557            parts.push(format!(
1558                "Hypothesis {}: {:?} (confidence={:.2}, support={}, contradictions={})",
1559                h.hypothesis_id, h.status, h.confidence, h.support_count, h.contradiction_count
1560            ));
1561        }
1562
1563        // Explicit trial lineage
1564        for trial in &self.verification_trials {
1565            parts.push(format!(
1566                "Trial {} / attempt {}: {:?} completed={}",
1567                trial.trial_id, trial.attempt_id, trial.baseline_or_patch, trial.completed
1568            ));
1569        }
1570
1571        // Refutation artifacts (pass/fail surface)
1572        for artifact in &self.refutation_artifacts {
1573            let outcome = match &artifact.outcome {
1574                RefutationArtifactOutcome::Passed => "passed".to_string(),
1575                RefutationArtifactOutcome::Failed { reason } => format!("failed: {reason}"),
1576                RefutationArtifactOutcome::Inconclusive { reason } => {
1577                    format!("inconclusive: {reason}")
1578                }
1579                RefutationArtifactOutcome::Skipped { reason } => format!("skipped: {reason}"),
1580            };
1581            parts.push(format!(
1582                "Refutation {:?} {}",
1583                artifact.artifact_type, outcome
1584            ));
1585        }
1586
1587        // Verification recommendations
1588        if let Some(ref plan) = self.verification {
1589            let required_count = plan
1590                .steps
1591                .iter()
1592                .filter(|s| s.requirement == StepRequirement::Required)
1593                .count();
1594            parts.push(format!(
1595                "Verification: {} steps ({} required)",
1596                plan.steps.len(),
1597                required_count,
1598            ));
1599            if let Some(ref budget) = plan.budget {
1600                parts.push(format!(
1601                    "Plan budget: max {} steps, est. {}s",
1602                    budget.max_steps, budget.estimated_duration_secs
1603                ));
1604            }
1605        }
1606
1607        parts.join("\n")
1608    }
1609}
1610
1611fn default_bundle_created_at() -> String {
1612    chrono::Utc::now().to_rfc3339()
1613}
1614
1615#[path = "evidence_analysis.rs"]
1616mod evidence_analysis;
1617
1618#[allow(deprecated)]
1619pub use evidence_analysis::{
1620    build_hypothesis_edges, compute_assessment, compute_confidence, derive_status,
1621    generate_verification_plan, local_hypothesis_support_confidence, update_hypotheses_from_diff,
1622    AssessmentCategory, CausalHypothesis, ContradictionState, DroppedStep, EvidenceAssessment,
1623    ExperimentRunner, HypothesisStatus, LocalExperimentRunner, PairComparability, PlanBudget,
1624    SampleSupport, StepRequirement, VerificationPlan, VerificationPolicy, VerificationStep,
1625    VerificationType,
1626};