Skip to main content

a3s_code_core/research/
evidence_graph.rs

1//! Bounded evidence-graph projection with publication completeness checks.
2
3use super::{
4    digest, validate_digest_field, validate_id, ResearchCitationV1, ResearchClaimStatusV1,
5    ResearchClaimV1, ResearchContractError,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9
10pub const RESEARCH_EVIDENCE_GRAPH_SCHEMA_V1: &str = "a3s.code.evidence-graph.v1";
11pub const RESEARCH_MAX_EVIDENCE_GRAPH_CLAIMS: usize = 512;
12pub const RESEARCH_MAX_EVIDENCE_GRAPH_CITATIONS: usize = 2048;
13const RESEARCH_EVIDENCE_GRAPH_DIGEST_DOMAIN: &str = "a3s.code.evidence-graph.identity.v1";
14
15/// One immutable projection of claims and citations for a research Run.
16///
17/// The graph does not decide scientific truth. It only fences mixed project or
18/// Run identities and measures whether every claim carries explicit support,
19/// conflict, or gap evidence before a host may treat the set as publishable.
20#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22pub struct ResearchEvidenceGraphV1 {
23    pub schema: String,
24    pub graph_id: String,
25    pub project_id: String,
26    pub run_id: String,
27    pub claims: Vec<ResearchClaimV1>,
28    pub citations: Vec<ResearchCitationV1>,
29    pub graph_digest: String,
30}
31
32/// Digest-only completeness counters for one evidence graph.
33#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35pub struct ResearchEvidenceCompletenessV1 {
36    pub claim_count: u32,
37    pub citation_count: u32,
38    pub supported_count: u32,
39    pub conflicted_count: u32,
40    pub unsupported_count: u32,
41    pub proposed_count: u32,
42    pub publication_ready: bool,
43}
44
45impl ResearchEvidenceGraphV1 {
46    /// Construct and validate a graph against the admitted research Run.
47    pub fn new_for_run(
48        graph_id: impl Into<String>,
49        run: &crate::research::ResearchRunV1,
50        claims: Vec<ResearchClaimV1>,
51        citations: Vec<ResearchCitationV1>,
52    ) -> Result<Self, ResearchContractError> {
53        let graph = Self::new(
54            graph_id,
55            run.project_id.clone(),
56            run.run_id.clone(),
57            claims,
58            citations,
59        )?;
60        graph.validate_for_run(run)?;
61        Ok(graph)
62    }
63
64    pub fn new(
65        graph_id: impl Into<String>,
66        project_id: impl Into<String>,
67        run_id: impl Into<String>,
68        mut claims: Vec<ResearchClaimV1>,
69        mut citations: Vec<ResearchCitationV1>,
70    ) -> Result<Self, ResearchContractError> {
71        claims.sort_unstable_by(|left, right| left.claim_id.cmp(&right.claim_id));
72        citations.sort_unstable_by(|left, right| left.citation_id.cmp(&right.citation_id));
73        let mut graph = Self {
74            schema: RESEARCH_EVIDENCE_GRAPH_SCHEMA_V1.to_owned(),
75            graph_id: graph_id.into(),
76            project_id: project_id.into(),
77            run_id: run_id.into(),
78            claims,
79            citations,
80            graph_digest: String::new(),
81        };
82        graph.validate_without_digest()?;
83        graph.graph_digest = graph.expected_digest()?;
84        Ok(graph)
85    }
86
87    pub fn validate_for_run(
88        &self,
89        run: &crate::research::ResearchRunV1,
90    ) -> Result<(), ResearchContractError> {
91        self.validate()?;
92        if self.project_id != run.project_id {
93            return Err(ResearchContractError::InvalidField("projectId"));
94        }
95        if self.run_id != run.run_id {
96            return Err(ResearchContractError::InvalidField("runId"));
97        }
98        for claim in &self.claims {
99            claim.validate_for_run(run)?;
100        }
101        for citation in &self.citations {
102            citation.validate_for_run(run)?;
103        }
104        Ok(())
105    }
106
107    pub fn validate(&self) -> Result<(), ResearchContractError> {
108        self.validate_without_digest()?;
109        validate_digest_field("graphDigest", &self.graph_digest)?;
110        if self.graph_digest != self.expected_digest()? {
111            return Err(ResearchContractError::DigestMismatch("graphDigest"));
112        }
113        Ok(())
114    }
115
116    /// Measure whether every claim has explicit support, conflict, or gap
117    /// evidence and whether citations close those support links.
118    pub fn completeness(&self) -> Result<ResearchEvidenceCompletenessV1, ResearchContractError> {
119        self.validate()?;
120        let mut supported_count = 0u32;
121        let mut conflicted_count = 0u32;
122        let mut unsupported_count = 0u32;
123        let mut proposed_count = 0u32;
124        for claim in &self.claims {
125            match claim.status {
126                ResearchClaimStatusV1::Proposed => proposed_count += 1,
127                ResearchClaimStatusV1::Supported => supported_count += 1,
128                ResearchClaimStatusV1::Conflicted => conflicted_count += 1,
129                ResearchClaimStatusV1::Unsupported => unsupported_count += 1,
130            }
131        }
132        let publication_ready = proposed_count == 0
133            && self
134                .claims
135                .iter()
136                .all(|claim| self.claim_is_publication_complete(claim).is_ok());
137        Ok(ResearchEvidenceCompletenessV1 {
138            claim_count: u32::try_from(self.claims.len())
139                .map_err(|_| ResearchContractError::InvalidField("claims"))?,
140            citation_count: u32::try_from(self.citations.len())
141                .map_err(|_| ResearchContractError::InvalidField("citations"))?,
142            supported_count,
143            conflicted_count,
144            unsupported_count,
145            proposed_count,
146            publication_ready,
147        })
148    }
149
150    /// Fail closed unless every claim is publication-ready and support links
151    /// resolve to citations retained in this graph.
152    pub fn validate_publication_completeness(&self) -> Result<(), ResearchContractError> {
153        let completeness = self.completeness()?;
154        if !completeness.publication_ready {
155            return Err(ResearchContractError::InvalidField("publicationReady"));
156        }
157        Ok(())
158    }
159
160    pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
161        let graph: Self = super::decode_json_slice(bytes)?;
162        graph.validate()?;
163        Ok(graph)
164    }
165
166    pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
167        self.validate()?;
168        super::encode_json(self)
169    }
170
171    fn claim_is_publication_complete(
172        &self,
173        claim: &ResearchClaimV1,
174    ) -> Result<(), ResearchContractError> {
175        if !claim.status.is_publication_ready() {
176            return Err(ResearchContractError::InvalidField("claim.status"));
177        }
178        if matches!(claim.status, ResearchClaimStatusV1::Supported) {
179            let citation_digests: BTreeSet<&str> = self
180                .citations
181                .iter()
182                .filter(|citation| citation.claim_id == claim.claim_id)
183                .map(|citation| citation.citation_digest.as_str())
184                .collect();
185            if citation_digests.is_empty() {
186                return Err(ResearchContractError::InvalidField("citations"));
187            }
188            for support in &claim.support_digests {
189                if !citation_digests.contains(support.as_str()) {
190                    return Err(ResearchContractError::InvalidField("supportDigests"));
191                }
192            }
193        }
194        Ok(())
195    }
196
197    fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
198        if self.schema != RESEARCH_EVIDENCE_GRAPH_SCHEMA_V1 {
199            return Err(ResearchContractError::UnsupportedSchema);
200        }
201        validate_id("graphId", &self.graph_id)?;
202        validate_id("projectId", &self.project_id)?;
203        validate_id("runId", &self.run_id)?;
204        if self.claims.len() > RESEARCH_MAX_EVIDENCE_GRAPH_CLAIMS {
205            return Err(ResearchContractError::InvalidField("claims"));
206        }
207        if self.citations.len() > RESEARCH_MAX_EVIDENCE_GRAPH_CITATIONS {
208            return Err(ResearchContractError::InvalidField("citations"));
209        }
210        for pair in self.claims.windows(2) {
211            if pair[0].claim_id >= pair[1].claim_id {
212                return Err(ResearchContractError::InvalidField("claims"));
213            }
214        }
215        for pair in self.citations.windows(2) {
216            if pair[0].citation_id >= pair[1].citation_id {
217                return Err(ResearchContractError::InvalidField("citations"));
218            }
219        }
220        let mut claim_ids = BTreeMap::new();
221        for claim in &self.claims {
222            claim.validate()?;
223            if claim.project_id != self.project_id {
224                return Err(ResearchContractError::InvalidField("claim.projectId"));
225            }
226            if claim.run_id != self.run_id {
227                return Err(ResearchContractError::InvalidField("claim.runId"));
228            }
229            if claim_ids.insert(claim.claim_id.as_str(), ()).is_some() {
230                return Err(ResearchContractError::InvalidField("claimId"));
231            }
232        }
233        let mut citation_ids = BTreeSet::new();
234        for citation in &self.citations {
235            citation.validate()?;
236            if citation.project_id != self.project_id {
237                return Err(ResearchContractError::InvalidField("citation.projectId"));
238            }
239            if citation.run_id != self.run_id {
240                return Err(ResearchContractError::InvalidField("citation.runId"));
241            }
242            if !claim_ids.contains_key(citation.claim_id.as_str()) {
243                return Err(ResearchContractError::InvalidField("citation.claimId"));
244            }
245            if !citation_ids.insert(citation.citation_id.as_str()) {
246                return Err(ResearchContractError::InvalidField("citationId"));
247            }
248        }
249        Ok(())
250    }
251
252    fn expected_digest(&self) -> Result<String, ResearchContractError> {
253        #[derive(Serialize)]
254        struct Identity<'a> {
255            schema: &'a str,
256            graph_id: &'a str,
257            project_id: &'a str,
258            run_id: &'a str,
259            claim_digests: Vec<&'a str>,
260            citation_digests: Vec<&'a str>,
261        }
262        digest(
263            RESEARCH_EVIDENCE_GRAPH_DIGEST_DOMAIN,
264            &Identity {
265                schema: &self.schema,
266                graph_id: &self.graph_id,
267                project_id: &self.project_id,
268                run_id: &self.run_id,
269                claim_digests: self
270                    .claims
271                    .iter()
272                    .map(|claim| claim.claim_digest.as_str())
273                    .collect(),
274                citation_digests: self
275                    .citations
276                    .iter()
277                    .map(|citation| citation.citation_digest.as_str())
278                    .collect(),
279            },
280        )
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::capability::{
288        CapabilityCeiling, CapabilityContribution, CapabilityDescriptor,
289        CapabilityExecutionCeiling, CapabilityKind, CapabilitySet, CapabilitySource,
290        CodeCatalogGeneration, GovernanceCapabilityCeiling, RunCapabilityBindingV1, Sha256Digest,
291        WorkspaceCapabilityCeiling,
292    };
293    use crate::research::{ResearchReproducibilityV1, ResearchRunStatusV1, ResearchRunV1};
294
295    fn digest(ch: char) -> String {
296        format!("sha256:{}", ch.to_string().repeat(64))
297    }
298
299    fn binding() -> RunCapabilityBindingV1 {
300        let source =
301            CapabilitySource::builtin("test", Sha256Digest::new(digest('c')).unwrap()).unwrap();
302        let descriptor = CapabilityDescriptor::new(
303            &source,
304            CapabilityKind::Tool,
305            "tool",
306            "tool",
307            Sha256Digest::new(digest('d')).unwrap(),
308            [],
309        )
310        .unwrap();
311        let contribution = CapabilityContribution::new(source, [descriptor]).unwrap();
312        let set = CapabilitySet::from_contributions(CodeCatalogGeneration::new(1), [contribution])
313            .unwrap();
314        let ceiling = CapabilityCeiling::all(
315            &set,
316            WorkspaceCapabilityCeiling::default(),
317            GovernanceCapabilityCeiling::default(),
318            CapabilityExecutionCeiling::new(1, 1, None, None, None).unwrap(),
319        )
320        .unwrap();
321        RunCapabilityBindingV1::from_set_and_ceiling(&set, &ceiling).unwrap()
322    }
323
324    fn admitted_run() -> ResearchRunV1 {
325        let mut run = ResearchRunV1::new(
326            "run-1",
327            "project-1",
328            1,
329            digest('1'),
330            digest('2'),
331            binding(),
332            "provider-1",
333            "model-1",
334            ResearchReproducibilityV1::Reproducible,
335            Some(7),
336        )
337        .unwrap();
338        run.transition_to(ResearchRunStatusV1::Admitted).unwrap();
339        run
340    }
341
342    #[test]
343    fn publication_completeness_requires_support_or_explicit_gap() {
344        let run = admitted_run();
345        let citation = ResearchCitationV1::new(
346            "cite-1",
347            "project-1",
348            "run-1",
349            "claim-1",
350            digest('3'),
351            digest('4'),
352            Some("p.1".to_owned()),
353            1,
354        )
355        .unwrap();
356        let supported = ResearchClaimV1::new("claim-1", "project-1", "run-1", digest('a'), 1)
357            .unwrap()
358            .mark_supported(vec![citation.citation_digest.clone()])
359            .unwrap();
360        let gap = ResearchClaimV1::new("claim-2", "project-1", "run-1", digest('b'), 2)
361            .unwrap()
362            .mark_unsupported(digest('5'))
363            .unwrap();
364        let graph = ResearchEvidenceGraphV1::new_for_run(
365            "graph-1",
366            &run,
367            vec![supported, gap],
368            vec![citation],
369        )
370        .unwrap();
371        let completeness = graph.completeness().unwrap();
372        assert!(completeness.publication_ready);
373        assert_eq!(completeness.supported_count, 1);
374        assert_eq!(completeness.unsupported_count, 1);
375        assert_eq!(completeness.proposed_count, 0);
376        graph.validate_publication_completeness().unwrap();
377        let encoded = graph.to_vec().unwrap();
378        assert_eq!(
379            ResearchEvidenceGraphV1::from_slice(&encoded).unwrap(),
380            graph
381        );
382    }
383
384    #[test]
385    fn proposed_or_unlinked_support_blocks_publication() {
386        let run = admitted_run();
387        let proposed =
388            ResearchClaimV1::new("claim-1", "project-1", "run-1", digest('a'), 1).unwrap();
389        let graph =
390            ResearchEvidenceGraphV1::new_for_run("graph-1", &run, vec![proposed], Vec::new())
391                .unwrap();
392        assert!(!graph.completeness().unwrap().publication_ready);
393        assert!(matches!(
394            graph.validate_publication_completeness(),
395            Err(ResearchContractError::InvalidField("publicationReady"))
396        ));
397
398        let citation = ResearchCitationV1::new(
399            "cite-1",
400            "project-1",
401            "run-1",
402            "claim-2",
403            digest('3'),
404            digest('4'),
405            None,
406            1,
407        )
408        .unwrap();
409        let supported = ResearchClaimV1::new("claim-2", "project-1", "run-1", digest('b'), 1)
410            .unwrap()
411            .mark_supported(vec![digest('6')])
412            .unwrap();
413        let graph =
414            ResearchEvidenceGraphV1::new_for_run("graph-2", &run, vec![supported], vec![citation])
415                .unwrap();
416        assert!(!graph.completeness().unwrap().publication_ready);
417    }
418
419    #[test]
420    fn mixed_run_or_orphan_citation_fail_closed() {
421        let run = admitted_run();
422        let claim = ResearchClaimV1::new("claim-1", "project-1", "run-1", digest('a'), 1).unwrap();
423        let orphan = ResearchCitationV1::new(
424            "cite-1",
425            "project-1",
426            "run-1",
427            "missing-claim",
428            digest('3'),
429            digest('4'),
430            None,
431            1,
432        )
433        .unwrap();
434        assert!(matches!(
435            ResearchEvidenceGraphV1::new_for_run("graph-1", &run, vec![claim], vec![orphan]),
436            Err(ResearchContractError::InvalidField("citation.claimId"))
437        ));
438    }
439}