Skip to main content

a3s_code_core/research/
provenance.rs

1use super::{
2    digest, validate_digest_field, validate_id, ResearchContractError, RESEARCH_MAX_DIGESTS,
3};
4use serde::{Deserialize, Serialize};
5
6pub const RESEARCH_PROVENANCE_RECEIPT_SCHEMA_V1: &str = "a3s.code.provenance-receipt.v1";
7const RESEARCH_PROVENANCE_RECEIPT_DIGEST_DOMAIN: &str = "a3s.code.provenance-receipt.identity.v1";
8
9/// Artifact families that can be bound to a research provenance receipt.
10#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ResearchArtifactKindV1 {
13    Figure,
14    Table,
15    Dataset,
16    Notebook,
17    Model,
18    Report,
19    Other,
20}
21
22pub const RESEARCH_ARTIFACT_KINDS: &[ResearchArtifactKindV1] = &[
23    ResearchArtifactKindV1::Figure,
24    ResearchArtifactKindV1::Table,
25    ResearchArtifactKindV1::Dataset,
26    ResearchArtifactKindV1::Notebook,
27    ResearchArtifactKindV1::Model,
28    ResearchArtifactKindV1::Report,
29    ResearchArtifactKindV1::Other,
30];
31
32/// Reproducibility identity for one generated artifact.
33#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35pub struct ResearchProvenanceReceiptV1 {
36    pub schema: String,
37    pub project_id: String,
38    pub project_revision: u64,
39    pub run_id: String,
40    pub artifact_id: String,
41    pub artifact_kind: ResearchArtifactKindV1,
42    pub artifact_digest: String,
43    pub input_digests: Vec<String>,
44    pub workflow_digest: String,
45    pub code_digest: String,
46    pub environment_digest: String,
47    pub provider_id: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub model_digest: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub random_seed: Option<u64>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub validation_digest: Option<String>,
54    pub receipt_digest: String,
55}
56
57impl ResearchProvenanceReceiptV1 {
58    #[allow(clippy::too_many_arguments)]
59    pub fn new(
60        project_id: impl Into<String>,
61        project_revision: u64,
62        run_id: impl Into<String>,
63        artifact_id: impl Into<String>,
64        artifact_kind: ResearchArtifactKindV1,
65        artifact_digest: impl Into<String>,
66        mut input_digests: Vec<String>,
67        workflow_digest: impl Into<String>,
68        code_digest: impl Into<String>,
69        environment_digest: impl Into<String>,
70        provider_id: impl Into<String>,
71        model_digest: Option<String>,
72        random_seed: Option<u64>,
73        validation_digest: Option<String>,
74    ) -> Result<Self, ResearchContractError> {
75        input_digests.sort();
76        input_digests.dedup();
77        let mut receipt = Self {
78            schema: RESEARCH_PROVENANCE_RECEIPT_SCHEMA_V1.to_owned(),
79            project_id: project_id.into(),
80            project_revision,
81            run_id: run_id.into(),
82            artifact_id: artifact_id.into(),
83            artifact_kind,
84            artifact_digest: artifact_digest.into(),
85            input_digests,
86            workflow_digest: workflow_digest.into(),
87            code_digest: code_digest.into(),
88            environment_digest: environment_digest.into(),
89            provider_id: provider_id.into(),
90            model_digest,
91            random_seed,
92            validation_digest,
93            receipt_digest: String::new(),
94        };
95        receipt.validate_without_digest()?;
96        receipt.receipt_digest = receipt.expected_digest()?;
97        Ok(receipt)
98    }
99
100    pub fn validate(&self) -> Result<(), ResearchContractError> {
101        self.validate_without_digest()?;
102        validate_digest_field("receiptDigest", &self.receipt_digest)?;
103        if self.receipt_digest != self.expected_digest()? {
104            return Err(ResearchContractError::DigestMismatch("receiptDigest"));
105        }
106        Ok(())
107    }
108
109    /// Decode a bounded JSON provenance receipt and validate its identity
110    /// before returning it to a caller at a process boundary.
111    pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
112        let receipt: Self = super::decode_json_slice(bytes)?;
113        receipt.validate()?;
114        Ok(receipt)
115    }
116
117    /// Encode a validated provenance receipt for a process boundary.
118    pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
119        self.validate()?;
120        super::encode_json(self)
121    }
122
123    fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
124        if self.schema != RESEARCH_PROVENANCE_RECEIPT_SCHEMA_V1 {
125            return Err(ResearchContractError::UnsupportedSchema);
126        }
127        validate_id("projectId", &self.project_id)?;
128        if self.project_revision == 0 {
129            return Err(ResearchContractError::InvalidField("projectRevision"));
130        }
131        validate_id("runId", &self.run_id)?;
132        validate_id("artifactId", &self.artifact_id)?;
133        validate_digest_field("artifactDigest", &self.artifact_digest)?;
134        if self.input_digests.is_empty() || self.input_digests.len() > RESEARCH_MAX_DIGESTS {
135            return Err(ResearchContractError::InvalidField("inputDigests"));
136        }
137        for pair in self.input_digests.windows(2) {
138            if pair[0] >= pair[1] {
139                return Err(ResearchContractError::InvalidField("inputDigests"));
140            }
141        }
142        for digest in &self.input_digests {
143            validate_digest_field("inputDigests", digest)?;
144        }
145        validate_digest_field("workflowDigest", &self.workflow_digest)?;
146        validate_digest_field("codeDigest", &self.code_digest)?;
147        validate_digest_field("environmentDigest", &self.environment_digest)?;
148        validate_id("providerId", &self.provider_id)?;
149        if let Some(model_digest) = &self.model_digest {
150            validate_digest_field("modelDigest", model_digest)?;
151        }
152        if let Some(validation_digest) = &self.validation_digest {
153            validate_digest_field("validationDigest", validation_digest)?;
154        }
155        Ok(())
156    }
157
158    fn expected_digest(&self) -> Result<String, ResearchContractError> {
159        #[derive(Serialize)]
160        struct Identity<'a> {
161            schema: &'a str,
162            project_id: &'a str,
163            project_revision: u64,
164            run_id: &'a str,
165            artifact_id: &'a str,
166            artifact_kind: ResearchArtifactKindV1,
167            artifact_digest: &'a str,
168            input_digests: &'a [String],
169            workflow_digest: &'a str,
170            code_digest: &'a str,
171            environment_digest: &'a str,
172            provider_id: &'a str,
173            model_digest: Option<&'a str>,
174            random_seed: Option<u64>,
175            validation_digest: Option<&'a str>,
176        }
177        digest(
178            RESEARCH_PROVENANCE_RECEIPT_DIGEST_DOMAIN,
179            &Identity {
180                schema: &self.schema,
181                project_id: &self.project_id,
182                project_revision: self.project_revision,
183                run_id: &self.run_id,
184                artifact_id: &self.artifact_id,
185                artifact_kind: self.artifact_kind,
186                artifact_digest: &self.artifact_digest,
187                input_digests: &self.input_digests,
188                workflow_digest: &self.workflow_digest,
189                code_digest: &self.code_digest,
190                environment_digest: &self.environment_digest,
191                provider_id: &self.provider_id,
192                model_digest: self.model_digest.as_deref(),
193                random_seed: self.random_seed,
194                validation_digest: self.validation_digest.as_deref(),
195            },
196        )
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn digest(ch: char) -> String {
205        format!("sha256:{}", ch.to_string().repeat(64))
206    }
207
208    #[test]
209    fn receipt_sorts_inputs_and_binds_environment_and_workflow() {
210        let receipt = ResearchProvenanceReceiptV1::new(
211            "project-1",
212            1,
213            "run-1",
214            "artifact-1",
215            ResearchArtifactKindV1::Figure,
216            digest('a'),
217            vec![digest('c'), digest('b'), digest('c')],
218            digest('d'),
219            digest('e'),
220            digest('f'),
221            "local",
222            Some(digest('f')),
223            Some(7),
224            None,
225        )
226        .unwrap();
227        assert_eq!(receipt.input_digests, vec![digest('b'), digest('c')]);
228        assert!(receipt.validate().is_ok());
229        let encoded = receipt.to_vec().unwrap();
230        assert_eq!(
231            ResearchProvenanceReceiptV1::from_slice(&encoded).unwrap(),
232            receipt
233        );
234    }
235
236    #[test]
237    fn receipt_rejects_duplicate_or_unsorted_mutation() {
238        let mut receipt = ResearchProvenanceReceiptV1::new(
239            "project-1",
240            1,
241            "run-1",
242            "artifact-1",
243            ResearchArtifactKindV1::Report,
244            digest('a'),
245            vec![digest('b'), digest('c')],
246            digest('d'),
247            digest('e'),
248            digest('f'),
249            "local",
250            None,
251            None,
252            None,
253        )
254        .unwrap();
255        receipt.input_digests.reverse();
256        assert_eq!(
257            receipt.validate(),
258            Err(ResearchContractError::InvalidField("inputDigests"))
259        );
260    }
261}