Skip to main content

graphforge_knowledge/research/
claims.rs

1//! Immutable research metadata joins existing assertions rather than copying them.
2use 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/// Frozen research classifications and explicit claim relations.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct ResearchClaimLedger {
15    claims: Vec<ResearchClaimRecord>,
16    relations: Vec<ClaimRelationRecord>,
17}
18impl ResearchClaimLedger {
19    /// Resolve stable conceptual origin through classified and legacy successors.
20    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    /// Encode frozen classifications as native Arrow.
33    pub fn claim_batch(&self) -> Result<arrow::record_batch::RecordBatch, KnowledgeError> {
34        super::encoding::claim_batch(&self.claims)
35    }
36    /// Encode explicit claim relations as native Arrow.
37    pub fn relation_batch(&self) -> Result<arrow::record_batch::RecordBatch, KnowledgeError> {
38        super::encoding::relation_batch(&self.relations)
39    }
40    /// Decode bounded frozen metadata, validating immutable identities.
41    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    /// Validate identities and deterministic ordering without inventing legacy metadata.
51    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    /// Exact metadata replay is idempotent; immutable identity rewrites conflict.
77    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    /// Validate joins against the existing immutable owners before publication.
94    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    /// Existing assertions with no row remain unclassified/statusless.
144    #[must_use]
145    pub fn claims(&self) -> &[ResearchClaimRecord] {
146        &self.claims
147    }
148    /// All explicit alternatives and relations; no winner is inferred.
149    #[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}