Skip to main content

termivar_scanner/knowledge/
relations.rs

1//! Bounded knowledge-graph entity and relation storage.
2
3use termivar_core::{
4    EntityId, Evidence, KnowledgeEntity, KnowledgeRelation, RelationId, RelationKind,
5};
6
7use crate::knowledge::{
8    bump_subject_revision, collect_indexed, identity_conflict, index, KnowledgeBase,
9    KnowledgeBaseError, KnowledgeRecordKind, KnowledgeWrite,
10};
11
12/// Hard byte ceiling for one stored knowledge-relation identifier.
13pub const MAX_KNOWLEDGE_RELATION_ID_BYTES: usize = 512;
14/// Hard byte ceiling for either entity identifier on a stored relation.
15pub const MAX_KNOWLEDGE_RELATION_ENTITY_ID_BYTES: usize = 2_048;
16/// Hard byte ceiling for a stored custom relation-kind identifier.
17pub const MAX_KNOWLEDGE_RELATION_KIND_BYTES: usize = 256;
18/// Hard ceiling for distinct evidence records backing one stored relation.
19pub const MAX_KNOWLEDGE_RELATION_EVIDENCE_IDS: usize = 32;
20/// Hard byte ceiling for each evidence identifier backing a stored relation.
21pub const MAX_KNOWLEDGE_RELATION_EVIDENCE_ID_BYTES: usize = 512;
22
23impl KnowledgeBase {
24    /// Atomically inserts one immutable observation and its sole graph edge.
25    ///
26    /// The relation must cite exactly the supplied evidence ID. Both identity
27    /// conflicts are checked before either record or secondary index changes,
28    /// so callers never persist an orphaned half of the bundle. Relation IDs,
29    /// endpoints, custom kinds, and provenance are checked against the compiled
30    /// storage ceilings before either record is written.
31    pub fn insert_evidence_with_relation(
32        &self,
33        evidence: Evidence,
34        relation: KnowledgeRelation,
35    ) -> Result<(KnowledgeWrite, KnowledgeWrite), KnowledgeBaseError> {
36        validate_relation_bounds(&relation)?;
37        let evidence_id = evidence.id().clone();
38        let relation_id = relation.id().clone();
39        if relation.evidence_ids().len() != 1 || !relation.evidence_ids().contains(&evidence_id) {
40            return Err(KnowledgeBaseError::RelationEvidenceMismatch {
41                relation_id: relation_id.to_string(),
42                evidence_id: evidence_id.to_string(),
43            });
44        }
45        if relation.from() != evidence.subject() {
46            return Err(KnowledgeBaseError::RelationSubjectMismatch {
47                relation_id: relation_id.to_string(),
48                evidence_subject: evidence.subject().to_string(),
49                relation_from: relation.from().to_string(),
50            });
51        }
52
53        let evidence_subject = evidence.subject().clone();
54        let evidence_predicate = evidence.predicate().clone();
55        let relation_from = relation.from().clone();
56        let relation_to = relation.to().clone();
57        let mut state = self.write_state();
58
59        let evidence_write = match state.evidence.get(&evidence_id) {
60            Some(existing) if existing == &evidence => KnowledgeWrite::Unchanged,
61            Some(_) => {
62                return Err(identity_conflict(
63                    KnowledgeRecordKind::Evidence,
64                    &evidence_id,
65                ));
66            },
67            None => KnowledgeWrite::Inserted,
68        };
69        let relation_write = match state.relations.get(&relation_id) {
70            Some(existing) if existing == &relation => KnowledgeWrite::Unchanged,
71            Some(existing)
72                if existing.from() == relation.from()
73                    && existing.to() == relation.to()
74                    && existing.kind() == relation.kind() =>
75            {
76                KnowledgeWrite::Updated
77            },
78            Some(_) => {
79                return Err(identity_conflict(
80                    KnowledgeRecordKind::Relation,
81                    &relation_id,
82                ));
83            },
84            None => KnowledgeWrite::Inserted,
85        };
86
87        if evidence_write == KnowledgeWrite::Inserted {
88            state.evidence.insert(evidence_id.clone(), evidence);
89            bump_subject_revision(&mut state, &evidence_subject);
90            index(
91                &mut state.evidence_by_subject,
92                evidence_subject,
93                evidence_id.clone(),
94            );
95            index(
96                &mut state.evidence_by_predicate,
97                evidence_predicate,
98                evidence_id,
99            );
100        }
101        if relation_write != KnowledgeWrite::Unchanged {
102            state.relations.insert(relation_id.clone(), relation);
103            if relation_write == KnowledgeWrite::Inserted {
104                index(
105                    &mut state.relations_from,
106                    relation_from,
107                    relation_id.clone(),
108                );
109                index(&mut state.relations_to, relation_to, relation_id);
110            }
111        }
112
113        Ok((evidence_write, relation_write))
114    }
115
116    /// Inserts one immutable knowledge-graph entity.
117    pub fn insert_entity(
118        &self,
119        entity: KnowledgeEntity,
120    ) -> Result<KnowledgeWrite, KnowledgeBaseError> {
121        let id = entity.id().clone();
122        let mut state = self.write_state();
123
124        if let Some(existing) = state.entities.get(&id) {
125            return if existing == &entity {
126                Ok(KnowledgeWrite::Unchanged)
127            } else {
128                Err(identity_conflict(KnowledgeRecordKind::Entity, &id))
129            };
130        }
131
132        state.entities.insert(id, entity);
133        Ok(KnowledgeWrite::Inserted)
134    }
135
136    /// Inserts a relation or updates its confidence and provenance.
137    ///
138    /// The source, destination, and relation kind form the immutable graph
139    /// identity for an existing relation ID. Every field and provenance ID is
140    /// validated against the compiled relation storage ceilings first.
141    pub fn upsert_relation(
142        &self,
143        relation: KnowledgeRelation,
144    ) -> Result<KnowledgeWrite, KnowledgeBaseError> {
145        validate_relation_bounds(&relation)?;
146        let id = relation.id().clone();
147        let from = relation.from().clone();
148        let to = relation.to().clone();
149        let mut state = self.write_state();
150
151        if let Some(existing) = state.relations.get(&id) {
152            if existing == &relation {
153                return Ok(KnowledgeWrite::Unchanged);
154            }
155            if existing.from() != relation.from()
156                || existing.to() != relation.to()
157                || existing.kind() != relation.kind()
158            {
159                return Err(identity_conflict(KnowledgeRecordKind::Relation, &id));
160            }
161            state.relations.insert(id, relation);
162            return Ok(KnowledgeWrite::Updated);
163        }
164
165        state.relations.insert(id.clone(), relation);
166        index(&mut state.relations_from, from, id.clone());
167        index(&mut state.relations_to, to, id);
168        Ok(KnowledgeWrite::Inserted)
169    }
170
171    /// Returns an entity snapshot by ID.
172    pub fn entity(&self, id: &EntityId) -> Option<KnowledgeEntity> {
173        self.read_state().entities.get(id).cloned()
174    }
175
176    /// Returns a relation snapshot by ID.
177    pub fn relation(&self, id: &RelationId) -> Option<KnowledgeRelation> {
178        self.read_state().relations.get(id).cloned()
179    }
180
181    /// Returns outgoing graph relations, ordered by relation ID.
182    pub fn relations_from(&self, entity_id: &EntityId) -> Vec<KnowledgeRelation> {
183        let state = self.read_state();
184        collect_indexed(state.relations_from.get(entity_id), &state.relations)
185    }
186
187    /// Returns incoming graph relations, ordered by relation ID.
188    pub fn relations_to(&self, entity_id: &EntityId) -> Vec<KnowledgeRelation> {
189        let state = self.read_state();
190        collect_indexed(state.relations_to.get(entity_id), &state.relations)
191    }
192
193    /// Returns one bounded page of incoming relations in stable ID order.
194    ///
195    /// `after_exclusive` is an exclusive cursor and does not need to identify a
196    /// stored relation. At most `limit` indexed records are cloned. A zero limit
197    /// returns immediately without reading the store.
198    pub fn relations_to_page(
199        &self,
200        entity_id: &EntityId,
201        after_exclusive: Option<&RelationId>,
202        limit: usize,
203    ) -> Vec<KnowledgeRelation> {
204        self.relations_to_page_with_more(entity_id, after_exclusive, limit)
205            .0
206    }
207
208    /// Returns a bounded relation page and whether another indexed ID exists.
209    ///
210    /// The look-ahead checks only the borrowed relation index; it never clones
211    /// the record beyond this page's explicit `limit`.
212    pub(crate) fn relations_to_page_with_more(
213        &self,
214        entity_id: &EntityId,
215        after_exclusive: Option<&RelationId>,
216        limit: usize,
217    ) -> (Vec<KnowledgeRelation>, bool) {
218        if limit == 0 {
219            return (Vec::new(), false);
220        }
221
222        let state = self.read_state();
223        let Some(ids) = state.relations_to.get(entity_id) else {
224            return (Vec::new(), false);
225        };
226        let lower_bound = after_exclusive
227            .cloned()
228            .map_or(std::ops::Bound::Unbounded, std::ops::Bound::Excluded);
229        let mut ids = ids.range((lower_bound, std::ops::Bound::Unbounded));
230        let relations = ids
231            .by_ref()
232            .take(limit)
233            .filter_map(|id| state.relations.get(id).cloned())
234            .collect();
235        let has_more = ids.next().is_some();
236        (relations, has_more)
237    }
238}
239
240fn validate_relation_bounds(relation: &KnowledgeRelation) -> Result<(), KnowledgeBaseError> {
241    validate_relation_limit(
242        "id",
243        relation.id().as_str().len(),
244        MAX_KNOWLEDGE_RELATION_ID_BYTES,
245    )?;
246    validate_relation_limit(
247        "from",
248        relation.from().as_str().len(),
249        MAX_KNOWLEDGE_RELATION_ENTITY_ID_BYTES,
250    )?;
251    validate_relation_limit(
252        "to",
253        relation.to().as_str().len(),
254        MAX_KNOWLEDGE_RELATION_ENTITY_ID_BYTES,
255    )?;
256    if let RelationKind::Custom(kind) = relation.kind() {
257        validate_relation_limit("kind", kind.len(), MAX_KNOWLEDGE_RELATION_KIND_BYTES)?;
258    }
259    validate_relation_limit(
260        "evidence_ids",
261        relation.evidence_ids().len(),
262        MAX_KNOWLEDGE_RELATION_EVIDENCE_IDS,
263    )?;
264    for evidence_id in relation.evidence_ids() {
265        validate_relation_limit(
266            "evidence_id",
267            evidence_id.as_str().len(),
268            MAX_KNOWLEDGE_RELATION_EVIDENCE_ID_BYTES,
269        )?;
270    }
271    Ok(())
272}
273
274fn validate_relation_limit(
275    field: &'static str,
276    actual: usize,
277    maximum: usize,
278) -> Result<(), KnowledgeBaseError> {
279    if actual > maximum {
280        return Err(KnowledgeBaseError::RelationLimitExceeded {
281            field,
282            actual,
283            maximum,
284        });
285    }
286    Ok(())
287}