1use 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#[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#[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 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 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 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}