soma_application/graduation/
comparison.rs1use std::{collections::HashSet, path::Path};
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use soma_provider_core::ProviderInvocationContext;
6
7use super::{
8 ConformanceAttestation, GraduationArtifact, GraduationState, MAX_FIXTURE_BYTES, WorkspaceLock,
9 digest_bytes, digest_file, ensure_no_transaction, read_bounded, read_state,
10 validate_state_paths, write_state,
11};
12
13const MAX_FIXTURE_VALUE_BYTES: usize = 128 * 1024;
14const MAX_FIXTURES: usize = 64;
15const MAX_COMPARISON_REPORT_BYTES: usize = 32 * 1024;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct GraduationFixture {
21 pub name: String,
23 pub input: Value,
25 pub expected: Value,
27}
28
29struct PreparedComparison {
30 state: GraduationState,
31 candidate: GraduationArtifact,
32 catalog_digest: String,
33 component: soma_provider_adapters::wasm::PreparedComponentArtifact,
34}
35
36struct ComparisonOutcome {
37 candidate: GraduationArtifact,
38 fixture_digest: String,
39 fixture_count: usize,
40 matches: bool,
41 source_sha256: String,
42 catalog_sha256: String,
43}
44
45pub(crate) struct FixtureSnapshot {
46 pub fixtures: Vec<GraduationFixture>,
47 pub digest: String,
48}
49
50pub struct ComparisonRequest<'a> {
52 pub component: Option<&'a Path>,
54 pub(crate) fixtures: FixtureSnapshot,
56 pub live_runs: Vec<(Value, Value)>,
58 pub context: &'a ProviderInvocationContext,
60 pub provider_root: &'a Path,
62 pub deadline: tokio::time::Instant,
64 pub max_response_bytes: usize,
66}
67
68pub async fn compare(workspace: &Path, request: ComparisonRequest<'_>) -> anyhow::Result<Value> {
70 let ComparisonRequest {
71 component,
72 fixtures,
73 live_runs,
74 context,
75 provider_root,
76 deadline,
77 max_response_bytes,
78 } = request;
79 let workspace_for_prepare = workspace.to_path_buf();
80 let component_for_prepare = component.map(Path::to_path_buf);
81 let provider_root_for_prepare = provider_root.to_path_buf();
82 let compile_deadline = deadline.into_std();
83 let prepare_task = tokio::task::spawn_blocking(move || {
84 prepare_comparison(
85 &workspace_for_prepare,
86 component_for_prepare.as_deref(),
87 &provider_root_for_prepare,
88 compile_deadline,
89 )
90 });
91 let remaining = deadline
92 .checked_duration_since(tokio::time::Instant::now())
93 .ok_or_else(|| anyhow::anyhow!("graduation comparison exceeded its 30 second limit"))?;
94 let prepared = tokio::time::timeout(remaining, prepare_task)
95 .await
96 .map_err(|_| anyhow::anyhow!("graduation comparison exceeded its 30 second limit"))???;
97 if live_runs.len() != fixtures.fixtures.len() {
98 anyhow::bail!("live Python result count does not match the fixture corpus");
99 }
100 let mut results = Vec::with_capacity(fixtures.fixtures.len());
101 for (fixture, (effective_input, live_output)) in fixtures.fixtures.iter().zip(live_runs) {
102 let remaining = deadline
103 .checked_duration_since(tokio::time::Instant::now())
104 .ok_or_else(|| anyhow::anyhow!("graduation comparison exceeded its 30 second limit"))?;
105 let actual = tokio::time::timeout(
106 remaining,
107 soma_provider_adapters::wasm::invoke_prepared_component_artifact_before_async(
108 &prepared.component,
109 &effective_input,
110 &prepared.state.catalog.capabilities,
111 context,
112 deadline.into_std(),
113 ),
114 )
115 .await
116 .map_err(|_| anyhow::anyhow!("graduation comparison exceeded its 30 second limit"))?;
117 let component_matches_live = actual.as_ref().is_ok_and(|actual| actual == &live_output);
118 let recorded_matches_live = fixture.expected == live_output;
119 results.push(json!({
120 "name": fixture.name.chars().take(64).collect::<String>(),
121 "recorded_matches_live": recorded_matches_live,
122 "component_matches_live": component_matches_live,
123 "error": actual.as_ref().err().map(|error| error.chars().take(96).collect::<String>()),
124 }));
125 }
126 let matches = results.iter().all(|result| {
127 result["recorded_matches_live"] == true && result["component_matches_live"] == true
128 });
129 let report = json!({
130 "ok": matches,
131 "artifact_sha256": prepared.candidate.sha256,
132 "source_sha256": prepared.state.source_sha256,
133 "fixtures_sha256": fixtures.digest,
134 "fixtures": results
135 });
136 if serde_json::to_vec(&report)?.len() > MAX_COMPARISON_REPORT_BYTES {
137 anyhow::bail!("graduation comparison report exceeds {MAX_COMPARISON_REPORT_BYTES} bytes");
138 }
139 crate::ExecuteActionResponse {
140 output: report.clone(),
141 request_id: context.request_id.clone(),
142 progress: context.progress.events(),
143 }
144 .enforce_serialized_limit(max_response_bytes)
145 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
146 let workspace_for_finish = workspace.to_path_buf();
147 let outcome = ComparisonOutcome {
148 candidate: prepared.candidate.clone(),
149 fixture_digest: fixtures.digest.clone(),
150 fixture_count: fixtures.fixtures.len(),
151 matches,
152 source_sha256: prepared.state.source_sha256.clone(),
153 catalog_sha256: prepared.catalog_digest.clone(),
154 };
155 let finish_deadline = deadline.into_std();
156 let finish_task = tokio::task::spawn_blocking(move || {
157 finish_comparison(&workspace_for_finish, &outcome, finish_deadline)
158 });
159 let remaining = deadline
160 .checked_duration_since(tokio::time::Instant::now())
161 .ok_or_else(|| anyhow::anyhow!("graduation comparison exceeded its 30 second limit"))?;
162 tokio::time::timeout(remaining, finish_task)
163 .await
164 .map_err(|_| anyhow::anyhow!("graduation comparison exceeded its 30 second limit"))???;
165 Ok(report)
166}
167
168fn prepare_comparison(
169 workspace: &Path,
170 component: Option<&Path>,
171 provider_root: &Path,
172 deadline: std::time::Instant,
173) -> anyhow::Result<PreparedComparison> {
174 let _lock = WorkspaceLock::acquire_before(workspace, deadline)?;
175 ensure_no_transaction(workspace)?;
176 let mut state = read_state(workspace)?;
177 validate_state_paths(workspace, provider_root, &state)?;
178 if digest_file(&state.source)? != state.source_sha256 {
179 anyhow::bail!("live Python source changed since graduation was scaffolded");
180 }
181 let candidate = state
182 .candidate
183 .clone()
184 .ok_or_else(|| anyhow::anyhow!("no component candidate exists"))?;
185 let component = component.unwrap_or(&candidate.path).canonicalize()?;
186 if component != candidate.path.canonicalize()? {
187 anyhow::bail!("comparison component is not the published candidate");
188 }
189 if digest_file(&candidate.path)? != candidate.sha256 {
190 anyhow::bail!("component artifact digest mismatch");
191 }
192 if state.attestation.take().is_some() {
196 write_state(workspace, &state)?;
197 }
198 let component =
199 soma_provider_adapters::wasm::prepare_component_artifact_before(&candidate.path, deadline)
200 .map_err(anyhow::Error::msg)?;
201 let catalog_digest = super::catalog_contract_digest(&state.catalog)?;
202 Ok(PreparedComparison {
203 state,
204 candidate,
205 catalog_digest,
206 component,
207 })
208}
209
210fn finish_comparison(
211 workspace: &Path,
212 outcome: &ComparisonOutcome,
213 deadline: std::time::Instant,
214) -> anyhow::Result<()> {
215 let _lock = WorkspaceLock::acquire_before(workspace, deadline)?;
216 ensure_no_transaction(workspace)?;
217 let mut state = read_state(workspace)?;
218 if state.candidate.as_ref() != Some(&outcome.candidate) {
219 anyhow::bail!("graduation candidate changed while comparison was running");
220 }
221 if state.source_sha256 != outcome.source_sha256
222 || digest_file(&state.source)? != outcome.source_sha256
223 {
224 anyhow::bail!("live Python source changed while comparison was running");
225 }
226 if state.catalog_sha256 != outcome.catalog_sha256
227 || super::catalog_contract_digest(&state.catalog)? != outcome.catalog_sha256
228 {
229 anyhow::bail!("provider catalog changed while comparison was running");
230 }
231 if digest_file(&outcome.candidate.path)? != outcome.candidate.sha256 {
232 anyhow::bail!("component candidate changed while comparison was running");
233 }
234 state.attestation = outcome.matches.then(|| ConformanceAttestation {
235 artifact_sha256: outcome.candidate.sha256.clone(),
236 fixtures_sha256: outcome.fixture_digest.clone(),
237 fixture_count: outcome.fixture_count,
238 source_sha256: outcome.source_sha256.clone(),
239 catalog_sha256: outcome.catalog_sha256.clone(),
240 verified_unix_ms: super::unix_ms(),
241 });
242 write_state(workspace, &state)
243}
244
245pub(crate) fn read_fixture_snapshot(path: &Path) -> anyhow::Result<FixtureSnapshot> {
246 let bytes = read_bounded(path, MAX_FIXTURE_BYTES, "graduation fixtures")?;
247 Ok(FixtureSnapshot {
248 fixtures: parse_fixtures(&bytes)?,
249 digest: digest_bytes(&bytes),
250 })
251}
252
253pub(crate) fn read_fixtures(path: &Path) -> anyhow::Result<Vec<GraduationFixture>> {
254 Ok(read_fixture_snapshot(path)?.fixtures)
255}
256
257fn parse_fixtures(bytes: &[u8]) -> anyhow::Result<Vec<GraduationFixture>> {
258 let corpus: Vec<GraduationFixture> = serde_json::from_slice(bytes)?;
259 if corpus.is_empty() {
260 anyhow::bail!("graduation fixture corpus must not be empty");
261 }
262 if corpus.len() > MAX_FIXTURES {
263 anyhow::bail!("graduation fixture corpus exceeds {MAX_FIXTURES} entries");
264 }
265 let mut names = HashSet::with_capacity(corpus.len());
266 for fixture in &corpus {
267 if fixture.name.is_empty() || fixture.name.len() > 256 {
268 anyhow::bail!("graduation fixture names must contain 1 to 256 bytes");
269 }
270 if !names.insert(fixture.name.as_str()) {
271 anyhow::bail!("graduation fixture names must be unique");
272 }
273 let input = fixture
274 .input
275 .as_object()
276 .ok_or_else(|| anyhow::anyhow!("graduation fixture input must be an object"))?;
277 if !["provider", "action", "arguments"]
278 .iter()
279 .all(|key| input.contains_key(*key))
280 || input
281 .keys()
282 .any(|key| !matches!(key.as_str(), "provider" | "action" | "arguments"))
283 {
284 anyhow::bail!(
285 "graduation fixture input must contain only provider, action, and arguments"
286 );
287 }
288 if serde_json::to_vec(&(&fixture.input, &fixture.expected))?.len() > MAX_FIXTURE_VALUE_BYTES
289 {
290 anyhow::bail!(
291 "graduation fixture `{}` exceeds {MAX_FIXTURE_VALUE_BYTES} input/output bytes",
292 fixture.name
293 );
294 }
295 }
296 Ok(corpus)
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn requires_a_nonempty_recorded_fixture_set() {
305 assert!(
306 parse_fixtures(b"[]")
307 .expect_err("empty corpus")
308 .to_string()
309 .contains("must not be empty")
310 );
311 }
312
313 #[test]
314 fn corpus_and_entries_are_bounded() {
315 assert!(parse_fixtures(&vec![b' '; MAX_FIXTURE_BYTES + 1]).is_err());
316
317 let duplicate = serde_json::to_vec(&[
318 GraduationFixture {
319 name: "same".to_owned(),
320 input: json!({"provider": "example", "action": "echo", "arguments": {}}),
321 expected: json!({}),
322 },
323 GraduationFixture {
324 name: "same".to_owned(),
325 input: json!({"provider": "example", "action": "echo", "arguments": {}}),
326 expected: json!({}),
327 },
328 ])
329 .expect("fixture JSON");
330 assert!(
331 parse_fixtures(&duplicate)
332 .expect_err("duplicates rejected")
333 .to_string()
334 .contains("unique")
335 );
336
337 let oversized = serde_json::to_vec(&[GraduationFixture {
338 name: "large".to_owned(),
339 input: json!({
340 "provider": "example",
341 "action": "echo",
342 "arguments": {"value": "x".repeat(MAX_FIXTURE_VALUE_BYTES)}
343 }),
344 expected: json!({}),
345 }])
346 .expect("fixture JSON");
347 assert!(
348 parse_fixtures(&oversized)
349 .expect_err("oversized value rejected")
350 .to_string()
351 .contains("input/output bytes")
352 );
353 }
354
355 #[test]
356 fn fixture_snapshot_is_immutable_after_the_source_file_changes() {
357 let temp = tempfile::NamedTempFile::new().expect("fixture file");
358 let first = serde_json::to_vec(&[GraduationFixture {
359 name: "first".to_owned(),
360 input: json!({"provider": "example", "action": "echo", "arguments": {}}),
361 expected: json!({"value": 1}),
362 }])
363 .expect("fixture JSON");
364 std::fs::write(temp.path(), &first).expect("first corpus");
365 let snapshot = read_fixture_snapshot(temp.path()).expect("snapshot");
366 std::fs::write(
367 temp.path(),
368 serde_json::to_vec(&[GraduationFixture {
369 name: "second".to_owned(),
370 input: json!({"provider": "example", "action": "echo", "arguments": {}}),
371 expected: json!({"value": 2}),
372 }])
373 .expect("second fixture JSON"),
374 )
375 .expect("replace corpus");
376
377 assert_eq!(snapshot.fixtures[0].name, "first");
378 assert_eq!(snapshot.digest, digest_bytes(&first));
379 }
380}