1use super::{digest, validate_digest_field, validate_id, ResearchContractError};
2use crate::capability::RunCapabilityBindingV1;
3use serde::{Deserialize, Serialize};
4
5pub const RESEARCH_RUN_SCHEMA_V1: &str = "a3s.code.research-run.v1";
6const RESEARCH_RUN_DIGEST_DOMAIN: &str = "a3s.code.research-run.identity.v1";
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ResearchReproducibilityV1 {
12 Exploratory,
13 Reproducible,
14 Deterministic,
15}
16
17impl ResearchReproducibilityV1 {
18 pub const fn as_str(self) -> &'static str {
19 match self {
20 Self::Exploratory => "exploratory",
21 Self::Reproducible => "reproducible",
22 Self::Deterministic => "deterministic",
23 }
24 }
25}
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ResearchRunStatusV1 {
31 Planned,
32 Admitted,
33 Running,
34 Checkpointed,
35 Completed,
36 Failed,
37 Cancelled,
38}
39
40impl ResearchRunStatusV1 {
41 pub const fn as_str(self) -> &'static str {
42 match self {
43 Self::Planned => "planned",
44 Self::Admitted => "admitted",
45 Self::Running => "running",
46 Self::Checkpointed => "checkpointed",
47 Self::Completed => "completed",
48 Self::Failed => "failed",
49 Self::Cancelled => "cancelled",
50 }
51 }
52
53 pub const fn is_terminal(self) -> bool {
54 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
55 }
56
57 pub const fn can_transition_to(self, next: Self) -> bool {
58 matches!(
59 (self, next),
60 (Self::Planned, Self::Admitted | Self::Cancelled)
61 | (Self::Admitted, Self::Running | Self::Cancelled)
62 | (
63 Self::Running,
64 Self::Checkpointed | Self::Completed | Self::Failed | Self::Cancelled
65 )
66 | (
67 Self::Checkpointed,
68 Self::Running | Self::Completed | Self::Failed | Self::Cancelled
69 )
70 )
71 }
72}
73
74#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct ResearchRunV1 {
78 pub schema: String,
79 pub run_id: String,
80 pub project_id: String,
81 pub project_revision: u64,
82 pub source_snapshot_digest: String,
83 pub evidence_snapshot_digest: String,
84 pub capability_binding: RunCapabilityBindingV1,
85 pub provider_id: String,
86 pub model_id: String,
87 pub reproducibility: ResearchReproducibilityV1,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub random_seed: Option<u64>,
90 pub status: ResearchRunStatusV1,
91 pub run_digest: String,
92}
93
94impl ResearchRunV1 {
95 #[allow(clippy::too_many_arguments)]
96 pub fn new(
97 run_id: impl Into<String>,
98 project_id: impl Into<String>,
99 project_revision: u64,
100 source_snapshot_digest: impl Into<String>,
101 evidence_snapshot_digest: impl Into<String>,
102 capability_binding: RunCapabilityBindingV1,
103 provider_id: impl Into<String>,
104 model_id: impl Into<String>,
105 reproducibility: ResearchReproducibilityV1,
106 random_seed: Option<u64>,
107 ) -> Result<Self, ResearchContractError> {
108 let mut run = Self {
109 schema: RESEARCH_RUN_SCHEMA_V1.to_owned(),
110 run_id: run_id.into(),
111 project_id: project_id.into(),
112 project_revision,
113 source_snapshot_digest: source_snapshot_digest.into(),
114 evidence_snapshot_digest: evidence_snapshot_digest.into(),
115 capability_binding,
116 provider_id: provider_id.into(),
117 model_id: model_id.into(),
118 reproducibility,
119 random_seed,
120 status: ResearchRunStatusV1::Planned,
121 run_digest: String::new(),
122 };
123 run.validate_without_digest()?;
124 run.run_digest = run.expected_digest()?;
125 Ok(run)
126 }
127
128 pub fn validate(&self) -> Result<(), ResearchContractError> {
129 self.validate_without_digest()?;
130 validate_digest_field("runDigest", &self.run_digest)?;
131 if self.run_digest != self.expected_digest()? {
132 return Err(ResearchContractError::DigestMismatch("runDigest"));
133 }
134 Ok(())
135 }
136
137 pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
140 let run: Self = super::decode_json_slice(bytes)?;
141 run.validate()?;
142 Ok(run)
143 }
144
145 pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
147 self.validate()?;
148 super::encode_json(self)
149 }
150
151 pub fn validate_execution_target(
156 &self,
157 target: &crate::evaluation::ExecutionTargetV1,
158 ) -> Result<(), ResearchContractError> {
159 self.validate()?;
160 target
161 .validate()
162 .map_err(|_| ResearchContractError::InvalidField("executionTarget"))?;
163 if target.run_id != self.run_id {
164 return Err(ResearchContractError::InvalidField("executionTarget.runId"));
165 }
166 Ok(())
167 }
168
169 pub(crate) fn validate_reviewable(&self) -> Result<(), ResearchContractError> {
178 self.validate()?;
179 if matches!(self.status, ResearchRunStatusV1::Planned) {
180 return Err(ResearchContractError::InvalidField("researchRun.status"));
181 }
182 Ok(())
183 }
184
185 pub fn transition_to(
186 &mut self,
187 next: ResearchRunStatusV1,
188 ) -> Result<(), ResearchContractError> {
189 self.validate()?;
190 if !self.status.can_transition_to(next) {
191 return Err(ResearchContractError::InvalidTransition {
192 from: self.status.as_str(),
193 to: next.as_str(),
194 });
195 }
196 self.status = next;
197 self.run_digest = self.expected_digest()?;
198 Ok(())
199 }
200
201 fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
202 if self.schema != RESEARCH_RUN_SCHEMA_V1 {
203 return Err(ResearchContractError::UnsupportedSchema);
204 }
205 validate_id("runId", &self.run_id)?;
206 validate_id("projectId", &self.project_id)?;
207 if self.project_revision == 0 {
208 return Err(ResearchContractError::InvalidField("projectRevision"));
209 }
210 validate_digest_field("sourceSnapshotDigest", &self.source_snapshot_digest)?;
211 validate_digest_field("evidenceSnapshotDigest", &self.evidence_snapshot_digest)?;
212 self.capability_binding
213 .validate()
214 .map_err(|_| ResearchContractError::InvalidField("capabilityBinding"))?;
215 validate_id("providerId", &self.provider_id)?;
216 validate_id("modelId", &self.model_id)?;
217 if matches!(
218 self.reproducibility,
219 ResearchReproducibilityV1::Deterministic
220 ) && self.random_seed.is_none()
221 {
222 return Err(ResearchContractError::InvalidField("randomSeed"));
223 }
224 Ok(())
225 }
226
227 fn expected_digest(&self) -> Result<String, ResearchContractError> {
228 #[derive(Serialize)]
229 struct Identity<'a> {
230 schema: &'a str,
231 run_id: &'a str,
232 project_id: &'a str,
233 project_revision: u64,
234 source_snapshot_digest: &'a str,
235 evidence_snapshot_digest: &'a str,
236 capability_binding: &'a RunCapabilityBindingV1,
237 provider_id: &'a str,
238 model_id: &'a str,
239 reproducibility: ResearchReproducibilityV1,
240 random_seed: Option<u64>,
241 status: ResearchRunStatusV1,
242 }
243 digest(
244 RESEARCH_RUN_DIGEST_DOMAIN,
245 &Identity {
246 schema: &self.schema,
247 run_id: &self.run_id,
248 project_id: &self.project_id,
249 project_revision: self.project_revision,
250 source_snapshot_digest: &self.source_snapshot_digest,
251 evidence_snapshot_digest: &self.evidence_snapshot_digest,
252 capability_binding: &self.capability_binding,
253 provider_id: &self.provider_id,
254 model_id: &self.model_id,
255 reproducibility: self.reproducibility,
256 random_seed: self.random_seed,
257 status: self.status,
258 },
259 )
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::capability::{
267 CapabilityCeiling, CapabilityContribution, CapabilityDescriptor,
268 CapabilityExecutionCeiling, CapabilityKind, CapabilitySet, CapabilitySource,
269 CodeCatalogGeneration, GovernanceCapabilityCeiling, Sha256Digest,
270 WorkspaceCapabilityCeiling,
271 };
272
273 fn digest(ch: char) -> String {
274 format!("sha256:{}", ch.to_string().repeat(64))
275 }
276
277 fn binding() -> RunCapabilityBindingV1 {
278 let source =
279 CapabilitySource::builtin("test", Sha256Digest::new(digest('c')).unwrap()).unwrap();
280 let descriptor = CapabilityDescriptor::new(
281 &source,
282 CapabilityKind::Tool,
283 "tool",
284 "tool",
285 Sha256Digest::new(digest('d')).unwrap(),
286 [],
287 )
288 .unwrap();
289 let contribution = CapabilityContribution::new(source, [descriptor]).unwrap();
290 let set = CapabilitySet::from_contributions(CodeCatalogGeneration::new(1), [contribution])
291 .unwrap();
292 let ceiling = CapabilityCeiling::all(
293 &set,
294 WorkspaceCapabilityCeiling::default(),
295 GovernanceCapabilityCeiling::default(),
296 CapabilityExecutionCeiling::new(1, 1, None, None, None).unwrap(),
297 )
298 .unwrap();
299 RunCapabilityBindingV1::from_set_and_ceiling(&set, &ceiling).unwrap()
300 }
301
302 #[test]
303 fn run_digest_changes_when_status_changes() {
304 let mut run = ResearchRunV1::new(
305 "run-1",
306 "project-1",
307 1,
308 digest('a'),
309 digest('b'),
310 binding(),
311 "local",
312 "model",
313 ResearchReproducibilityV1::Reproducible,
314 None,
315 )
316 .unwrap();
317 let before = run.run_digest.clone();
318 run.transition_to(ResearchRunStatusV1::Admitted).unwrap();
319 assert_ne!(before, run.run_digest);
320 assert!(run.validate().is_ok());
321 let encoded = run.to_vec().unwrap();
322 assert_eq!(ResearchRunV1::from_slice(&encoded).unwrap(), run);
323 }
324
325 #[test]
326 fn deterministic_runs_require_a_seed_and_terminal_runs_cannot_resume() {
327 assert!(matches!(
328 ResearchRunV1::new(
329 "run-1",
330 "project-1",
331 1,
332 digest('a'),
333 digest('b'),
334 binding(),
335 "local",
336 "model",
337 ResearchReproducibilityV1::Deterministic,
338 None,
339 ),
340 Err(ResearchContractError::InvalidField("randomSeed"))
341 ));
342 let mut run = ResearchRunV1::new(
343 "run-1",
344 "project-1",
345 1,
346 digest('a'),
347 digest('b'),
348 binding(),
349 "local",
350 "model",
351 ResearchReproducibilityV1::Reproducible,
352 None,
353 )
354 .unwrap();
355 run.transition_to(ResearchRunStatusV1::Admitted).unwrap();
356 run.transition_to(ResearchRunStatusV1::Running).unwrap();
357 run.transition_to(ResearchRunStatusV1::Completed).unwrap();
358 assert!(matches!(
359 run.transition_to(ResearchRunStatusV1::Running),
360 Err(ResearchContractError::InvalidTransition {
361 from: "completed",
362 to: "running"
363 })
364 ));
365 }
366
367 #[test]
368 fn transition_rejects_a_tampered_run_before_rebinding_identity() {
369 let mut run = ResearchRunV1::new(
370 "run-1",
371 "project-1",
372 1,
373 digest('a'),
374 digest('b'),
375 binding(),
376 "local",
377 "model",
378 ResearchReproducibilityV1::Reproducible,
379 None,
380 )
381 .unwrap();
382 run.project_id = "other-project".to_owned();
383 assert_eq!(
384 run.transition_to(ResearchRunStatusV1::Admitted),
385 Err(ResearchContractError::DigestMismatch("runDigest"))
386 );
387 }
388
389 #[test]
390 fn model_id_uses_the_same_single_line_identity_bound_as_provider_id() {
391 assert_eq!(
392 ResearchRunV1::new(
393 "run-1",
394 "project-1",
395 1,
396 digest('a'),
397 digest('b'),
398 binding(),
399 "local",
400 "model\nwith-newline",
401 ResearchReproducibilityV1::Exploratory,
402 None,
403 ),
404 Err(ResearchContractError::InvalidField("modelId"))
405 );
406 }
407
408 #[test]
409 fn execution_target_binding_rejects_a_cross_run_projection() {
410 let run = ResearchRunV1::new(
411 "run-1",
412 "project-1",
413 1,
414 digest('a'),
415 digest('b'),
416 binding(),
417 "local",
418 "model",
419 ResearchReproducibilityV1::Reproducible,
420 None,
421 )
422 .unwrap();
423 assert!(run
424 .validate_execution_target(&crate::evaluation::ExecutionTargetV1::new(
425 "session-1",
426 "run-1"
427 ))
428 .is_ok());
429 assert_eq!(
430 run.validate_execution_target(&crate::evaluation::ExecutionTargetV1::new(
431 "session-1",
432 "run-2"
433 )),
434 Err(ResearchContractError::InvalidField("executionTarget.runId"))
435 );
436 }
437}