Skip to main content

termivar_scanner/knowledge/
writes.rs

1//! Atomic evidence, fact, and hypothesis write transactions.
2
3use std::{collections::HashMap, fmt};
4
5use serde::{Deserialize, Serialize};
6use termivar_core::{
7    EntityId, Evidence, EvidenceId, Fact, Hypothesis, HypothesisState, OntologyStats,
8};
9
10use crate::knowledge::{index, KnowledgeBase, KnowledgeSnapshot, KnowledgeState};
11
12/// Result of an idempotent write to the knowledge base.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum KnowledgeWrite {
17    /// A new identity was stored and indexed.
18    Inserted,
19    /// An existing mutable record was replaced with a newer evaluation.
20    Updated,
21    /// The store already contained the exact same record.
22    Unchanged,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub(crate) enum HypothesisStateTransition {
27    Missing,
28    SubjectMismatch {
29        actual: EntityId,
30    },
31    StaleSnapshot(KnowledgeBaseError),
32    TerminalConflict {
33        current: HypothesisState,
34        attempted: HypothesisState,
35    },
36    Written(KnowledgeWrite),
37}
38
39/// Record categories used in identity-conflict diagnostics.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum KnowledgeRecordKind {
43    /// Immutable evidence observation.
44    Evidence,
45    /// Materialized fact.
46    Fact,
47    /// Evaluated hypothesis.
48    Hypothesis,
49    /// Knowledge-graph entity.
50    Entity,
51    /// Knowledge-graph relation.
52    Relation,
53}
54
55impl fmt::Display for KnowledgeRecordKind {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        let name = match self {
58            Self::Evidence => "evidence",
59            Self::Fact => "fact",
60            Self::Hypothesis => "hypothesis",
61            Self::Entity => "entity",
62            Self::Relation => "relation",
63        };
64        formatter.write_str(name)
65    }
66}
67
68/// Errors raised when a knowledge record violates storage or identity invariants.
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum KnowledgeBaseError {
72    /// A snapshot or commit token was minted by another knowledge-base instance.
73    SnapshotAuthorityMismatch {
74        /// Subject whose snapshot authority did not match this knowledge base.
75        subject: EntityId,
76    },
77
78    /// The identity exists, but its immutable claim or graph identity differs.
79    IdentityConflict {
80        /// Category of the conflicting record.
81        kind: KnowledgeRecordKind,
82        /// Reused stable identifier.
83        id: String,
84    },
85
86    /// An atomic relation bundle did not reference exactly its supplied evidence.
87    RelationEvidenceMismatch {
88        /// Relation whose provenance was inconsistent.
89        relation_id: String,
90        /// Evidence expected to be the relation's sole provenance record.
91        evidence_id: String,
92    },
93
94    /// An atomic relation did not originate at its evidence subject.
95    RelationSubjectMismatch {
96        /// Relation whose source entity was inconsistent.
97        relation_id: String,
98        /// Subject described by the evidence.
99        evidence_subject: String,
100        /// Source entity declared by the relation.
101        relation_from: String,
102    },
103
104    /// A relation field or provenance collection exceeded its storage ceiling.
105    RelationLimitExceeded {
106        /// Stable field name (`id`, `from`, `to`, `kind`, `evidence_ids`, or
107        /// `evidence_id`).
108        field: &'static str,
109        /// Rejected byte or item count.
110        actual: usize,
111        /// Inclusive compiled ceiling.
112        maximum: usize,
113    },
114
115    /// A reasoning batch was evaluated against knowledge that has since changed.
116    StaleSnapshot {
117        /// Subject captured by the stale snapshot.
118        subject: EntityId,
119        /// Subject revision captured by the snapshot.
120        expected_subject_revision: u64,
121        /// Current subject revision.
122        actual_subject_revision: u64,
123        /// Ontology revision captured by the snapshot.
124        expected_ontology_revision: u64,
125        /// Current ontology revision.
126        actual_ontology_revision: u64,
127    },
128
129    /// A reasoning batch contained a conclusion for another subject.
130    ReasoningSubjectMismatch {
131        /// Hypothesis whose subject violated the batch boundary.
132        hypothesis_id: String,
133        /// Subject captured by the reasoning snapshot.
134        expected: EntityId,
135        /// Subject declared by the hypothesis.
136        actual: EntityId,
137    },
138
139    /// A derived evidence record referenced a parent that neither already
140    /// exists nor appears in the same atomic batch.
141    MissingDerivationParent {
142        /// Derived child evidence ID.
143        child: String,
144        /// Referenced parent evidence ID that could not be resolved.
145        parent: String,
146    },
147
148    /// A derived evidence record referenced itself as a parent.
149    SelfDerivation {
150        /// Evidence ID that referenced itself.
151        evidence_id: String,
152    },
153
154    /// A derived evidence record referenced a parent recorded for a different
155    /// subject.
156    DerivationSubjectMismatch {
157        /// Derived child evidence ID.
158        child: String,
159        /// Referenced parent evidence ID.
160        parent: String,
161    },
162
163    /// The derivation edges in one atomic batch formed a cycle.
164    DerivationCycle {
165        /// One evidence ID participating in the detected cycle.
166        evidence_id: String,
167    },
168}
169
170impl fmt::Display for KnowledgeBaseError {
171    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
172        match self {
173            Self::SnapshotAuthorityMismatch { subject } => write!(
174                formatter,
175                "knowledge snapshot for {subject} belongs to a different knowledge base"
176            ),
177            Self::IdentityConflict { kind, id } => {
178                write!(
179                    formatter,
180                    "{kind} identity {id} already has different meaning"
181                )
182            },
183            Self::RelationEvidenceMismatch {
184                relation_id,
185                evidence_id,
186            } => write!(
187                formatter,
188                "relation {relation_id} must be backed only by evidence {evidence_id}"
189            ),
190            Self::RelationSubjectMismatch {
191                relation_id,
192                evidence_subject,
193                relation_from,
194            } => write!(
195                formatter,
196                "relation {relation_id} starts at {relation_from}, not evidence subject {evidence_subject}"
197            ),
198            Self::RelationLimitExceeded {
199                field,
200                actual,
201                maximum,
202            } => write!(
203                formatter,
204                "knowledge relation {field} size {actual} exceeds hard ceiling {maximum}"
205            ),
206            Self::StaleSnapshot {
207                subject,
208                expected_subject_revision,
209                actual_subject_revision,
210                expected_ontology_revision,
211                actual_ontology_revision,
212            } => write!(
213                formatter,
214                "knowledge snapshot for {subject} is stale (subject revision {expected_subject_revision}->{actual_subject_revision}, ontology revision {expected_ontology_revision}->{actual_ontology_revision})"
215            ),
216            Self::ReasoningSubjectMismatch {
217                hypothesis_id,
218                expected,
219                actual,
220            } => write!(
221                formatter,
222                "reasoning hypothesis {hypothesis_id} belongs to {actual}, expected snapshot subject {expected}"
223            ),
224            Self::MissingDerivationParent { child, parent } => write!(
225                formatter,
226                "derived evidence {child} references parent {parent} that is neither stored nor in the same batch"
227            ),
228            Self::SelfDerivation { evidence_id } => write!(
229                formatter,
230                "derived evidence {evidence_id} references itself as a parent"
231            ),
232            Self::DerivationSubjectMismatch { child, parent } => write!(
233                formatter,
234                "derived evidence {child} references parent {parent} recorded for a different subject"
235            ),
236            Self::DerivationCycle { evidence_id } => write!(
237                formatter,
238                "derivation lineage forms a cycle through evidence {evidence_id}"
239            ),
240        }
241    }
242}
243
244impl std::error::Error for KnowledgeBaseError {}
245
246/// Counts of records currently held by a [`KnowledgeBase`].
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
248#[non_exhaustive]
249pub struct KnowledgeBaseStats {
250    /// Number of immutable observations.
251    pub evidence: usize,
252    /// Number of materialized facts.
253    pub facts: usize,
254    /// Number of evaluated hypotheses.
255    pub hypotheses: usize,
256    /// Number of knowledge-graph entities.
257    pub entities: usize,
258    /// Number of evidence-backed graph relations.
259    pub relations: usize,
260    /// Counts for ontology concepts, relation types, and axioms.
261    pub ontology: OntologyStats,
262}
263
264impl KnowledgeBase {
265    /// Inserts one immutable observation.
266    ///
267    /// Repeating the exact record is idempotent. Reusing an evidence ID for a
268    /// different observation is rejected because provenance IDs are immutable.
269    pub fn insert_evidence(
270        &self,
271        evidence: Evidence,
272    ) -> Result<KnowledgeWrite, KnowledgeBaseError> {
273        let id = evidence.id().clone();
274        let subject = evidence.subject().clone();
275        let predicate = evidence.predicate().clone();
276        let mut state = self.write_state();
277
278        if let Some(existing) = state.evidence.get(&id) {
279            return if existing == &evidence {
280                Ok(KnowledgeWrite::Unchanged)
281            } else {
282                Err(identity_conflict(KnowledgeRecordKind::Evidence, &id))
283            };
284        }
285
286        if evidence.origin().derivation().is_some() {
287            let mut pending = HashMap::with_capacity(1);
288            pending.insert(id.clone(), evidence.clone());
289            validate_batch_derivations(&state, &pending)?;
290        }
291
292        index_derivation(&mut state, &evidence);
293        state.evidence.insert(id.clone(), evidence);
294        bump_subject_revision(&mut state, &subject);
295        index(&mut state.evidence_by_subject, subject, id.clone());
296        index(&mut state.evidence_by_predicate, predicate, id);
297        Ok(KnowledgeWrite::Inserted)
298    }
299
300    /// Inserts an evidence batch in one write transaction.
301    ///
302    /// Every identity is validated before the first record is written. If an
303    /// existing record or another item in the batch reuses an evidence ID for
304    /// different meaning, the complete batch is rejected without changing the
305    /// knowledge base. Results preserve input order; exact duplicates are
306    /// idempotent.
307    pub fn insert_evidence_batch(
308        &self,
309        evidence: Vec<Evidence>,
310    ) -> Result<Vec<KnowledgeWrite>, KnowledgeBaseError> {
311        let mut state = self.write_state();
312        let mut pending = HashMap::<EvidenceId, Evidence>::new();
313
314        for observation in &evidence {
315            let id = observation.id();
316            if state
317                .evidence
318                .get(id)
319                .is_some_and(|existing| existing != observation)
320                || pending
321                    .get(id)
322                    .is_some_and(|existing| existing != observation)
323            {
324                return Err(identity_conflict(KnowledgeRecordKind::Evidence, id));
325            }
326            pending
327                .entry(id.clone())
328                .or_insert_with(|| observation.clone());
329        }
330
331        // Lineage is validated across the whole batch before any write, so a
332        // missing parent, self-reference, cross-subject parent, or cycle rejects
333        // the complete batch without leaving an orphaned child or index entry.
334        validate_batch_derivations(&state, &pending)?;
335
336        let mut writes = Vec::with_capacity(evidence.len());
337        for observation in evidence {
338            let id = observation.id().clone();
339            if state.evidence.contains_key(&id) {
340                writes.push(KnowledgeWrite::Unchanged);
341                continue;
342            }
343
344            let subject = observation.subject().clone();
345            let predicate = observation.predicate().clone();
346            index_derivation(&mut state, &observation);
347            state.evidence.insert(id.clone(), observation);
348            bump_subject_revision(&mut state, &subject);
349            index(&mut state.evidence_by_subject, subject, id.clone());
350            index(&mut state.evidence_by_predicate, predicate, id);
351            writes.push(KnowledgeWrite::Inserted);
352        }
353        Ok(writes)
354    }
355
356    /// Inserts a materialized fact or updates its confidence and provenance.
357    ///
358    /// The subject, predicate, and value form the immutable claim identity for
359    /// an existing fact ID.
360    pub fn upsert_fact(&self, fact: Fact) -> Result<KnowledgeWrite, KnowledgeBaseError> {
361        let id = fact.id().to_owned();
362        let subject = fact.subject().clone();
363        let predicate = fact.predicate().clone();
364        let mut state = self.write_state();
365
366        if let Some(existing) = state.facts.get(&id) {
367            if existing == &fact {
368                return Ok(KnowledgeWrite::Unchanged);
369            }
370            if existing.subject() != fact.subject()
371                || existing.predicate() != fact.predicate()
372                || existing.value() != fact.value()
373            {
374                return Err(identity_conflict(KnowledgeRecordKind::Fact, &id));
375            }
376            state.facts.insert(id, fact);
377            bump_subject_revision(&mut state, &subject);
378            return Ok(KnowledgeWrite::Updated);
379        }
380
381        state.facts.insert(id.clone(), fact);
382        bump_subject_revision(&mut state, &subject);
383        index(&mut state.facts_by_subject, subject, id.clone());
384        index(&mut state.facts_by_predicate, predicate, id);
385        Ok(KnowledgeWrite::Inserted)
386    }
387
388    /// Inserts a hypothesis or updates its Bayesian evaluation.
389    ///
390    /// The subject, predicate, and value form the immutable claim identity for
391    /// an existing hypothesis ID.
392    pub fn upsert_hypothesis(
393        &self,
394        hypothesis: Hypothesis,
395    ) -> Result<KnowledgeWrite, KnowledgeBaseError> {
396        self.upsert_hypothesis_batch(vec![hypothesis])
397            .map(|writes| writes[0])
398    }
399
400    /// Inserts or updates a hypothesis batch in one write transaction.
401    ///
402    /// Every stored and intra-batch identity is validated before the first
403    /// hypothesis or secondary index changes. Reusing an ID for a different
404    /// claim, or supplying different evaluations for one ID in the same batch,
405    /// rejects the complete batch. Results preserve input order; semantically
406    /// exact duplicates are idempotent even when their update timestamps differ.
407    pub fn upsert_hypothesis_batch(
408        &self,
409        hypotheses: Vec<Hypothesis>,
410    ) -> Result<Vec<KnowledgeWrite>, KnowledgeBaseError> {
411        self.upsert_hypothesis_batch_with_policy(hypotheses, false, None)
412    }
413
414    /// Atomically writes rule-produced hypotheses from a current snapshot.
415    ///
416    /// Snapshot validation, terminal state lookup, and every resulting write
417    /// happen under the same knowledge-base write lock. A concurrent rule-visible
418    /// write rejects the complete batch, including an empty unmatched batch.
419    pub(crate) fn upsert_reasoning_hypothesis_batch(
420        &self,
421        snapshot: &KnowledgeSnapshot,
422        hypotheses: Vec<Hypothesis>,
423    ) -> Result<Vec<KnowledgeWrite>, KnowledgeBaseError> {
424        if let Some(hypothesis) = hypotheses
425            .iter()
426            .find(|hypothesis| hypothesis.subject() != snapshot.subject())
427        {
428            return Err(KnowledgeBaseError::ReasoningSubjectMismatch {
429                hypothesis_id: hypothesis.id().to_owned(),
430                expected: snapshot.subject().clone(),
431                actual: hypothesis.subject().clone(),
432            });
433        }
434        self.upsert_hypothesis_batch_with_policy(hypotheses, true, Some(snapshot))
435    }
436
437    fn upsert_hypothesis_batch_with_policy(
438        &self,
439        hypotheses: Vec<Hypothesis>,
440        preserve_terminal_state: bool,
441        expected_snapshot: Option<&KnowledgeSnapshot>,
442    ) -> Result<Vec<KnowledgeWrite>, KnowledgeBaseError> {
443        if let Some(snapshot) = expected_snapshot {
444            self.validate_snapshot_authority(snapshot.authority(), snapshot.subject())?;
445        }
446        let mut state = self.write_state();
447        if let Some(snapshot) = expected_snapshot {
448            let actual_subject_revision = subject_revision(&state, snapshot.subject());
449            if actual_subject_revision != snapshot.subject_revision()
450                || state.ontology_revision != snapshot.ontology_revision()
451            {
452                return Err(KnowledgeBaseError::StaleSnapshot {
453                    subject: snapshot.subject().clone(),
454                    expected_subject_revision: snapshot.subject_revision(),
455                    actual_subject_revision,
456                    expected_ontology_revision: snapshot.ontology_revision(),
457                    actual_ontology_revision: state.ontology_revision,
458                });
459            }
460        }
461        let mut pending = HashMap::<String, Hypothesis>::new();
462
463        for hypothesis in &hypotheses {
464            let id = hypothesis.id();
465            if state
466                .hypotheses
467                .get(id)
468                .is_some_and(|existing| !same_hypothesis_claim(existing, hypothesis))
469                || pending
470                    .get(id)
471                    .is_some_and(|existing| !existing.same_evaluation_as(hypothesis))
472            {
473                return Err(identity_conflict(KnowledgeRecordKind::Hypothesis, &id));
474            }
475            pending
476                .entry(id.to_owned())
477                .or_insert_with(|| hypothesis.clone());
478        }
479
480        let mut writes = Vec::with_capacity(hypotheses.len());
481        for mut hypothesis in hypotheses {
482            let id = hypothesis.id().to_owned();
483            if preserve_terminal_state {
484                let terminal_state = state.hypotheses.get(&id).and_then(|existing| {
485                    matches!(
486                        existing.state(),
487                        HypothesisState::Confirmed | HypothesisState::Rejected
488                    )
489                    .then_some(existing.state())
490                });
491                if let Some(terminal_state) = terminal_state {
492                    hypothesis.set_state(terminal_state);
493                }
494            }
495
496            if let Some(existing) = state.hypotheses.get(&id) {
497                if existing.same_evaluation_as(&hypothesis) {
498                    writes.push(KnowledgeWrite::Unchanged);
499                } else {
500                    let subject = hypothesis.subject().clone();
501                    state.hypotheses.insert(id, hypothesis);
502                    bump_subject_revision(&mut state, &subject);
503                    writes.push(KnowledgeWrite::Updated);
504                }
505                continue;
506            }
507
508            let subject = hypothesis.subject().clone();
509            let predicate = hypothesis.predicate().clone();
510            state.hypotheses.insert(id.clone(), hypothesis);
511            bump_subject_revision(&mut state, &subject);
512            index(&mut state.hypotheses_by_subject, subject, id.clone());
513            index(&mut state.hypotheses_by_predicate, predicate, id);
514            writes.push(KnowledgeWrite::Inserted);
515        }
516        Ok(writes)
517    }
518
519    /// Changes only the lifecycle state of the latest stored hypothesis.
520    ///
521    /// The update is performed in place under the knowledge-base write lock, so
522    /// verifier state transitions cannot overwrite a concurrent recalibration's
523    /// belief trail or strength with a stale cloned record.
524    pub(crate) fn transition_hypothesis_state(
525        &self,
526        hypothesis_id: &str,
527        expected_subject: &EntityId,
528        new_state: HypothesisState,
529        expected_revisions: Option<(u64, u64)>,
530    ) -> HypothesisStateTransition {
531        let mut state = self.write_state();
532        let Some(hypothesis) = state.hypotheses.get(hypothesis_id) else {
533            return HypothesisStateTransition::Missing;
534        };
535        if hypothesis.subject() != expected_subject {
536            return HypothesisStateTransition::SubjectMismatch {
537                actual: hypothesis.subject().clone(),
538            };
539        }
540        if hypothesis.state() == new_state {
541            return HypothesisStateTransition::Written(KnowledgeWrite::Unchanged);
542        }
543        if is_terminal_hypothesis_state(hypothesis.state())
544            && is_terminal_hypothesis_state(new_state)
545        {
546            return HypothesisStateTransition::TerminalConflict {
547                current: hypothesis.state(),
548                attempted: new_state,
549            };
550        }
551        if let Some((expected_subject_revision, expected_ontology_revision)) = expected_revisions {
552            let actual_subject_revision = subject_revision(&state, expected_subject);
553            if actual_subject_revision != expected_subject_revision
554                || state.ontology_revision != expected_ontology_revision
555            {
556                return HypothesisStateTransition::StaleSnapshot(
557                    KnowledgeBaseError::StaleSnapshot {
558                        subject: expected_subject.clone(),
559                        expected_subject_revision,
560                        actual_subject_revision,
561                        expected_ontology_revision,
562                        actual_ontology_revision: state.ontology_revision,
563                    },
564                );
565            }
566        }
567
568        let hypothesis = state
569            .hypotheses
570            .get_mut(hypothesis_id)
571            .expect("validated hypothesis remains present under the write lock");
572        hypothesis.set_state(new_state);
573        bump_subject_revision(&mut state, expected_subject);
574        HypothesisStateTransition::Written(KnowledgeWrite::Updated)
575    }
576}
577
578pub(super) fn identity_conflict(
579    kind: KnowledgeRecordKind,
580    id: &impl fmt::Display,
581) -> KnowledgeBaseError {
582    KnowledgeBaseError::IdentityConflict {
583        kind,
584        id: id.to_string(),
585    }
586}
587
588/// Validates derivation lineage for one atomic batch before any record is
589/// written. `pending` holds every distinct record in the batch keyed by ID; a
590/// parent reference may resolve to `pending` (same batch) or to the committed
591/// store. Structural validity (non-empty, de-duplicated, bounded parents) is
592/// already guaranteed by [`termivar_core::EvidenceDerivation`]; the checks that
593/// require store context are enforced here: self-reference, parent existence,
594/// subject agreement, and cycle freedom. Any violation returns before the write
595/// phase, so the batch is rejected without mutating the knowledge base.
596fn validate_batch_derivations(
597    state: &KnowledgeState,
598    pending: &HashMap<EvidenceId, Evidence>,
599) -> Result<(), KnowledgeBaseError> {
600    for (child_id, child) in pending {
601        let Some(derivation) = child.origin().derivation() else {
602            continue;
603        };
604        for parent in derivation.parents() {
605            if parent == child_id {
606                return Err(KnowledgeBaseError::SelfDerivation {
607                    evidence_id: child_id.to_string(),
608                });
609            }
610            let parent_subject = pending
611                .get(parent)
612                .map(Evidence::subject)
613                .or_else(|| state.evidence.get(parent).map(Evidence::subject));
614            let Some(parent_subject) = parent_subject else {
615                return Err(KnowledgeBaseError::MissingDerivationParent {
616                    child: child_id.to_string(),
617                    parent: parent.to_string(),
618                });
619            };
620            if parent_subject != child.subject() {
621                return Err(KnowledgeBaseError::DerivationSubjectMismatch {
622                    child: child_id.to_string(),
623                    parent: parent.to_string(),
624                });
625            }
626        }
627    }
628    detect_batch_derivation_cycles(pending)
629}
630
631/// Iterative three-color DFS over batch-local derivation edges. Committed store
632/// records are terminals: the store is an immutable DAG whose records precede
633/// every batch record, so a new cycle can only form among records in this
634/// batch. Traversal is explicit-stack (never recursive) and bounded by the
635/// batch size times the per-record parent bound.
636fn detect_batch_derivation_cycles(
637    pending: &HashMap<EvidenceId, Evidence>,
638) -> Result<(), KnowledgeBaseError> {
639    enum Color {
640        White,
641        Gray,
642        Black,
643    }
644    let adjacency: HashMap<EvidenceId, Vec<EvidenceId>> = pending
645        .iter()
646        .map(|(id, evidence)| {
647            let parents = evidence
648                .origin()
649                .derivation()
650                .map(|derivation| {
651                    derivation
652                        .parents()
653                        .iter()
654                        .filter(|parent| pending.contains_key(*parent))
655                        .cloned()
656                        .collect::<Vec<_>>()
657                })
658                .unwrap_or_default();
659            (id.clone(), parents)
660        })
661        .collect();
662    let mut color: HashMap<EvidenceId, Color> = pending
663        .keys()
664        .map(|id| (id.clone(), Color::White))
665        .collect();
666    for start in pending.keys() {
667        if !matches!(color.get(start), Some(Color::White)) {
668            continue;
669        }
670        color.insert(start.clone(), Color::Gray);
671        let mut stack: Vec<(EvidenceId, usize)> = vec![(start.clone(), 0)];
672        while let Some((node, index)) = stack.last().cloned() {
673            let neighbors = &adjacency[&node];
674            if index < neighbors.len() {
675                stack.last_mut().unwrap().1 = index + 1;
676                let next = neighbors[index].clone();
677                match color.get(&next) {
678                    Some(Color::Gray) => {
679                        return Err(KnowledgeBaseError::DerivationCycle {
680                            evidence_id: next.to_string(),
681                        });
682                    },
683                    Some(Color::White) => {
684                        color.insert(next.clone(), Color::Gray);
685                        stack.push((next, 0));
686                    },
687                    _ => {},
688                }
689            } else {
690                color.insert(node, Color::Black);
691                stack.pop();
692            }
693        }
694    }
695    Ok(())
696}
697
698/// Records the reverse derivation edges for one newly inserted derived record.
699fn index_derivation(state: &mut KnowledgeState, evidence: &Evidence) {
700    if let Some(derivation) = evidence.origin().derivation() {
701        let child = evidence.id().clone();
702        for parent in derivation.parents() {
703            state
704                .derivation_children
705                .entry(parent.clone())
706                .or_default()
707                .insert(child.clone());
708        }
709    }
710}
711
712pub(super) fn subject_revision(state: &KnowledgeState, subject: &EntityId) -> u64 {
713    state.subject_revisions.get(subject).copied().unwrap_or(0)
714}
715
716pub(super) fn validate_revisions(
717    state: &KnowledgeState,
718    subject: &EntityId,
719    expected_subject_revision: u64,
720    expected_ontology_revision: u64,
721) -> Result<(), KnowledgeBaseError> {
722    let actual_subject_revision = subject_revision(state, subject);
723    if actual_subject_revision != expected_subject_revision
724        || state.ontology_revision != expected_ontology_revision
725    {
726        return Err(KnowledgeBaseError::StaleSnapshot {
727            subject: subject.clone(),
728            expected_subject_revision,
729            actual_subject_revision,
730            expected_ontology_revision,
731            actual_ontology_revision: state.ontology_revision,
732        });
733    }
734    Ok(())
735}
736
737pub(super) fn bump_subject_revision(state: &mut KnowledgeState, subject: &EntityId) {
738    let revision = state.subject_revisions.entry(subject.clone()).or_default();
739    *revision = revision
740        .checked_add(1)
741        .expect("subject knowledge revision must not overflow");
742}
743
744pub(super) fn bump_ontology_revision(state: &mut KnowledgeState) {
745    state.ontology_revision = state
746        .ontology_revision
747        .checked_add(1)
748        .expect("ontology knowledge revision must not overflow");
749}
750
751fn same_hypothesis_claim(left: &Hypothesis, right: &Hypothesis) -> bool {
752    left.subject() == right.subject()
753        && left.predicate() == right.predicate()
754        && left.value() == right.value()
755}
756
757fn is_terminal_hypothesis_state(state: HypothesisState) -> bool {
758    matches!(
759        state,
760        HypothesisState::Confirmed | HypothesisState::Rejected
761    )
762}