1use 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": ¶meters,
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(¶meters),
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}