strontium_assurance/assurance/model/
execution.rs1use crate::assurance::producers::ContextServiceEvidence;
2use alloy_assurance::api as alloy_api;
3use serde::{Deserialize, Serialize};
4
5use super::{FailureClassification, ScenarioManifest, ValidationError};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum ScenarioResult {
10 Pass,
11 Fail,
12 Mixed,
13}
14
15impl ScenarioResult {
16 pub fn to_alloy(&self) -> alloy_api::ScenarioOutcome {
17 match self {
18 Self::Pass => alloy_api::ScenarioOutcome::Pass,
19 Self::Fail => alloy_api::ScenarioOutcome::Fail,
20 Self::Mixed => alloy_api::ScenarioOutcome::Mixed,
21 }
22 }
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct AssertionResult {
27 pub assertion: String,
28 pub passed: bool,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub detail: Option<String>,
31}
32
33impl AssertionResult {
34 pub fn passing(assertion: impl Into<String>) -> Self {
35 Self {
36 assertion: assertion.into(),
37 passed: true,
38 detail: None,
39 }
40 }
41
42 pub fn failing(assertion: impl Into<String>, detail: impl Into<String>) -> Self {
43 Self {
44 assertion: assertion.into(),
45 passed: false,
46 detail: Some(detail.into()),
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct SupportingArtifact {
53 pub kind: String,
54 pub path: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub description: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct ProducerEvidence {
61 pub producer_surface: String,
62 pub dataset_family: String,
63 pub workload_id: String,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub supporting_artifacts: Vec<SupportingArtifact>,
66 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
67 pub metadata: serde_json::Map<String, serde_json::Value>,
68}
69
70impl ProducerEvidence {
71 pub fn new(
72 producer_surface: impl Into<String>,
73 dataset_family: impl Into<String>,
74 workload_id: impl Into<String>,
75 ) -> Self {
76 Self {
77 producer_surface: producer_surface.into(),
78 dataset_family: dataset_family.into(),
79 workload_id: workload_id.into(),
80 supporting_artifacts: Vec::new(),
81 metadata: serde_json::Map::new(),
82 }
83 }
84
85 pub fn with_metadata_field(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
86 self.metadata.insert(key.into(), value);
87 self
88 }
89
90 pub fn with_artifact(
91 mut self,
92 kind: impl Into<String>,
93 path: impl Into<String>,
94 description: Option<String>,
95 ) -> Self {
96 self.supporting_artifacts.push(SupportingArtifact {
97 kind: kind.into(),
98 path: path.into(),
99 description,
100 });
101 self
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct TimelineEvent {
107 pub sequence: u32,
108 pub label: String,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub detail: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub connection_id: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub stream_id: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub fault_class: Option<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct TraceSummary {
121 pub trace_id: String,
122 pub summary: String,
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct MetricSnapshot {
127 pub counters: serde_json::Map<String, serde_json::Value>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "kebab-case", tag = "kind", content = "payload")]
132pub enum RichProducerEvidence {
133 ContextService(ContextServiceEvidence),
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct ScenarioExecution {
138 pub manifest: ScenarioManifest,
139 pub workload_id: String,
140 pub result: ScenarioResult,
141 pub assertion_results: Vec<AssertionResult>,
142 pub producer_evidence: Vec<ProducerEvidence>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub failure_classification: Option<FailureClassification>,
145 pub timeline: Vec<TimelineEvent>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub trace_summary: Option<TraceSummary>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub metrics_snapshot: Option<MetricSnapshot>,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub rich_producer_evidence: Vec<RichProducerEvidence>,
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub attachments: Vec<SupportingArtifact>,
154}
155
156impl ScenarioExecution {
157 pub fn validate(&self) -> Result<(), ValidationError> {
158 self.manifest.validate()?;
159
160 if self.workload_id.trim().is_empty() {
161 return Err(ValidationError::MissingField("workload_id"));
162 }
163 if self.assertion_results.is_empty() {
164 return Err(ValidationError::MissingField("assertion_results"));
165 }
166 if self.producer_evidence.is_empty() {
167 return Err(ValidationError::MissingField("producer_evidence"));
168 }
169
170 for evidence in &self.producer_evidence {
171 if evidence.producer_surface.trim().is_empty() {
172 return Err(ValidationError::MissingField(
173 "producer_evidence.producer_surface",
174 ));
175 }
176 if evidence.dataset_family != self.manifest.preconditions.dataset_family {
177 return Err(ValidationError::DatasetFamilyMismatch {
178 expected: self.manifest.preconditions.dataset_family.clone(),
179 actual: evidence.dataset_family.clone(),
180 });
181 }
182 if evidence.workload_id != self.workload_id {
183 return Err(ValidationError::WorkloadMismatch {
184 expected: self.workload_id.clone(),
185 actual: evidence.workload_id.clone(),
186 });
187 }
188 }
189
190 if !self
191 .assertion_results
192 .iter()
193 .all(|result| !result.assertion.trim().is_empty())
194 {
195 return Err(ValidationError::MissingField("assertion_results.assertion"));
196 }
197
198 match self.result {
199 ScenarioResult::Pass => {
200 if self.failure_classification.is_some() {
201 return Err(ValidationError::UnexpectedFailureClassification);
202 }
203 }
204 ScenarioResult::Fail | ScenarioResult::Mixed => {
205 let classification = self
206 .failure_classification
207 .as_ref()
208 .ok_or(ValidationError::MissingField("failure_classification"))?;
209 if !self
210 .manifest
211 .failure_classifications
212 .contains(classification)
213 {
214 return Err(ValidationError::UnsupportedFailureClassification(
215 classification.clone(),
216 ));
217 }
218 }
219 }
220
221 Ok(())
222 }
223}