graphforge_knowledge/research/
claims.rs1use super::{
3 ClaimRelationKind, ClaimRelationRecord, MAX_RESEARCH_ROWS, ResearchCategory,
4 ResearchClaimRecord,
5};
6use crate::{
7 AssertionLedger, AssertionSupersessionLedger, KnowledgeError, invalid, require_uuid, require_v7,
8};
9use std::collections::{HashMap, HashSet};
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct ResearchClaimLedger {
15 claims: Vec<ResearchClaimRecord>,
16 relations: Vec<ClaimRelationRecord>,
17}
18impl ResearchClaimLedger {
19 pub fn conceptual_origin(
21 &self,
22 assertion: Uuid,
23 supersessions: &AssertionSupersessionLedger,
24 ) -> Result<Uuid, KnowledgeError> {
25 let concepts = self
26 .claims
27 .iter()
28 .map(|row| (row.assertion_uuid, row.conceptual_uuid))
29 .collect();
30 super::lineage::resolve(&concepts, supersessions)?.get(&assertion).copied().unwrap_or(Some(assertion)).ok_or_else(|| invalid("research_claim.conceptual_uuid", "multiple incompatible conceptual ancestors require an explicit alternative, not revision"))
31 }
32 pub fn claim_batch(&self) -> Result<arrow::record_batch::RecordBatch, KnowledgeError> {
34 super::encoding::claim_batch(&self.claims)
35 }
36 pub fn relation_batch(&self) -> Result<arrow::record_batch::RecordBatch, KnowledgeError> {
38 super::encoding::relation_batch(&self.relations)
39 }
40 pub fn from_batches(
42 claims: &[arrow::record_batch::RecordBatch],
43 relations: &[arrow::record_batch::RecordBatch],
44 ) -> Result<Self, KnowledgeError> {
45 Self::new(
46 super::encoding::claim_rows(claims)?,
47 super::encoding::relation_rows(relations)?,
48 )
49 }
50 pub fn new(
52 mut claims: Vec<ResearchClaimRecord>,
53 mut relations: Vec<ClaimRelationRecord>,
54 ) -> Result<Self, KnowledgeError> {
55 bounded(claims.len(), "research_claims")?;
56 bounded(relations.len(), "claim_relations")?;
57 let mut ids = HashSet::new();
58 for row in &claims {
59 validate_claim(row)?;
60 if !ids.insert(row.assertion_uuid) {
61 return Err(KnowledgeError::Duplicate("research_claim.assertion_uuid"));
62 }
63 }
64 ids.clear();
65 for row in &relations {
66 validate_relation(row)?;
67 if !ids.insert(row.relation_uuid) {
68 return Err(KnowledgeError::Duplicate("claim_relation.relation_uuid"));
69 }
70 }
71 claims.sort_by_key(|row| row.assertion_uuid);
72 relations.sort_by_key(|row| row.relation_uuid);
73 Ok(Self { claims, relations })
74 }
75
76 pub fn merge(&self, staged: &Self) -> Result<Self, KnowledgeError> {
78 let claims = append(
79 &self.claims,
80 &staged.claims,
81 |row| row.assertion_uuid,
82 "research_claim.assertion_uuid",
83 )?;
84 let relations = append(
85 &self.relations,
86 &staged.relations,
87 |row| row.relation_uuid,
88 "claim_relation.relation_uuid",
89 )?;
90 Self::new(claims, relations)
91 }
92
93 pub fn validate_references(
95 &self,
96 assertions: &AssertionLedger,
97 supersessions: &AssertionSupersessionLedger,
98 ) -> Result<(), KnowledgeError> {
99 let assertions: HashMap<_, _> = assertions
100 .assertions
101 .iter()
102 .map(|row| (row.assertion_uuid, row))
103 .collect();
104 let concepts: HashMap<_, _> = self
105 .claims
106 .iter()
107 .map(|row| (row.assertion_uuid, row.conceptual_uuid))
108 .collect();
109 let successors: HashSet<_> = supersessions
110 .relations()
111 .iter()
112 .map(|row| (row.replacement_assertion_uuid, row.prior_assertion_uuid))
113 .collect();
114 for row in &self.claims {
115 let assertion = assertions
116 .get(&row.assertion_uuid)
117 .ok_or(KnowledgeError::Dangling("research_claim.assertion_uuid"))?;
118 if assertion.provenance_uuid != row.provenance_uuid
119 || assertion.recorded_at_micros != row.recorded_at
120 {
121 return Err(invalid(
122 "research_claim.origin",
123 "classification must preserve the assertion's producing provenance and time",
124 ));
125 }
126 }
127 super::lineage::validate(&concepts, supersessions)?;
128 for row in &self.relations {
129 if !assertions.contains_key(&row.source_assertion_uuid)
130 || !assertions.contains_key(&row.target_assertion_uuid)
131 {
132 return Err(KnowledgeError::Dangling("claim_relation.assertion_uuid"));
133 }
134 if row.kind == ClaimRelationKind::Supersedes
135 && !successors.contains(&(row.source_assertion_uuid, row.target_assertion_uuid))
136 {
137 return Err(KnowledgeError::Dangling("claim_relation.supersession"));
138 }
139 }
140 Ok(())
141 }
142
143 #[must_use]
145 pub fn claims(&self) -> &[ResearchClaimRecord] {
146 &self.claims
147 }
148 #[must_use]
150 pub fn relations(&self) -> &[ClaimRelationRecord] {
151 &self.relations
152 }
153}
154
155fn bounded(observed: usize, participant: &'static str) -> Result<(), KnowledgeError> {
156 if observed > MAX_RESEARCH_ROWS {
157 return Err(KnowledgeError::Limit {
158 participant,
159 observed,
160 limit: MAX_RESEARCH_ROWS,
161 });
162 }
163 Ok(())
164}
165fn append<T: Clone + PartialEq>(
166 existing: &[T],
167 staged: &[T],
168 id: impl Fn(&T) -> Uuid,
169 field: &'static str,
170) -> Result<Vec<T>, KnowledgeError> {
171 let by_id: HashMap<_, _> = existing.iter().map(|row| (id(row), row)).collect();
172 let mut rows = existing.to_vec();
173 for row in staged {
174 if let Some(old) = by_id.get(&id(row)) {
175 if *old != row {
176 return Err(KnowledgeError::Conflict(field));
177 }
178 } else {
179 bounded(rows.len() + 1, field)?;
180 rows.push(row.clone());
181 }
182 }
183 Ok(rows)
184}
185fn validate_claim(row: &ResearchClaimRecord) -> Result<(), KnowledgeError> {
186 require_v7(row.assertion_uuid, "assertion_uuid")?;
187 for (id, field) in [
188 (row.conceptual_uuid, "conceptual_uuid"),
189 (row.creator_uuid, "creator_uuid"),
190 (row.provenance_uuid, "provenance_uuid"),
191 ] {
192 require_uuid(id, field)?;
193 }
194 for (id, field) in [
195 (row.run_uuid, "run_uuid"),
196 (row.origin_branch_uuid, "origin_branch_uuid"),
197 (row.origin_version_uuid, "origin_version_uuid"),
198 ] {
199 if let Some(id) = id {
200 require_uuid(id, field)?;
201 }
202 }
203 if row.category == ResearchCategory::MachineExtraction && row.run_uuid.is_none() {
204 return Err(invalid(
205 "research_claim.run_uuid",
206 "machine extraction requires its producer run",
207 ));
208 }
209 if row.origin_branch_uuid.is_some() && row.origin_version_uuid.is_none() {
210 return Err(invalid(
211 "research_claim.origin_version_uuid",
212 "Branch origin requires an exact Version",
213 ));
214 }
215 Ok(())
216}
217fn validate_relation(row: &ClaimRelationRecord) -> Result<(), KnowledgeError> {
218 for (id, field) in [
219 (row.relation_uuid, "relation_uuid"),
220 (row.source_assertion_uuid, "source_assertion_uuid"),
221 (row.target_assertion_uuid, "target_assertion_uuid"),
222 ] {
223 require_v7(id, field)?;
224 }
225 require_uuid(row.creator_uuid, "creator_uuid")?;
226 require_uuid(row.provenance_uuid, "provenance_uuid")?;
227 if row.source_assertion_uuid == row.target_assertion_uuid {
228 return Err(invalid(
229 "claim_relation.target_assertion_uuid",
230 "a claim relation requires distinct assertions",
231 ));
232 }
233 Ok(())
234}