Skip to main content

canwu_knowledge/
lib.rs

1//! Actor-relative information kept separate from world ground truth.
2
3use canwu_core::{
4    ArmyId, BoundaryId, DomainRecordRef, EventId, EvidenceRef, HolderKnowledgeRecordId,
5    KnowledgeHolderRef, KnowledgeRecordId, KnowledgeSchemaId, PersonId, TerritoryId,
6};
7use canwu_time::SimTime;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::{BTreeMap, BTreeSet};
11
12pub const DEFAULT_KNOWLEDGE_PAGE_SIZE: u32 = 100;
13pub const MAX_KNOWLEDGE_PAGE_SIZE: u32 = 1_000;
14
15#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum KnowledgeSource {
18    DirectObservation,
19    CommandResponsibility,
20    Report { source_event: EventId },
21    ScenarioRecord,
22}
23
24#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
25pub struct EstimateRange {
26    pub minimum: u32,
27    pub maximum: u32,
28}
29
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31pub struct ArmyKnowledge {
32    pub army: ArmyId,
33    pub known_name: Option<String>,
34    pub known_location: Option<TerritoryId>,
35    pub estimated_strength: EstimateRange,
36    pub observed_at: SimTime,
37    pub learned_at: SimTime,
38    pub confidence_per_mille: u16,
39    pub source: KnowledgeSource,
40}
41
42#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
43pub struct ActorKnowledge {
44    pub actor: PersonId,
45    pub armies: BTreeMap<ArmyId, ArmyKnowledge>,
46}
47
48#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
49#[serde(tag = "type", content = "value", rename_all = "snake_case")]
50pub enum KnowledgeSubjectTarget {
51    Entity(canwu_core::EntityRef),
52    DomainRecord(DomainRecordRef),
53    Event(EventId),
54}
55
56#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
57pub struct KnowledgeSubject {
58    pub role: String,
59    pub target: KnowledgeSubjectTarget,
60}
61
62/// Compatibility name for knowledge-origin evidence. The persisted type is
63/// shared by every evidence-producing subsystem in `canwu-core`.
64pub type KnowledgeEvidenceRef = EvidenceRef;
65
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67pub struct KnowledgeOrigin {
68    pub method: String,
69    pub evidence: Vec<EvidenceRef>,
70}
71
72#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
73pub struct KnowledgeRecordDraft {
74    pub schema: KnowledgeSchemaId,
75    pub subjects: Vec<KnowledgeSubject>,
76    pub payload: Value,
77    pub as_of: Option<SimTime>,
78    pub confidence_per_mille: u16,
79    pub origin: KnowledgeOrigin,
80    pub supersedes: Vec<KnowledgeRecordId>,
81    pub contradicts: Vec<KnowledgeRecordId>,
82}
83
84#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
85pub struct KnowledgeRecord {
86    pub id: KnowledgeRecordId,
87    pub holder: KnowledgeHolderRef,
88    pub schema: KnowledgeSchemaId,
89    pub subjects: Vec<KnowledgeSubject>,
90    pub payload: Value,
91    pub as_of: Option<SimTime>,
92    pub learned_at: SimTime,
93    pub confidence_per_mille: u16,
94    pub origin: KnowledgeOrigin,
95    pub supersedes: Vec<KnowledgeRecordId>,
96    pub contradicts: Vec<KnowledgeRecordId>,
97}
98
99#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
100pub struct KnowledgeRecordView {
101    pub id: HolderKnowledgeRecordId,
102    pub holder: KnowledgeHolderRef,
103    pub schema: KnowledgeSchemaId,
104    pub subjects: Vec<KnowledgeSubject>,
105    pub payload: Value,
106    pub as_of: Option<SimTime>,
107    pub learned_at: SimTime,
108    pub confidence_per_mille: u16,
109    pub supersedes: Vec<HolderKnowledgeRecordId>,
110    pub contradicts: Vec<HolderKnowledgeRecordId>,
111}
112
113#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
114#[serde(rename_all = "snake_case")]
115pub enum KnowledgeHistoryView {
116    CurrentHeads,
117    FullHistory,
118}
119
120#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
121pub struct KnowledgeReadCut {
122    pub boundary: Option<BoundaryId>,
123    pub holder_projection_root: String,
124    pub holder_overlay_root: Option<String>,
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128pub struct KnowledgeCursor {
129    pub holder: KnowledgeHolderRef,
130    pub query_hash: String,
131    pub read_cut: KnowledgeReadCut,
132    pub binding_hash: String,
133    pub learned_at: SimTime,
134    pub record: HolderKnowledgeRecordId,
135}
136
137#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138pub struct KnowledgeQuery {
139    #[serde(default)]
140    pub schemas: Vec<KnowledgeSchemaId>,
141    #[serde(default)]
142    pub subjects: Vec<KnowledgeSubject>,
143    pub learned_after: Option<SimTime>,
144    pub learned_at_or_before: Option<SimTime>,
145    pub view: KnowledgeHistoryView,
146    pub after: Option<KnowledgeCursor>,
147    pub limit: u32,
148}
149
150impl Default for KnowledgeQuery {
151    fn default() -> Self {
152        Self {
153            schemas: Vec::new(),
154            subjects: Vec::new(),
155            learned_after: None,
156            learned_at_or_before: None,
157            view: KnowledgeHistoryView::CurrentHeads,
158            after: None,
159            limit: DEFAULT_KNOWLEDGE_PAGE_SIZE,
160        }
161    }
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165pub struct KnowledgeQueryResult {
166    pub holder: KnowledgeHolderRef,
167    pub read_cut: KnowledgeReadCut,
168    pub records: Vec<KnowledgeRecordView>,
169    pub next: Option<KnowledgeCursor>,
170}
171
172#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
173/// Holder-relative generic knowledge ledger used by the current snapshot.
174pub struct GenericKnowledgeLedger {
175    pub records: BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
176}
177
178/// Atomic append validation failures for the standalone generic ledger.
179#[derive(Clone, Copy, Debug, Eq, PartialEq)]
180pub enum KnowledgeLedgerError {
181    HolderMismatch,
182    DuplicateRecordId,
183}
184
185impl GenericKnowledgeLedger {
186    #[must_use]
187    pub fn for_holder(
188        &self,
189        holder: &KnowledgeHolderRef,
190    ) -> Option<&BTreeMap<KnowledgeRecordId, KnowledgeRecord>> {
191        self.records.get(holder)
192    }
193
194    /// Atomically appends records after validating holder and global ID invariants.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`KnowledgeLedgerError::HolderMismatch`] when any record names a
199    /// different holder, or [`KnowledgeLedgerError::DuplicateRecordId`] when an
200    /// ID already exists anywhere in the ledger or repeats within the batch.
201    pub fn insert_records(
202        &mut self,
203        holder: KnowledgeHolderRef,
204        records: impl IntoIterator<Item = KnowledgeRecord>,
205    ) -> Result<(), KnowledgeLedgerError> {
206        let records = records.into_iter().collect::<Vec<_>>();
207        let mut incoming_ids = BTreeSet::new();
208        for record in &records {
209            if record.holder != holder {
210                return Err(KnowledgeLedgerError::HolderMismatch);
211            }
212            if !incoming_ids.insert(record.id)
213                || self
214                    .records
215                    .values()
216                    .any(|existing| existing.contains_key(&record.id))
217            {
218                return Err(KnowledgeLedgerError::DuplicateRecordId);
219            }
220        }
221        let entry = self.records.entry(holder).or_default();
222        for record in records {
223            entry.insert(record.id, record);
224        }
225        Ok(())
226    }
227
228    /// Returns one deterministic page from a holder's ledger at the supplied cut.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`KnowledgeQueryError::InvalidLimit`] for a zero or oversized
233    /// page and [`KnowledgeQueryError::InvalidCursor`] when a cursor belongs to
234    /// another holder, query, or ledger position. It returns
235    /// [`KnowledgeQueryError::ReadCutUnavailable`] when the cursor's committed
236    /// knowledge or overlay root is no longer the supplied view. It returns
237    /// [`KnowledgeQueryError::InvalidLedger`] for internally inconsistent stored
238    /// records and [`KnowledgeQueryError::Encoding`] if query hashing fails.
239    pub fn query(
240        &self,
241        holder: KnowledgeHolderRef,
242        query: &KnowledgeQuery,
243        read_cut: KnowledgeReadCut,
244    ) -> Result<KnowledgeQueryResult, KnowledgeQueryError> {
245        query_records(&self.records, holder, query, read_cut)
246    }
247}
248
249fn query_records(
250    ledger: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
251    holder: KnowledgeHolderRef,
252    query: &KnowledgeQuery,
253    read_cut: KnowledgeReadCut,
254) -> Result<KnowledgeQueryResult, KnowledgeQueryError> {
255    if query.limit == 0 || query.limit > MAX_KNOWLEDGE_PAGE_SIZE {
256        return Err(KnowledgeQueryError::InvalidLimit);
257    }
258    let query_hash = query_hash(query)?;
259    let binding_hash = cursor_binding_hash(&holder, &query_hash, &read_cut)?;
260    if let Some(cursor) = &query.after {
261        validate_cursor(cursor, &holder, &query_hash, &read_cut)?;
262    }
263
264    let Some(records) = ledger.get(&holder) else {
265        if query.after.is_some() {
266            return Err(KnowledgeQueryError::InvalidCursor);
267        }
268        return Ok(KnowledgeQueryResult {
269            holder,
270            read_cut,
271            records: Vec::new(),
272            next: None,
273        });
274    };
275    if records.iter().any(|(id, record)| {
276        *id != record.id
277            || record.id.get() == 0
278            || record.holder != holder
279            || record
280                .supersedes
281                .iter()
282                .chain(&record.contradicts)
283                .any(|related| !records.contains_key(related))
284    }) {
285        return Err(KnowledgeQueryError::InvalidLedger);
286    }
287    let local_ids = holder_local_ids(records);
288    if let Some(cursor) = &query.after
289        && !records.iter().any(|(id, record)| {
290            local_ids[id] == cursor.record && record.learned_at == cursor.learned_at
291        })
292    {
293        return Err(KnowledgeQueryError::InvalidCursor);
294    }
295    let current = if query.view == KnowledgeHistoryView::CurrentHeads {
296        current_heads(records)
297    } else {
298        records.keys().copied().collect()
299    };
300    let mut candidates = records
301        .iter()
302        .filter(|(id, record)| {
303            current.contains(id)
304                && (query.schemas.is_empty() || query.schemas.contains(&record.schema))
305                && query
306                    .subjects
307                    .iter()
308                    .all(|subject| record.subjects.contains(subject))
309                && query
310                    .learned_after
311                    .is_none_or(|time| record.learned_at > time)
312                && query
313                    .learned_at_or_before
314                    .is_none_or(|time| record.learned_at <= time)
315        })
316        .map(|(id, record)| (*id, record))
317        .collect::<Vec<_>>();
318    candidates.sort_by_key(|(_, record)| (record.learned_at, record.id));
319
320    if let Some(cursor) = &query.after {
321        candidates.retain(|(id, record)| {
322            (record.learned_at, local_ids[id]) > (cursor.learned_at, cursor.record)
323        });
324    }
325
326    let limit = usize::try_from(query.limit).unwrap_or(MAX_KNOWLEDGE_PAGE_SIZE as usize);
327    let has_more = candidates.len() > limit;
328    let page = candidates.into_iter().take(limit).collect::<Vec<_>>();
329    let next = if has_more {
330        page.last().map(|(_, record)| KnowledgeCursor {
331            holder: holder.clone(),
332            query_hash: query_hash.clone(),
333            read_cut: read_cut.clone(),
334            binding_hash: binding_hash.clone(),
335            learned_at: record.learned_at,
336            record: local_ids[&record.id],
337        })
338    } else {
339        None
340    };
341    let views = page
342        .iter()
343        .map(|(id, record)| to_view(record, local_ids[id], &local_ids))
344        .collect();
345    Ok(KnowledgeQueryResult {
346        holder,
347        read_cut,
348        records: views,
349        next,
350    })
351}
352
353#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
354pub struct KnowledgeSnapshot {
355    pub actors: BTreeMap<PersonId, ActorKnowledge>,
356    #[serde(
357        default,
358        skip_serializing_if = "BTreeMap::is_empty",
359        with = "holder_records_wire"
360    )]
361    pub records: BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
362}
363
364mod holder_records_wire {
365    use super::{BTreeMap, BTreeSet, KnowledgeHolderRef, KnowledgeRecord, KnowledgeRecordId};
366    use serde::{Deserialize, Deserializer, Serialize, Serializer};
367
368    #[derive(Deserialize, Serialize)]
369    struct HolderLedgerEntry {
370        holder: KnowledgeHolderRef,
371        records: Vec<KnowledgeRecord>,
372    }
373
374    pub fn serialize<S>(
375        value: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
376        serializer: S,
377    ) -> Result<S::Ok, S::Error>
378    where
379        S: Serializer,
380    {
381        let mut global_ids = BTreeSet::new();
382        let mut entries = Vec::with_capacity(value.len());
383        for (holder, records) in value {
384            let mut ordered = Vec::with_capacity(records.len());
385            for (id, record) in records {
386                if id != &record.id || &record.holder != holder || !global_ids.insert(*id) {
387                    return Err(serde::ser::Error::custom(
388                        "knowledge snapshot contains inconsistent holders or record IDs",
389                    ));
390                }
391                ordered.push(record.clone());
392            }
393            entries.push(HolderLedgerEntry {
394                holder: holder.clone(),
395                records: ordered,
396            });
397        }
398        entries.serialize(serializer)
399    }
400
401    pub fn deserialize<'de, D>(
402        deserializer: D,
403    ) -> Result<BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>, D::Error>
404    where
405        D: Deserializer<'de>,
406    {
407        let entries = Vec::<HolderLedgerEntry>::deserialize(deserializer)?;
408        let mut ledger = BTreeMap::new();
409        let mut global_ids = BTreeSet::new();
410        for entry in entries {
411            let holder = entry.holder;
412            let mut records = BTreeMap::new();
413            for record in entry.records {
414                if record.holder != holder
415                    || !global_ids.insert(record.id)
416                    || records.insert(record.id, record).is_some()
417                {
418                    return Err(serde::de::Error::custom(
419                        "holder ledger contains a mismatched holder or duplicate record ID",
420                    ));
421                }
422            }
423            if ledger.insert(holder, records).is_some() {
424                return Err(serde::de::Error::custom(
425                    "knowledge snapshot contains a duplicate holder ledger",
426                ));
427            }
428        }
429        Ok(ledger)
430    }
431}
432
433impl KnowledgeSnapshot {
434    /// Counts all retained holder-relative records in one schema namespace.
435    #[must_use]
436    pub fn record_count_in_namespace(&self, namespace: &str) -> usize {
437        self.records
438            .values()
439            .flat_map(BTreeMap::values)
440            .filter(|record| record.schema.kind.namespace == namespace)
441            .count()
442    }
443
444    #[must_use]
445    pub fn for_actor(&self, actor: PersonId) -> Option<&ActorKnowledge> {
446        self.actors.get(&actor)
447    }
448
449    #[must_use]
450    pub fn for_holder(
451        &self,
452        holder: &KnowledgeHolderRef,
453    ) -> Option<&BTreeMap<KnowledgeRecordId, KnowledgeRecord>> {
454        self.records.get(holder)
455    }
456
457    /// Returns one deterministic holder-facing page at the supplied read cut.
458    ///
459    /// # Errors
460    ///
461    /// Returns the same validation errors as [`GenericKnowledgeLedger::query`].
462    pub fn query(
463        &self,
464        holder: KnowledgeHolderRef,
465        query: &KnowledgeQuery,
466        read_cut: KnowledgeReadCut,
467    ) -> Result<KnowledgeQueryResult, KnowledgeQueryError> {
468        query_records(&self.records, holder, query, read_cut)
469    }
470
471    /// Queries the current settled holder projection using an engine-derived
472    /// read cut. Callers cannot substitute a cross-holder or stale root.
473    ///
474    /// # Errors
475    ///
476    /// Returns an error when the holder ledger is inconsistent or the query or
477    /// cursor does not match the derived read cut.
478    pub fn query_current(
479        &self,
480        holder: KnowledgeHolderRef,
481        query: &KnowledgeQuery,
482        boundary: Option<BoundaryId>,
483    ) -> Result<KnowledgeQueryResult, KnowledgeQueryError> {
484        let read_cut = KnowledgeReadCut {
485            boundary,
486            holder_projection_root: holder_projection_root(&holder, &self.records)?,
487            holder_overlay_root: None,
488        };
489        query_records(&self.records, holder, query, read_cut)
490    }
491
492    /// Queries an omniscient system view with a same-boundary holder overlay.
493    /// The returned values are owned so no mutable runtime ledger is exposed.
494    ///
495    /// # Errors
496    ///
497    /// Returns an error when the overlay collides with a settled record ID,
498    /// when either ledger is inconsistent, or when the query or cursor does not
499    /// match the derived read cut.
500    pub fn query_with_overlay(
501        &self,
502        holder: KnowledgeHolderRef,
503        query: &KnowledgeQuery,
504        boundary: Option<BoundaryId>,
505        overlay: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
506    ) -> Result<KnowledgeQueryResult, KnowledgeQueryError> {
507        let mut merged = self.records.clone();
508        if let Some(records) = overlay.get(&holder) {
509            let entry = merged.entry(holder.clone()).or_default();
510            for (id, record) in records {
511                if entry.insert(*id, record.clone()).is_some() {
512                    return Err(KnowledgeQueryError::InvalidLedger);
513                }
514            }
515        }
516        let read_cut = KnowledgeReadCut {
517            boundary,
518            holder_projection_root: holder_projection_root(&holder, &self.records)?,
519            holder_overlay_root: Some(holder_overlay_root(&holder, &merged, overlay)?),
520        };
521        query_records(&merged, holder, query, read_cut)
522    }
523}
524
525#[derive(Serialize)]
526struct HolderProjectionCutMaterial<'a> {
527    holder: &'a KnowledgeHolderRef,
528    full_history_views: Vec<KnowledgeRecordView>,
529}
530
531#[derive(Serialize)]
532struct HolderOverlayCutMaterial<'a> {
533    holder: &'a KnowledgeHolderRef,
534    visible_projected_records: Vec<KnowledgeRecordView>,
535}
536
537fn holder_projection_root(
538    holder: &KnowledgeHolderRef,
539    ledger: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
540) -> Result<String, KnowledgeQueryError> {
541    let records = ledger.get(holder).cloned().unwrap_or_default();
542    let local_ids = holder_local_ids(&records);
543    let views = records
544        .values()
545        .map(|record| to_view(record, local_ids[&record.id], &local_ids))
546        .collect();
547    hash_material(
548        b"canwu.knowledge.holder-projection.v1",
549        &HolderProjectionCutMaterial {
550            holder,
551            full_history_views: views,
552        },
553    )
554}
555
556fn holder_overlay_root(
557    holder: &KnowledgeHolderRef,
558    merged: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
559    overlay: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
560) -> Result<String, KnowledgeQueryError> {
561    let merged_records = merged.get(holder).cloned().unwrap_or_default();
562    let local_ids = holder_local_ids(&merged_records);
563    let views = overlay
564        .get(holder)
565        .into_iter()
566        .flat_map(|records| records.values())
567        .map(|record| to_view(record, local_ids[&record.id], &local_ids))
568        .collect();
569    hash_material(
570        b"canwu.knowledge.holder-overlay.v1",
571        &HolderOverlayCutMaterial {
572            holder,
573            visible_projected_records: views,
574        },
575    )
576}
577
578fn hash_material<T: Serialize>(domain: &[u8], value: &T) -> Result<String, KnowledgeQueryError> {
579    let bytes = serde_json::to_vec(value).map_err(|_| KnowledgeQueryError::Encoding)?;
580    let mut hasher = blake3::Hasher::new();
581    hasher.update(domain);
582    hasher.update(&[0]);
583    hasher.update(&bytes);
584    Ok(hasher.finalize().to_hex().to_string())
585}
586
587#[derive(Clone, Copy, Debug, Eq, PartialEq)]
588pub enum KnowledgeQueryError {
589    InvalidLimit,
590    InvalidCursor,
591    ReadCutUnavailable,
592    InvalidLedger,
593    Encoding,
594}
595
596#[derive(Serialize)]
597struct KnowledgeQueryHashMaterial {
598    schemas: Vec<KnowledgeSchemaId>,
599    subjects: Vec<KnowledgeSubject>,
600    learned_after: Option<SimTime>,
601    learned_at_or_before: Option<SimTime>,
602    view: KnowledgeHistoryView,
603}
604
605fn query_hash(query: &KnowledgeQuery) -> Result<String, KnowledgeQueryError> {
606    let mut schemas = query.schemas.clone();
607    schemas.sort();
608    schemas.dedup();
609    let mut subjects = query.subjects.clone();
610    subjects.sort();
611    subjects.dedup();
612    let material = KnowledgeQueryHashMaterial {
613        schemas,
614        subjects,
615        learned_after: query.learned_after,
616        learned_at_or_before: query.learned_at_or_before,
617        view: query.view,
618    };
619    let bytes = serde_json::to_vec(&material).map_err(|_| KnowledgeQueryError::Encoding)?;
620    let mut hasher = blake3::Hasher::new();
621    hasher.update(b"canwu.knowledge.query.v1");
622    hasher.update(&[0]);
623    hasher.update(&bytes);
624    Ok(hasher.finalize().to_hex().to_string())
625}
626
627#[derive(Serialize)]
628struct KnowledgeCursorBindingMaterial<'a> {
629    holder: &'a KnowledgeHolderRef,
630    query_hash: &'a str,
631    read_cut: &'a KnowledgeReadCut,
632}
633
634fn cursor_binding_hash(
635    holder: &KnowledgeHolderRef,
636    query_hash: &str,
637    read_cut: &KnowledgeReadCut,
638) -> Result<String, KnowledgeQueryError> {
639    let bytes = serde_json::to_vec(&KnowledgeCursorBindingMaterial {
640        holder,
641        query_hash,
642        read_cut,
643    })
644    .map_err(|_| KnowledgeQueryError::Encoding)?;
645    let mut hasher = blake3::Hasher::new();
646    hasher.update(b"canwu.knowledge.cursor.v1");
647    hasher.update(&[0]);
648    hasher.update(&bytes);
649    Ok(hasher.finalize().to_hex().to_string())
650}
651
652fn validate_cursor(
653    cursor: &KnowledgeCursor,
654    holder: &KnowledgeHolderRef,
655    query_hash: &str,
656    read_cut: &KnowledgeReadCut,
657) -> Result<(), KnowledgeQueryError> {
658    if cursor.binding_hash
659        != cursor_binding_hash(&cursor.holder, &cursor.query_hash, &cursor.read_cut)?
660    {
661        return Err(KnowledgeQueryError::InvalidCursor);
662    }
663    if cursor.read_cut != *read_cut {
664        return Err(KnowledgeQueryError::ReadCutUnavailable);
665    }
666    if cursor.holder != *holder || cursor.query_hash != query_hash {
667        return Err(KnowledgeQueryError::InvalidCursor);
668    }
669    Ok(())
670}
671
672fn holder_local_ids(
673    records: &BTreeMap<KnowledgeRecordId, KnowledgeRecord>,
674) -> BTreeMap<KnowledgeRecordId, HolderKnowledgeRecordId> {
675    records
676        .keys()
677        .enumerate()
678        .map(|(index, id)| {
679            (
680                *id,
681                HolderKnowledgeRecordId::new(u64::try_from(index + 1).unwrap_or(u64::MAX)),
682            )
683        })
684        .collect()
685}
686
687fn current_heads(
688    records: &BTreeMap<KnowledgeRecordId, KnowledgeRecord>,
689) -> std::collections::BTreeSet<KnowledgeRecordId> {
690    let mut superseded = std::collections::BTreeSet::new();
691    for record in records.values() {
692        superseded.extend(record.supersedes.iter().copied());
693    }
694    records
695        .keys()
696        .filter(|id| !superseded.contains(id))
697        .copied()
698        .collect()
699}
700
701fn to_view(
702    record: &KnowledgeRecord,
703    local_id: HolderKnowledgeRecordId,
704    local_ids: &BTreeMap<KnowledgeRecordId, HolderKnowledgeRecordId>,
705) -> KnowledgeRecordView {
706    KnowledgeRecordView {
707        id: local_id,
708        holder: record.holder.clone(),
709        schema: record.schema.clone(),
710        subjects: record.subjects.clone(),
711        payload: record.payload.clone(),
712        as_of: record.as_of,
713        learned_at: record.learned_at,
714        confidence_per_mille: record.confidence_per_mille,
715        supersedes: record
716            .supersedes
717            .iter()
718            .filter_map(|id| local_ids.get(id).copied())
719            .collect(),
720        contradicts: record
721            .contradicts
722            .iter()
723            .filter_map(|id| local_ids.get(id).copied())
724            .collect(),
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use canwu_core::{KnowledgeRecordKind, KnowledgeSchemaId, OrganizationId};
732    use serde_json::json;
733
734    fn schema() -> KnowledgeSchemaId {
735        KnowledgeSchemaId::new(KnowledgeRecordKind::new("fixture.knowledge", "claim"), 1)
736    }
737
738    fn record(
739        holder: &KnowledgeHolderRef,
740        id: u64,
741        learned_at: SimTime,
742        supersedes: Vec<KnowledgeRecordId>,
743        contradicts: Vec<KnowledgeRecordId>,
744    ) -> KnowledgeRecord {
745        KnowledgeRecord {
746            id: KnowledgeRecordId::new(id),
747            holder: holder.clone(),
748            schema: schema(),
749            subjects: vec![],
750            payload: json!({ "value": id }),
751            as_of: None,
752            learned_at,
753            confidence_per_mille: 900,
754            origin: KnowledgeOrigin {
755                method: "fixture".to_owned(),
756                evidence: vec![],
757            },
758            supersedes,
759            contradicts,
760        }
761    }
762
763    fn history_query(
764        schemas: Vec<KnowledgeSchemaId>,
765        limit: u32,
766        after: Option<KnowledgeCursor>,
767    ) -> KnowledgeQuery {
768        KnowledgeQuery {
769            schemas,
770            limit,
771            view: KnowledgeHistoryView::FullHistory,
772            after,
773            ..KnowledgeQuery::default()
774        }
775    }
776
777    fn read_cut(root: &str) -> KnowledgeReadCut {
778        KnowledgeReadCut {
779            boundary: Some(BoundaryId::new(2)),
780            holder_projection_root: root.to_owned(),
781            holder_overlay_root: None,
782        }
783    }
784
785    #[test]
786    fn current_heads_and_full_history_are_distinct() {
787        let holder =
788            KnowledgeHolderRef::Entity(canwu_core::EntityRef::Organization(OrganizationId::new(4)));
789        let mut ledger = GenericKnowledgeLedger::default();
790        ledger
791            .insert_records(
792                holder.clone(),
793                [
794                    record(&holder, 1, SimTime::from_minutes(1), vec![], vec![]),
795                    record(
796                        &holder,
797                        2,
798                        SimTime::from_minutes(2),
799                        vec![KnowledgeRecordId::new(1)],
800                        vec![],
801                    ),
802                    record(
803                        &holder,
804                        3,
805                        SimTime::from_minutes(3),
806                        vec![],
807                        vec![KnowledgeRecordId::new(1)],
808                    ),
809                ],
810            )
811            .expect("fixture records should be admitted");
812
813        let current = ledger
814            .query(
815                holder.clone(),
816                &KnowledgeQuery::default(),
817                read_cut("holder-root-1"),
818            )
819            .expect("current-head query should succeed");
820        assert_eq!(
821            current
822                .records
823                .iter()
824                .map(|record| record.payload["value"].as_u64().unwrap())
825                .collect::<Vec<_>>(),
826            vec![2, 3]
827        );
828        assert!(current.records.iter().all(|record| record.id.get() > 0));
829        let holder_view = serde_json::to_value(&current.records[0])
830            .expect("holder-facing record should serialize");
831        assert!(holder_view.get("origin").is_none());
832        assert!(holder_view.get("evidence").is_none());
833
834        let history = ledger
835            .query(
836                holder,
837                &KnowledgeQuery {
838                    view: KnowledgeHistoryView::FullHistory,
839                    ..KnowledgeQuery::default()
840                },
841                read_cut("holder-root-1"),
842            )
843            .expect("history query should succeed");
844        assert_eq!(history.records.len(), 3);
845    }
846
847    #[test]
848    fn holder_local_ids_hide_global_gaps_and_cursor_is_stable() {
849        let holder = KnowledgeHolderRef::Person(PersonId::new(9));
850        let mut ledger = GenericKnowledgeLedger::default();
851        ledger
852            .insert_records(
853                holder.clone(),
854                [
855                    record(&holder, 10, SimTime::from_minutes(1), vec![], vec![]),
856                    record(&holder, 42, SimTime::from_minutes(1), vec![], vec![]),
857                ],
858            )
859            .expect("fixture records should be admitted");
860        let cut = read_cut("holder-root-4");
861        let first = ledger
862            .query(
863                holder.clone(),
864                &history_query(vec![schema(), schema()], 1, None),
865                cut.clone(),
866            )
867            .expect("first page should succeed");
868        assert_eq!(first.records[0].id.get(), 1);
869        let cursor = first.next.clone().expect("first page should have a cursor");
870        let mut forged_cursor = cursor.clone();
871        forged_cursor.binding_hash = "forged".to_owned();
872        assert_eq!(
873            ledger.query(
874                holder.clone(),
875                &history_query(vec![schema()], 2, Some(forged_cursor)),
876                cut.clone(),
877            ),
878            Err(KnowledgeQueryError::InvalidCursor)
879        );
880        let second = ledger
881            .query(
882                holder.clone(),
883                &history_query(vec![schema()], 2, Some(cursor.clone())),
884                cut.clone(),
885            )
886            .expect("second page should succeed");
887        assert_eq!(second.records[0].id.get(), 2);
888
889        assert_eq!(
890            ledger.query(
891                holder,
892                &history_query(vec![schema()], 2, Some(cursor)),
893                read_cut("newer-holder-root"),
894            ),
895            Err(KnowledgeQueryError::ReadCutUnavailable)
896        );
897    }
898
899    #[test]
900    fn generic_ledger_admission_is_atomic_and_globally_append_only() {
901        let first_holder = KnowledgeHolderRef::Person(PersonId::new(1));
902        let second_holder = KnowledgeHolderRef::Person(PersonId::new(2));
903        let mut ledger = GenericKnowledgeLedger::default();
904        ledger
905            .insert_records(
906                first_holder.clone(),
907                [record(
908                    &first_holder,
909                    1,
910                    SimTime::from_minutes(1),
911                    vec![],
912                    vec![],
913                )],
914            )
915            .expect("the first global ID should be admitted");
916
917        let before = ledger.clone();
918        assert_eq!(
919            ledger.insert_records(
920                first_holder.clone(),
921                [record(
922                    &second_holder,
923                    2,
924                    SimTime::from_minutes(2),
925                    vec![],
926                    vec![],
927                )],
928            ),
929            Err(KnowledgeLedgerError::HolderMismatch)
930        );
931        assert_eq!(ledger, before);
932
933        assert_eq!(
934            ledger.insert_records(
935                second_holder.clone(),
936                [record(
937                    &second_holder,
938                    1,
939                    SimTime::from_minutes(2),
940                    vec![],
941                    vec![],
942                )],
943            ),
944            Err(KnowledgeLedgerError::DuplicateRecordId)
945        );
946        assert_eq!(ledger, before);
947
948        assert_eq!(
949            ledger.insert_records(
950                second_holder.clone(),
951                [
952                    record(&second_holder, 2, SimTime::from_minutes(2), vec![], vec![],),
953                    record(&second_holder, 2, SimTime::from_minutes(3), vec![], vec![],),
954                ],
955            ),
956            Err(KnowledgeLedgerError::DuplicateRecordId)
957        );
958        assert_eq!(ledger, before);
959    }
960
961    #[test]
962    fn empty_snapshot_preserves_wire_and_holder_ledgers_are_canonical() {
963        assert_eq!(
964            serde_json::to_value(KnowledgeSnapshot::default())
965                .expect("empty knowledge should serialize"),
966            json!({ "actors": {} })
967        );
968
969        let first_holder = KnowledgeHolderRef::Person(PersonId::new(1));
970        let second_holder = KnowledgeHolderRef::Person(PersonId::new(2));
971        let mut ledger = GenericKnowledgeLedger::default();
972        ledger
973            .insert_records(
974                second_holder.clone(),
975                [record(
976                    &second_holder,
977                    3,
978                    SimTime::from_minutes(3),
979                    vec![],
980                    vec![],
981                )],
982            )
983            .expect("second holder fixture should be admitted");
984        ledger
985            .insert_records(
986                first_holder.clone(),
987                [
988                    record(&first_holder, 2, SimTime::from_minutes(2), vec![], vec![]),
989                    record(&first_holder, 1, SimTime::from_minutes(1), vec![], vec![]),
990                ],
991            )
992            .expect("first holder fixture should be admitted");
993        let snapshot = KnowledgeSnapshot {
994            actors: BTreeMap::new(),
995            records: ledger.records,
996        };
997
998        let encoded = serde_json::to_value(&snapshot).expect("knowledge snapshot should serialize");
999        assert_eq!(
1000            encoded["records"][0]["holder"],
1001            json!({ "type": "person", "value": 1 })
1002        );
1003        assert_eq!(encoded["records"][0]["records"][0]["id"], json!(1));
1004        assert_eq!(encoded["records"][0]["records"][1]["id"], json!(2));
1005        assert_eq!(
1006            serde_json::from_value::<KnowledgeSnapshot>(encoded.clone())
1007                .expect("canonical holder ledger should deserialize"),
1008            snapshot
1009        );
1010
1011        let mut duplicate_global_id = encoded;
1012        duplicate_global_id["records"][1]["records"][0]["id"] = json!(1);
1013        assert!(serde_json::from_value::<KnowledgeSnapshot>(duplicate_global_id).is_err());
1014
1015        let mut inconsistent = snapshot;
1016        let record = inconsistent
1017            .records
1018            .get_mut(&first_holder)
1019            .expect("first holder exists")
1020            .remove(&KnowledgeRecordId::new(1))
1021            .expect("record exists");
1022        inconsistent
1023            .records
1024            .get_mut(&first_holder)
1025            .expect("first holder exists")
1026            .insert(KnowledgeRecordId::new(9), record);
1027        assert!(serde_json::to_value(inconsistent).is_err());
1028    }
1029
1030    #[test]
1031    fn successor_holders_do_not_inherit_and_page_limit_is_closed() {
1032        let retired = KnowledgeHolderRef::Entity(canwu_core::EntityRef::Domain(
1033            DomainRecordRef::new("fixture.organization", "office", "retired"),
1034        ));
1035        let successor = KnowledgeHolderRef::Entity(canwu_core::EntityRef::Domain(
1036            DomainRecordRef::new("fixture.organization", "office", "successor"),
1037        ));
1038        let mut ledger = GenericKnowledgeLedger::default();
1039        ledger
1040            .insert_records(
1041                retired.clone(),
1042                [record(
1043                    &retired,
1044                    1,
1045                    SimTime::from_minutes(1),
1046                    vec![],
1047                    vec![],
1048                )],
1049            )
1050            .expect("retired holder history should remain addressable");
1051        assert!(ledger.for_holder(&successor).is_none());
1052
1053        assert!(
1054            ledger
1055                .query(
1056                    successor.clone(),
1057                    &KnowledgeQuery {
1058                        limit: 1_000,
1059                        ..KnowledgeQuery::default()
1060                    },
1061                    read_cut("successor-root"),
1062                )
1063                .is_ok()
1064        );
1065        assert_eq!(
1066            ledger.query(
1067                successor,
1068                &KnowledgeQuery {
1069                    limit: 1_001,
1070                    ..KnowledgeQuery::default()
1071                },
1072                read_cut("successor-root"),
1073            ),
1074            Err(KnowledgeQueryError::InvalidLimit)
1075        );
1076    }
1077}