Skip to main content

aidens_contracts/
mechanism_display.rs

1//! Mechanism display and equivalence reports.
2//!
3//! AiDENs exposes report envelopes only; mechanism/kernel semantics remain delegated to sibling canonical crates.
4
5use super::*;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8#[serde(rename_all = "kebab-case")]
9pub enum MechanismKindV1 {
10    Symbolic,
11    Sparse,
12    Neural,
13    Hybrid,
14    SimulatorBacked,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "kebab-case")]
19pub enum TheoryVersionStateV1 {
20    Candidate,
21    Fitted,
22    Refuted,
23    Superseded,
24    Published,
25    Quarantined,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "kebab-case")]
30pub enum FitRunOutcomeV1 {
31    Succeeded,
32    Failed,
33    Degraded,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37#[serde(rename_all = "kebab-case")]
38pub enum InvarianceVerdictV1 {
39    Invariant,
40    Variant,
41    Inconclusive,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "kebab-case")]
46pub enum TheoryRefuterOutcomeV1 {
47    NotRun,
48    NoRefutationFound,
49    Refuted,
50    Inconclusive,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "kebab-case")]
55pub enum EquivalenceDispositionV1 {
56    Distinct,
57    AliasAdmitted,
58    Quarantined,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
62pub struct MechanismReportDraftV1 {
63    pub mechanism_kind: MechanismKindV1,
64    pub name: String,
65    pub version_label: String,
66    pub variables: Vec<String>,
67    pub assumptions: Vec<String>,
68    pub causal_claims: Vec<String>,
69    pub mechanism_program: Vec<String>,
70    pub parameters: BTreeMap<String, serde_json::Value>,
71    pub replay_recipe: String,
72}
73
74impl MechanismReportDraftV1 {
75    pub fn new(
76        mechanism_kind: MechanismKindV1,
77        name: impl Into<String>,
78        version_label: impl Into<String>,
79        replay_recipe: impl Into<String>,
80    ) -> Self {
81        Self {
82            mechanism_kind,
83            name: name.into(),
84            version_label: version_label.into(),
85            variables: Vec::new(),
86            assumptions: Vec::new(),
87            causal_claims: Vec::new(),
88            mechanism_program: Vec::new(),
89            parameters: BTreeMap::new(),
90            replay_recipe: replay_recipe.into(),
91        }
92    }
93
94    pub fn with_variables(mut self, variables: Vec<String>) -> Self {
95        self.variables = variables;
96        self
97    }
98
99    pub fn with_assumptions(mut self, assumptions: Vec<String>) -> Self {
100        self.assumptions = assumptions;
101        self
102    }
103
104    pub fn with_causal_claims(mut self, causal_claims: Vec<String>) -> Self {
105        self.causal_claims = causal_claims;
106        self
107    }
108
109    pub fn with_program(mut self, mechanism_program: Vec<String>) -> Self {
110        self.mechanism_program = mechanism_program;
111        self
112    }
113
114    pub fn with_parameters(mut self, parameters: BTreeMap<String, serde_json::Value>) -> Self {
115        self.parameters = parameters;
116        self
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
121pub struct MechanismReportV1 {
122    pub mechanism_id: ArtifactId,
123    pub kind: ArtifactKindV1,
124    pub mechanism_kind: MechanismKindV1,
125    pub name: String,
126    pub version_label: String,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub hypothesis_id: Option<ArtifactId>,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub variables: Vec<String>,
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub assumptions: Vec<String>,
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub causal_claims: Vec<String>,
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub mechanism_program: Vec<String>,
137    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
138    pub parameters: BTreeMap<String, serde_json::Value>,
139    pub structural_signature_digest: DisplayDigestV1,
140    pub observational_signature_digest: DisplayDigestV1,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub simulator_contract_id: Option<ArtifactId>,
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub source_artifact_ids: Vec<ArtifactId>,
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub evidence_ids: Vec<ArtifactId>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub raw_weight_digest: Option<String>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub notebook_reference: Option<String>,
151    pub replay_recipe: String,
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub reason_codes: Vec<String>,
154    pub created_at: DateTime<Utc>,
155}
156
157impl MechanismReportV1 {
158    pub fn new(draft: MechanismReportDraftV1) -> Self {
159        let mechanism_kind = draft.mechanism_kind;
160        let name = draft.name;
161        let version_label = draft.version_label;
162        let variables = sorted_unique_strings(draft.variables);
163        let assumptions = sorted_unique_strings(draft.assumptions);
164        let causal_claims = sorted_unique_strings(draft.causal_claims);
165        let mechanism_program = draft.mechanism_program;
166        let parameters = draft.parameters;
167        let replay_recipe = draft.replay_recipe;
168        let structural_payload = serde_json::json!({
169            "mechanism_kind": mechanism_kind,
170            "name": &name,
171            "version_label": &version_label,
172            "variables": &variables,
173            "assumptions": &assumptions,
174            "causal_claims": &causal_claims,
175            "mechanism_program": &mechanism_program,
176            "parameters": &parameters,
177        });
178        let observational_payload = serde_json::json!({
179            "name": &name,
180            "variables": &variables,
181            "mechanism_program": &mechanism_program,
182        });
183        Self {
184            mechanism_id: local_artifact_id_from_stack_digest(
185                "mechanism-report",
186                &display_json_string(&structural_payload),
187            ),
188            kind: ArtifactKindV1::MechanismReport,
189            mechanism_kind,
190            name,
191            version_label,
192            hypothesis_id: None,
193            variables,
194            assumptions,
195            causal_claims,
196            mechanism_program,
197            parameters,
198            structural_signature_digest: DisplayDigestV1::for_json_value(&structural_payload),
199            observational_signature_digest: DisplayDigestV1::for_json_value(&observational_payload),
200            simulator_contract_id: None,
201            source_artifact_ids: Vec::new(),
202            evidence_ids: Vec::new(),
203            raw_weight_digest: None,
204            notebook_reference: None,
205            replay_recipe,
206            reason_codes: vec![
207                "mechanism-is-versioned-artifact".into(),
208                "raw-model-weights-alone-are-insufficient".into(),
209            ],
210            created_at: Utc::now(),
211        }
212    }
213
214    pub fn with_hypothesis(mut self, hypothesis_id: ArtifactId) -> Self {
215        self.hypothesis_id = Some(hypothesis_id);
216        self
217    }
218
219    pub fn with_simulator_contract(mut self, simulator_contract_id: ArtifactId) -> Self {
220        self.simulator_contract_id = Some(simulator_contract_id);
221        self.reason_codes
222            .push("simulator-contract-linked-for-replay".into());
223        self.reason_codes.sort();
224        self.reason_codes.dedup();
225        self
226    }
227
228    pub fn with_evidence(mut self, evidence_ids: Vec<ArtifactId>) -> Self {
229        self.evidence_ids = sorted_unique_artifact_ids(evidence_ids);
230        self
231    }
232
233    pub fn with_source_artifacts(mut self, source_artifact_ids: Vec<ArtifactId>) -> Self {
234        self.source_artifact_ids = sorted_unique_artifact_ids(source_artifact_ids);
235        self
236    }
237
238    pub fn with_raw_weight_digest(mut self, raw_weight_digest: impl Into<String>) -> Self {
239        self.raw_weight_digest = Some(raw_weight_digest.into());
240        self.reason_codes
241            .push("raw-weights-linked-but-not-sole-artifact".into());
242        self.reason_codes.sort();
243        self.reason_codes.dedup();
244        self
245    }
246
247    pub fn is_replayable(&self) -> bool {
248        !self.replay_recipe.trim().is_empty()
249            && (!self.mechanism_program.is_empty() || self.simulator_contract_id.is_some())
250    }
251}
252
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
254pub struct SimulatorContractV1 {
255    pub contract_id: ArtifactId,
256    pub kind: ArtifactKindV1,
257    pub name: String,
258    pub backend: String,
259    pub input_schema: serde_json::Value,
260    pub output_schema: serde_json::Value,
261    pub deterministic: bool,
262    pub seed_policy: String,
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub replay_command: Vec<String>,
265    pub simulator_digest: DisplayDigestV1,
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub reason_codes: Vec<String>,
268    pub created_at: DateTime<Utc>,
269}
270
271impl SimulatorContractV1 {
272    pub fn new(
273        name: impl Into<String>,
274        backend: impl Into<String>,
275        input_schema: serde_json::Value,
276        output_schema: serde_json::Value,
277        replay_command: Vec<String>,
278    ) -> Self {
279        let name = name.into();
280        let backend = backend.into();
281        let digest_payload = serde_json::json!({
282            "name": &name,
283            "backend": &backend,
284            "input_schema": &input_schema,
285            "output_schema": &output_schema,
286            "replay_command": &replay_command,
287        });
288        Self {
289            contract_id: local_artifact_id_from_stack_digest(
290                "simulator-contract",
291                &display_json_string(&digest_payload),
292            ),
293            kind: ArtifactKindV1::SimulatorContract,
294            name,
295            backend,
296            input_schema,
297            output_schema,
298            deterministic: true,
299            seed_policy: "fixed-seed-required-for-replay".into(),
300            replay_command,
301            simulator_digest: DisplayDigestV1::for_json_value(&digest_payload),
302            reason_codes: vec!["simulator-contract-defines-replay-boundary".into()],
303            created_at: Utc::now(),
304        }
305    }
306
307    pub fn nondeterministic(mut self, seed_policy: impl Into<String>) -> Self {
308        self.deterministic = false;
309        self.seed_policy = seed_policy.into();
310        self.reason_codes
311            .push("nondeterminism-disclosed-by-contract".into());
312        self.reason_codes.sort();
313        self.reason_codes.dedup();
314        self
315    }
316
317    pub fn is_replayable(&self) -> bool {
318        !self.replay_command.is_empty() && (self.deterministic || self.seed_policy.contains("seed"))
319    }
320}
321
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
323pub struct FitRunReportV1 {
324    pub receipt_id: ArtifactId,
325    pub kind: ArtifactKindV1,
326    pub mechanism_id: ArtifactId,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub theory_id: Option<ArtifactId>,
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub simulator_contract_id: Option<ArtifactId>,
331    pub dataset_digest: DisplayDigestV1,
332    pub parameter_digest: DisplayDigestV1,
333    pub output_digest: DisplayDigestV1,
334    pub outcome: FitRunOutcomeV1,
335    pub objective: String,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub score: Option<f64>,
338    pub score_is_verification: bool,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub seed: Option<u64>,
341    #[serde(default, skip_serializing_if = "Vec::is_empty")]
342    pub replay_command: Vec<String>,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub run_id: Option<ArtifactId>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub attempt_id: Option<ArtifactId>,
347    #[serde(default, skip_serializing_if = "Vec::is_empty")]
348    pub source_receipt_ids: Vec<ArtifactId>,
349    #[serde(default, skip_serializing_if = "Vec::is_empty")]
350    pub reason_codes: Vec<String>,
351    pub fitted_at: DateTime<Utc>,
352}
353
354impl FitRunReportV1 {
355    pub fn new(
356        mechanism_id: ArtifactId,
357        simulator_contract_id: Option<ArtifactId>,
358        dataset: serde_json::Value,
359        parameters: serde_json::Value,
360        output: serde_json::Value,
361        objective: impl Into<String>,
362        score: Option<f64>,
363    ) -> Self {
364        Self {
365            receipt_id: display_only_unstable_id("fit-run-report"),
366            kind: ArtifactKindV1::FitRunReport,
367            mechanism_id,
368            theory_id: None,
369            simulator_contract_id,
370            dataset_digest: DisplayDigestV1::for_json_value(&dataset),
371            parameter_digest: DisplayDigestV1::for_json_value(&parameters),
372            output_digest: DisplayDigestV1::for_json_value(&output),
373            outcome: FitRunOutcomeV1::Succeeded,
374            objective: objective.into(),
375            score,
376            score_is_verification: false,
377            seed: None,
378            replay_command: Vec::new(),
379            run_id: None,
380            attempt_id: None,
381            source_receipt_ids: Vec::new(),
382            reason_codes: vec![
383                "fit-run-recorded-as-evidence".into(),
384                "score-is-not-verification".into(),
385            ],
386            fitted_at: Utc::now(),
387        }
388    }
389
390    pub fn for_theory(mut self, theory_id: ArtifactId) -> Self {
391        self.theory_id = Some(theory_id);
392        self
393    }
394
395    pub fn with_execution_context(mut self, context: &AidensRunContextV1) -> Self {
396        self.run_id = Some(context.run_id.clone());
397        self.attempt_id = Some(context.attempt_id.clone());
398        self
399    }
400
401    pub fn with_seed(mut self, seed: u64) -> Self {
402        self.seed = Some(seed);
403        self
404    }
405
406    pub fn with_replay_command(mut self, replay_command: Vec<String>) -> Self {
407        self.replay_command = replay_command;
408        self
409    }
410
411    pub fn with_source_receipts(mut self, source_receipt_ids: Vec<ArtifactId>) -> Self {
412        self.source_receipt_ids = sorted_unique_artifact_ids(source_receipt_ids);
413        self
414    }
415
416    pub fn failed(mut self, reason: impl Into<String>) -> Self {
417        self.outcome = FitRunOutcomeV1::Failed;
418        self.reason_codes.push(reason.into());
419        self.reason_codes.sort();
420        self.reason_codes.dedup();
421        self
422    }
423
424    pub fn is_replayable(&self) -> bool {
425        self.outcome == FitRunOutcomeV1::Succeeded
426            && !self.replay_command.is_empty()
427            && self.simulator_contract_id.is_some()
428    }
429}
430
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
432pub struct InvarianceCheckV1 {
433    pub check_label: String,
434    pub environment: String,
435    pub perturbation: String,
436    pub metric_delta: f64,
437    pub verdict: InvarianceVerdictV1,
438}
439
440impl InvarianceCheckV1 {
441    pub fn new(
442        check_label: impl Into<String>,
443        environment: impl Into<String>,
444        perturbation: impl Into<String>,
445        metric_delta: f64,
446        tolerance: f64,
447    ) -> Self {
448        let verdict = if metric_delta.abs() <= tolerance {
449            InvarianceVerdictV1::Invariant
450        } else {
451            InvarianceVerdictV1::Variant
452        };
453        Self {
454            check_label: check_label.into(),
455            environment: environment.into(),
456            perturbation: perturbation.into(),
457            metric_delta,
458            verdict,
459        }
460    }
461}
462
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
464pub struct InvarianceReportV1 {
465    pub report_id: ArtifactId,
466    pub kind: ArtifactKindV1,
467    pub mechanism_id: ArtifactId,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub theory_id: Option<ArtifactId>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub fit_run_receipt_id: Option<ArtifactId>,
472    #[serde(default, skip_serializing_if = "Vec::is_empty")]
473    pub environments: Vec<String>,
474    #[serde(default, skip_serializing_if = "Vec::is_empty")]
475    pub perturbations: Vec<String>,
476    #[serde(default, skip_serializing_if = "Vec::is_empty")]
477    pub checks: Vec<InvarianceCheckV1>,
478    pub verdict: InvarianceVerdictV1,
479    pub observational_equivalence_only: bool,
480    pub causal_identification_claimed: bool,
481    #[serde(default, skip_serializing_if = "Vec::is_empty")]
482    pub reason_codes: Vec<String>,
483    pub recorded_at: DateTime<Utc>,
484}
485
486impl InvarianceReportV1 {
487    pub fn new(
488        mechanism_id: ArtifactId,
489        theory_id: Option<ArtifactId>,
490        fit_run_receipt_id: Option<ArtifactId>,
491        checks: Vec<InvarianceCheckV1>,
492    ) -> Self {
493        let environments = sorted_unique_strings(
494            checks
495                .iter()
496                .map(|check| check.environment.clone())
497                .collect(),
498        );
499        let perturbations = sorted_unique_strings(
500            checks
501                .iter()
502                .map(|check| check.perturbation.clone())
503                .collect(),
504        );
505        let verdict = if checks.is_empty() {
506            InvarianceVerdictV1::Inconclusive
507        } else if checks
508            .iter()
509            .all(|check| check.verdict == InvarianceVerdictV1::Invariant)
510        {
511            InvarianceVerdictV1::Invariant
512        } else if checks
513            .iter()
514            .any(|check| check.verdict == InvarianceVerdictV1::Variant)
515        {
516            InvarianceVerdictV1::Variant
517        } else {
518            InvarianceVerdictV1::Inconclusive
519        };
520        Self {
521            report_id: display_only_unstable_id("invariance-report"),
522            kind: ArtifactKindV1::InvarianceReport,
523            mechanism_id,
524            theory_id,
525            fit_run_receipt_id,
526            environments,
527            perturbations,
528            checks,
529            verdict,
530            observational_equivalence_only: true,
531            causal_identification_claimed: false,
532            reason_codes: vec![
533                "invariance-tested-as-refuter-evidence".into(),
534                "observational-equivalence-is-not-causal-identification".into(),
535            ],
536            recorded_at: Utc::now(),
537        }
538    }
539
540    pub fn with_causal_identification(mut self, reason: impl Into<String>) -> Self {
541        self.observational_equivalence_only = false;
542        self.causal_identification_claimed = self.verdict == InvarianceVerdictV1::Invariant;
543        self.reason_codes.push(reason.into());
544        self.reason_codes
545            .push("causal-identification-claim-explicit".into());
546        self.reason_codes.sort();
547        self.reason_codes.dedup();
548        self
549    }
550
551    pub fn supports_causal_identification(&self) -> bool {
552        self.causal_identification_claimed
553            && !self.observational_equivalence_only
554            && self.verdict == InvarianceVerdictV1::Invariant
555    }
556}
557
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
559pub struct MechanismEquivalenceDecisionV1 {
560    pub decision_id: ArtifactId,
561    pub left_mechanism_id: ArtifactId,
562    pub right_mechanism_id: ArtifactId,
563    pub disposition: EquivalenceDispositionV1,
564    pub observationally_equivalent: bool,
565    pub causally_identified_same: bool,
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub admitted_by_artifact_id: Option<ArtifactId>,
568    #[serde(default, skip_serializing_if = "Vec::is_empty")]
569    pub reason_codes: Vec<String>,
570}
571
572impl MechanismEquivalenceDecisionV1 {
573    pub fn distinct(
574        left_mechanism_id: ArtifactId,
575        right_mechanism_id: ArtifactId,
576        observationally_equivalent: bool,
577    ) -> Self {
578        Self {
579            decision_id: display_only_unstable_id("mechanism-equivalence-decision"),
580            left_mechanism_id,
581            right_mechanism_id,
582            disposition: EquivalenceDispositionV1::Distinct,
583            observationally_equivalent,
584            causally_identified_same: false,
585            admitted_by_artifact_id: None,
586            reason_codes: vec![
587                "equivalent-observations-do-not-collapse-mechanisms".into(),
588                "causal-identification-not-established".into(),
589            ],
590        }
591    }
592
593    pub fn alias_admitted(
594        left_mechanism_id: ArtifactId,
595        right_mechanism_id: ArtifactId,
596        admitted_by_artifact_id: ArtifactId,
597    ) -> Self {
598        Self {
599            decision_id: display_only_unstable_id("mechanism-equivalence-decision"),
600            left_mechanism_id,
601            right_mechanism_id,
602            disposition: EquivalenceDispositionV1::AliasAdmitted,
603            observationally_equivalent: true,
604            causally_identified_same: true,
605            admitted_by_artifact_id: Some(admitted_by_artifact_id),
606            reason_codes: vec!["explicit-equivalence-alias-admitted".into()],
607        }
608    }
609
610    pub fn permits_alias(&self, left: &ArtifactId, right: &ArtifactId) -> bool {
611        self.disposition == EquivalenceDispositionV1::AliasAdmitted
612            && self.admitted_by_artifact_id.is_some()
613            && self.causally_identified_same
614            && ((&self.left_mechanism_id == left && &self.right_mechanism_id == right)
615                || (&self.left_mechanism_id == right && &self.right_mechanism_id == left))
616    }
617}