Skip to main content

a3s_code_core/research/
citation.rs

1//! Digest-bound citations that link claims to source-span evidence.
2
3use super::{
4    digest, validate_digest_field, validate_id, ResearchContractError, RESEARCH_MAX_TEXT_BYTES,
5};
6use serde::{Deserialize, Serialize};
7
8pub const RESEARCH_CITATION_SCHEMA_V1: &str = "a3s.code.research-citation.v1";
9const RESEARCH_CITATION_DIGEST_DOMAIN: &str = "a3s.code.research-citation.identity.v1";
10
11/// One bounded citation from a claim to a source span or evidence fact.
12///
13/// Citations carry digests only. Hosts retain source text and locators behind
14/// those digests and optional bounded locator text for UI routing.
15#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase", deny_unknown_fields)]
17pub struct ResearchCitationV1 {
18    pub schema: String,
19    pub citation_id: String,
20    pub project_id: String,
21    pub run_id: String,
22    pub claim_id: String,
23    pub source_digest: String,
24    pub source_span_digest: String,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub locator: Option<String>,
27    pub observed_at_ms: u64,
28    pub citation_digest: String,
29}
30
31impl ResearchCitationV1 {
32    #[allow(clippy::too_many_arguments)]
33    pub fn new(
34        citation_id: impl Into<String>,
35        project_id: impl Into<String>,
36        run_id: impl Into<String>,
37        claim_id: impl Into<String>,
38        source_digest: impl Into<String>,
39        source_span_digest: impl Into<String>,
40        locator: Option<String>,
41        observed_at_ms: u64,
42    ) -> Result<Self, ResearchContractError> {
43        let mut citation = Self {
44            schema: RESEARCH_CITATION_SCHEMA_V1.to_owned(),
45            citation_id: citation_id.into(),
46            project_id: project_id.into(),
47            run_id: run_id.into(),
48            claim_id: claim_id.into(),
49            source_digest: source_digest.into(),
50            source_span_digest: source_span_digest.into(),
51            locator,
52            observed_at_ms,
53            citation_digest: String::new(),
54        };
55        citation.validate_without_digest()?;
56        citation.citation_digest = citation.expected_digest()?;
57        Ok(citation)
58    }
59
60    pub fn validate_for_run(
61        &self,
62        run: &crate::research::ResearchRunV1,
63    ) -> Result<(), ResearchContractError> {
64        self.validate()?;
65        if self.project_id != run.project_id {
66            return Err(ResearchContractError::InvalidField("projectId"));
67        }
68        if self.run_id != run.run_id {
69            return Err(ResearchContractError::InvalidField("runId"));
70        }
71        Ok(())
72    }
73
74    pub fn validate(&self) -> Result<(), ResearchContractError> {
75        self.validate_without_digest()?;
76        validate_digest_field("citationDigest", &self.citation_digest)?;
77        if self.citation_digest != self.expected_digest()? {
78            return Err(ResearchContractError::DigestMismatch("citationDigest"));
79        }
80        Ok(())
81    }
82
83    pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
84        let citation: Self = super::decode_json_slice(bytes)?;
85        citation.validate()?;
86        Ok(citation)
87    }
88
89    pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
90        self.validate()?;
91        super::encode_json(self)
92    }
93
94    fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
95        if self.schema != RESEARCH_CITATION_SCHEMA_V1 {
96            return Err(ResearchContractError::UnsupportedSchema);
97        }
98        validate_id("citationId", &self.citation_id)?;
99        validate_id("projectId", &self.project_id)?;
100        validate_id("runId", &self.run_id)?;
101        validate_id("claimId", &self.claim_id)?;
102        validate_digest_field("sourceDigest", &self.source_digest)?;
103        validate_digest_field("sourceSpanDigest", &self.source_span_digest)?;
104        if let Some(locator) = &self.locator {
105            super::validate_text("locator", locator, RESEARCH_MAX_TEXT_BYTES)?;
106        }
107        if self.observed_at_ms == 0 {
108            return Err(ResearchContractError::InvalidField("observedAtMs"));
109        }
110        Ok(())
111    }
112
113    fn expected_digest(&self) -> Result<String, ResearchContractError> {
114        #[derive(Serialize)]
115        struct Identity<'a> {
116            schema: &'a str,
117            citation_id: &'a str,
118            project_id: &'a str,
119            run_id: &'a str,
120            claim_id: &'a str,
121            source_digest: &'a str,
122            source_span_digest: &'a str,
123            locator: Option<&'a str>,
124            observed_at_ms: u64,
125        }
126        digest(
127            RESEARCH_CITATION_DIGEST_DOMAIN,
128            &Identity {
129                schema: &self.schema,
130                citation_id: &self.citation_id,
131                project_id: &self.project_id,
132                run_id: &self.run_id,
133                claim_id: &self.claim_id,
134                source_digest: &self.source_digest,
135                source_span_digest: &self.source_span_digest,
136                locator: self.locator.as_deref(),
137                observed_at_ms: self.observed_at_ms,
138            },
139        )
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn digest(ch: char) -> String {
148        format!("sha256:{}", ch.to_string().repeat(64))
149    }
150
151    #[test]
152    fn citation_is_digest_bound_and_rejects_multiline_locator() {
153        let citation = ResearchCitationV1::new(
154            "cite-1",
155            "project-1",
156            "run-1",
157            "claim-1",
158            digest('a'),
159            digest('b'),
160            Some("page-3".to_owned()),
161            1,
162        )
163        .unwrap();
164        let encoded = citation.to_vec().unwrap();
165        assert_eq!(ResearchCitationV1::from_slice(&encoded).unwrap(), citation);
166        assert!(matches!(
167            ResearchCitationV1::new(
168                "cite-1",
169                "project-1",
170                "run-1",
171                "claim-1",
172                digest('a'),
173                digest('b'),
174                Some("page-3\n".to_owned()),
175                1,
176            ),
177            Err(ResearchContractError::InvalidField("locator"))
178        ));
179    }
180}