Skip to main content

strontium_assurance/assurance/model/
execution.rs

1use 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}