Skip to main content

a3s_code_core/research/
run.rs

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/// Reproducibility promise selected by a host for one research run.
9#[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/// Durable lifecycle state for a research run.
28#[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/// Exact Code/Use identity and policy binding for one scientific run.
75#[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    /// Decode a bounded JSON run and validate its identity before returning it
138    /// to a caller at a process boundary.
139    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    /// Encode a validated run for a process boundary.
146    pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
147        self.validate()?;
148        super::encode_json(self)
149    }
150
151    /// Verify that a research run is attached to the exact Code execution
152    /// target that was admitted by the host.  The target's session identity is
153    /// retained by Code's execution plane; this contract only owns the shared
154    /// Run id and refuses a cross-Run projection.
155    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    /// Validate that this Run has crossed the admission boundary before a
170    /// host attaches reviewer evidence or findings to it.
171    ///
172    /// A planned Run has not yet frozen an executable identity, so accepting
173    /// review output for it would allow an evaluator result to exist without
174    /// a corresponding admitted research execution. Terminal and checkpoint
175    /// states remain reviewable because hosts may inspect completed or failed
176    /// artifacts after execution.
177    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}