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 format-5 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    #[must_use]
435    pub fn for_actor(&self, actor: PersonId) -> Option<&ActorKnowledge> {
436        self.actors.get(&actor)
437    }
438
439    #[must_use]
440    pub fn for_holder(
441        &self,
442        holder: &KnowledgeHolderRef,
443    ) -> Option<&BTreeMap<KnowledgeRecordId, KnowledgeRecord>> {
444        self.records.get(holder)
445    }
446
447    /// Returns one deterministic holder-facing page at the supplied read cut.
448    ///
449    /// # Errors
450    ///
451    /// Returns the same validation errors as [`GenericKnowledgeLedger::query`].
452    pub fn query(
453        &self,
454        holder: KnowledgeHolderRef,
455        query: &KnowledgeQuery,
456        read_cut: KnowledgeReadCut,
457    ) -> Result<KnowledgeQueryResult, KnowledgeQueryError> {
458        query_records(&self.records, holder, query, read_cut)
459    }
460
461    /// Queries the current settled holder projection using an engine-derived
462    /// read cut. Callers cannot substitute a cross-holder or stale root.
463    ///
464    /// # Errors
465    ///
466    /// Returns an error when the holder ledger is inconsistent or the query or
467    /// cursor does not match the derived read cut.
468    pub fn query_current(
469        &self,
470        holder: KnowledgeHolderRef,
471        query: &KnowledgeQuery,
472        boundary: Option<BoundaryId>,
473    ) -> Result<KnowledgeQueryResult, KnowledgeQueryError> {
474        let read_cut = KnowledgeReadCut {
475            boundary,
476            holder_projection_root: holder_projection_root(&holder, &self.records)?,
477            holder_overlay_root: None,
478        };
479        query_records(&self.records, holder, query, read_cut)
480    }
481
482    /// Queries an omniscient system view with a same-boundary holder overlay.
483    /// The returned values are owned so no mutable runtime ledger is exposed.
484    ///
485    /// # Errors
486    ///
487    /// Returns an error when the overlay collides with a settled record ID,
488    /// when either ledger is inconsistent, or when the query or cursor does not
489    /// match the derived read cut.
490    pub fn query_with_overlay(
491        &self,
492        holder: KnowledgeHolderRef,
493        query: &KnowledgeQuery,
494        boundary: Option<BoundaryId>,
495        overlay: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
496    ) -> Result<KnowledgeQueryResult, KnowledgeQueryError> {
497        let mut merged = self.records.clone();
498        if let Some(records) = overlay.get(&holder) {
499            let entry = merged.entry(holder.clone()).or_default();
500            for (id, record) in records {
501                if entry.insert(*id, record.clone()).is_some() {
502                    return Err(KnowledgeQueryError::InvalidLedger);
503                }
504            }
505        }
506        let read_cut = KnowledgeReadCut {
507            boundary,
508            holder_projection_root: holder_projection_root(&holder, &self.records)?,
509            holder_overlay_root: Some(holder_overlay_root(&holder, &merged, overlay)?),
510        };
511        query_records(&merged, holder, query, read_cut)
512    }
513}
514
515#[derive(Serialize)]
516struct HolderProjectionCutMaterial<'a> {
517    holder: &'a KnowledgeHolderRef,
518    full_history_views: Vec<KnowledgeRecordView>,
519}
520
521#[derive(Serialize)]
522struct HolderOverlayCutMaterial<'a> {
523    holder: &'a KnowledgeHolderRef,
524    visible_projected_records: Vec<KnowledgeRecordView>,
525}
526
527fn holder_projection_root(
528    holder: &KnowledgeHolderRef,
529    ledger: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
530) -> Result<String, KnowledgeQueryError> {
531    let records = ledger.get(holder).cloned().unwrap_or_default();
532    let local_ids = holder_local_ids(&records);
533    let views = records
534        .values()
535        .map(|record| to_view(record, local_ids[&record.id], &local_ids))
536        .collect();
537    hash_material(
538        b"canwu.knowledge.holder-projection.v1",
539        &HolderProjectionCutMaterial {
540            holder,
541            full_history_views: views,
542        },
543    )
544}
545
546fn holder_overlay_root(
547    holder: &KnowledgeHolderRef,
548    merged: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
549    overlay: &BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
550) -> Result<String, KnowledgeQueryError> {
551    let merged_records = merged.get(holder).cloned().unwrap_or_default();
552    let local_ids = holder_local_ids(&merged_records);
553    let views = overlay
554        .get(holder)
555        .into_iter()
556        .flat_map(|records| records.values())
557        .map(|record| to_view(record, local_ids[&record.id], &local_ids))
558        .collect();
559    hash_material(
560        b"canwu.knowledge.holder-overlay.v1",
561        &HolderOverlayCutMaterial {
562            holder,
563            visible_projected_records: views,
564        },
565    )
566}
567
568fn hash_material<T: Serialize>(domain: &[u8], value: &T) -> Result<String, KnowledgeQueryError> {
569    let bytes = serde_json::to_vec(value).map_err(|_| KnowledgeQueryError::Encoding)?;
570    let mut hasher = blake3::Hasher::new();
571    hasher.update(domain);
572    hasher.update(&[0]);
573    hasher.update(&bytes);
574    Ok(hasher.finalize().to_hex().to_string())
575}
576
577#[derive(Clone, Copy, Debug, Eq, PartialEq)]
578pub enum KnowledgeQueryError {
579    InvalidLimit,
580    InvalidCursor,
581    ReadCutUnavailable,
582    InvalidLedger,
583    Encoding,
584}
585
586#[derive(Serialize)]
587struct KnowledgeQueryHashMaterial {
588    schemas: Vec<KnowledgeSchemaId>,
589    subjects: Vec<KnowledgeSubject>,
590    learned_after: Option<SimTime>,
591    learned_at_or_before: Option<SimTime>,
592    view: KnowledgeHistoryView,
593}
594
595fn query_hash(query: &KnowledgeQuery) -> Result<String, KnowledgeQueryError> {
596    let mut schemas = query.schemas.clone();
597    schemas.sort();
598    schemas.dedup();
599    let mut subjects = query.subjects.clone();
600    subjects.sort();
601    subjects.dedup();
602    let material = KnowledgeQueryHashMaterial {
603        schemas,
604        subjects,
605        learned_after: query.learned_after,
606        learned_at_or_before: query.learned_at_or_before,
607        view: query.view,
608    };
609    let bytes = serde_json::to_vec(&material).map_err(|_| KnowledgeQueryError::Encoding)?;
610    let mut hasher = blake3::Hasher::new();
611    hasher.update(b"canwu.knowledge.query.v1");
612    hasher.update(&[0]);
613    hasher.update(&bytes);
614    Ok(hasher.finalize().to_hex().to_string())
615}
616
617#[derive(Serialize)]
618struct KnowledgeCursorBindingMaterial<'a> {
619    holder: &'a KnowledgeHolderRef,
620    query_hash: &'a str,
621    read_cut: &'a KnowledgeReadCut,
622}
623
624fn cursor_binding_hash(
625    holder: &KnowledgeHolderRef,
626    query_hash: &str,
627    read_cut: &KnowledgeReadCut,
628) -> Result<String, KnowledgeQueryError> {
629    let bytes = serde_json::to_vec(&KnowledgeCursorBindingMaterial {
630        holder,
631        query_hash,
632        read_cut,
633    })
634    .map_err(|_| KnowledgeQueryError::Encoding)?;
635    let mut hasher = blake3::Hasher::new();
636    hasher.update(b"canwu.knowledge.cursor.v1");
637    hasher.update(&[0]);
638    hasher.update(&bytes);
639    Ok(hasher.finalize().to_hex().to_string())
640}
641
642fn validate_cursor(
643    cursor: &KnowledgeCursor,
644    holder: &KnowledgeHolderRef,
645    query_hash: &str,
646    read_cut: &KnowledgeReadCut,
647) -> Result<(), KnowledgeQueryError> {
648    if cursor.binding_hash
649        != cursor_binding_hash(&cursor.holder, &cursor.query_hash, &cursor.read_cut)?
650    {
651        return Err(KnowledgeQueryError::InvalidCursor);
652    }
653    if cursor.read_cut != *read_cut {
654        return Err(KnowledgeQueryError::ReadCutUnavailable);
655    }
656    if cursor.holder != *holder || cursor.query_hash != query_hash {
657        return Err(KnowledgeQueryError::InvalidCursor);
658    }
659    Ok(())
660}
661
662fn holder_local_ids(
663    records: &BTreeMap<KnowledgeRecordId, KnowledgeRecord>,
664) -> BTreeMap<KnowledgeRecordId, HolderKnowledgeRecordId> {
665    records
666        .keys()
667        .enumerate()
668        .map(|(index, id)| {
669            (
670                *id,
671                HolderKnowledgeRecordId::new(u64::try_from(index + 1).unwrap_or(u64::MAX)),
672            )
673        })
674        .collect()
675}
676
677fn current_heads(
678    records: &BTreeMap<KnowledgeRecordId, KnowledgeRecord>,
679) -> std::collections::BTreeSet<KnowledgeRecordId> {
680    let mut superseded = std::collections::BTreeSet::new();
681    for record in records.values() {
682        superseded.extend(record.supersedes.iter().copied());
683    }
684    records
685        .keys()
686        .filter(|id| !superseded.contains(id))
687        .copied()
688        .collect()
689}
690
691fn to_view(
692    record: &KnowledgeRecord,
693    local_id: HolderKnowledgeRecordId,
694    local_ids: &BTreeMap<KnowledgeRecordId, HolderKnowledgeRecordId>,
695) -> KnowledgeRecordView {
696    KnowledgeRecordView {
697        id: local_id,
698        holder: record.holder.clone(),
699        schema: record.schema.clone(),
700        subjects: record.subjects.clone(),
701        payload: record.payload.clone(),
702        as_of: record.as_of,
703        learned_at: record.learned_at,
704        confidence_per_mille: record.confidence_per_mille,
705        supersedes: record
706            .supersedes
707            .iter()
708            .filter_map(|id| local_ids.get(id).copied())
709            .collect(),
710        contradicts: record
711            .contradicts
712            .iter()
713            .filter_map(|id| local_ids.get(id).copied())
714            .collect(),
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use canwu_core::{KnowledgeRecordKind, KnowledgeSchemaId, OrganizationId};
722    use serde_json::json;
723
724    fn schema() -> KnowledgeSchemaId {
725        KnowledgeSchemaId::new(KnowledgeRecordKind::new("fixture.knowledge", "claim"), 1)
726    }
727
728    fn record(
729        holder: &KnowledgeHolderRef,
730        id: u64,
731        learned_at: SimTime,
732        supersedes: Vec<KnowledgeRecordId>,
733        contradicts: Vec<KnowledgeRecordId>,
734    ) -> KnowledgeRecord {
735        KnowledgeRecord {
736            id: KnowledgeRecordId::new(id),
737            holder: holder.clone(),
738            schema: schema(),
739            subjects: vec![],
740            payload: json!({ "value": id }),
741            as_of: None,
742            learned_at,
743            confidence_per_mille: 900,
744            origin: KnowledgeOrigin {
745                method: "fixture".to_owned(),
746                evidence: vec![],
747            },
748            supersedes,
749            contradicts,
750        }
751    }
752
753    fn history_query(
754        schemas: Vec<KnowledgeSchemaId>,
755        limit: u32,
756        after: Option<KnowledgeCursor>,
757    ) -> KnowledgeQuery {
758        KnowledgeQuery {
759            schemas,
760            limit,
761            view: KnowledgeHistoryView::FullHistory,
762            after,
763            ..KnowledgeQuery::default()
764        }
765    }
766
767    fn read_cut(root: &str) -> KnowledgeReadCut {
768        KnowledgeReadCut {
769            boundary: Some(BoundaryId::new(2)),
770            holder_projection_root: root.to_owned(),
771            holder_overlay_root: None,
772        }
773    }
774
775    #[test]
776    fn current_heads_and_full_history_are_distinct() {
777        let holder =
778            KnowledgeHolderRef::Entity(canwu_core::EntityRef::Organization(OrganizationId::new(4)));
779        let mut ledger = GenericKnowledgeLedger::default();
780        ledger
781            .insert_records(
782                holder.clone(),
783                [
784                    record(&holder, 1, SimTime::from_minutes(1), vec![], vec![]),
785                    record(
786                        &holder,
787                        2,
788                        SimTime::from_minutes(2),
789                        vec![KnowledgeRecordId::new(1)],
790                        vec![],
791                    ),
792                    record(
793                        &holder,
794                        3,
795                        SimTime::from_minutes(3),
796                        vec![],
797                        vec![KnowledgeRecordId::new(1)],
798                    ),
799                ],
800            )
801            .expect("fixture records should be admitted");
802
803        let current = ledger
804            .query(
805                holder.clone(),
806                &KnowledgeQuery::default(),
807                read_cut("holder-root-1"),
808            )
809            .expect("current-head query should succeed");
810        assert_eq!(
811            current
812                .records
813                .iter()
814                .map(|record| record.payload["value"].as_u64().unwrap())
815                .collect::<Vec<_>>(),
816            vec![2, 3]
817        );
818        assert!(current.records.iter().all(|record| record.id.get() > 0));
819        let holder_view = serde_json::to_value(&current.records[0])
820            .expect("holder-facing record should serialize");
821        assert!(holder_view.get("origin").is_none());
822        assert!(holder_view.get("evidence").is_none());
823
824        let history = ledger
825            .query(
826                holder,
827                &KnowledgeQuery {
828                    view: KnowledgeHistoryView::FullHistory,
829                    ..KnowledgeQuery::default()
830                },
831                read_cut("holder-root-1"),
832            )
833            .expect("history query should succeed");
834        assert_eq!(history.records.len(), 3);
835    }
836
837    #[test]
838    fn holder_local_ids_hide_global_gaps_and_cursor_is_stable() {
839        let holder = KnowledgeHolderRef::Person(PersonId::new(9));
840        let mut ledger = GenericKnowledgeLedger::default();
841        ledger
842            .insert_records(
843                holder.clone(),
844                [
845                    record(&holder, 10, SimTime::from_minutes(1), vec![], vec![]),
846                    record(&holder, 42, SimTime::from_minutes(1), vec![], vec![]),
847                ],
848            )
849            .expect("fixture records should be admitted");
850        let cut = read_cut("holder-root-4");
851        let first = ledger
852            .query(
853                holder.clone(),
854                &history_query(vec![schema(), schema()], 1, None),
855                cut.clone(),
856            )
857            .expect("first page should succeed");
858        assert_eq!(first.records[0].id.get(), 1);
859        let cursor = first.next.clone().expect("first page should have a cursor");
860        let mut forged_cursor = cursor.clone();
861        forged_cursor.binding_hash = "forged".to_owned();
862        assert_eq!(
863            ledger.query(
864                holder.clone(),
865                &history_query(vec![schema()], 2, Some(forged_cursor)),
866                cut.clone(),
867            ),
868            Err(KnowledgeQueryError::InvalidCursor)
869        );
870        let second = ledger
871            .query(
872                holder.clone(),
873                &history_query(vec![schema()], 2, Some(cursor.clone())),
874                cut.clone(),
875            )
876            .expect("second page should succeed");
877        assert_eq!(second.records[0].id.get(), 2);
878
879        assert_eq!(
880            ledger.query(
881                holder,
882                &history_query(vec![schema()], 2, Some(cursor)),
883                read_cut("newer-holder-root"),
884            ),
885            Err(KnowledgeQueryError::ReadCutUnavailable)
886        );
887    }
888
889    #[test]
890    fn generic_ledger_admission_is_atomic_and_globally_append_only() {
891        let first_holder = KnowledgeHolderRef::Person(PersonId::new(1));
892        let second_holder = KnowledgeHolderRef::Person(PersonId::new(2));
893        let mut ledger = GenericKnowledgeLedger::default();
894        ledger
895            .insert_records(
896                first_holder.clone(),
897                [record(
898                    &first_holder,
899                    1,
900                    SimTime::from_minutes(1),
901                    vec![],
902                    vec![],
903                )],
904            )
905            .expect("the first global ID should be admitted");
906
907        let before = ledger.clone();
908        assert_eq!(
909            ledger.insert_records(
910                first_holder.clone(),
911                [record(
912                    &second_holder,
913                    2,
914                    SimTime::from_minutes(2),
915                    vec![],
916                    vec![],
917                )],
918            ),
919            Err(KnowledgeLedgerError::HolderMismatch)
920        );
921        assert_eq!(ledger, before);
922
923        assert_eq!(
924            ledger.insert_records(
925                second_holder.clone(),
926                [record(
927                    &second_holder,
928                    1,
929                    SimTime::from_minutes(2),
930                    vec![],
931                    vec![],
932                )],
933            ),
934            Err(KnowledgeLedgerError::DuplicateRecordId)
935        );
936        assert_eq!(ledger, before);
937
938        assert_eq!(
939            ledger.insert_records(
940                second_holder.clone(),
941                [
942                    record(&second_holder, 2, SimTime::from_minutes(2), vec![], vec![],),
943                    record(&second_holder, 2, SimTime::from_minutes(3), vec![], vec![],),
944                ],
945            ),
946            Err(KnowledgeLedgerError::DuplicateRecordId)
947        );
948        assert_eq!(ledger, before);
949    }
950
951    #[test]
952    fn empty_snapshot_preserves_wire_and_holder_ledgers_are_canonical() {
953        assert_eq!(
954            serde_json::to_value(KnowledgeSnapshot::default())
955                .expect("empty knowledge should serialize"),
956            json!({ "actors": {} })
957        );
958
959        let first_holder = KnowledgeHolderRef::Person(PersonId::new(1));
960        let second_holder = KnowledgeHolderRef::Person(PersonId::new(2));
961        let mut ledger = GenericKnowledgeLedger::default();
962        ledger
963            .insert_records(
964                second_holder.clone(),
965                [record(
966                    &second_holder,
967                    3,
968                    SimTime::from_minutes(3),
969                    vec![],
970                    vec![],
971                )],
972            )
973            .expect("second holder fixture should be admitted");
974        ledger
975            .insert_records(
976                first_holder.clone(),
977                [
978                    record(&first_holder, 2, SimTime::from_minutes(2), vec![], vec![]),
979                    record(&first_holder, 1, SimTime::from_minutes(1), vec![], vec![]),
980                ],
981            )
982            .expect("first holder fixture should be admitted");
983        let snapshot = KnowledgeSnapshot {
984            actors: BTreeMap::new(),
985            records: ledger.records,
986        };
987
988        let encoded = serde_json::to_value(&snapshot).expect("knowledge snapshot should serialize");
989        assert_eq!(
990            encoded["records"][0]["holder"],
991            json!({ "type": "person", "value": 1 })
992        );
993        assert_eq!(encoded["records"][0]["records"][0]["id"], json!(1));
994        assert_eq!(encoded["records"][0]["records"][1]["id"], json!(2));
995        assert_eq!(
996            serde_json::from_value::<KnowledgeSnapshot>(encoded.clone())
997                .expect("canonical holder ledger should deserialize"),
998            snapshot
999        );
1000
1001        let mut duplicate_global_id = encoded;
1002        duplicate_global_id["records"][1]["records"][0]["id"] = json!(1);
1003        assert!(serde_json::from_value::<KnowledgeSnapshot>(duplicate_global_id).is_err());
1004
1005        let mut inconsistent = snapshot;
1006        let record = inconsistent
1007            .records
1008            .get_mut(&first_holder)
1009            .expect("first holder exists")
1010            .remove(&KnowledgeRecordId::new(1))
1011            .expect("record exists");
1012        inconsistent
1013            .records
1014            .get_mut(&first_holder)
1015            .expect("first holder exists")
1016            .insert(KnowledgeRecordId::new(9), record);
1017        assert!(serde_json::to_value(inconsistent).is_err());
1018    }
1019
1020    #[test]
1021    fn successor_holders_do_not_inherit_and_page_limit_is_closed() {
1022        let retired = KnowledgeHolderRef::Entity(canwu_core::EntityRef::Domain(
1023            DomainRecordRef::new("fixture.organization", "office", "retired"),
1024        ));
1025        let successor = KnowledgeHolderRef::Entity(canwu_core::EntityRef::Domain(
1026            DomainRecordRef::new("fixture.organization", "office", "successor"),
1027        ));
1028        let mut ledger = GenericKnowledgeLedger::default();
1029        ledger
1030            .insert_records(
1031                retired.clone(),
1032                [record(
1033                    &retired,
1034                    1,
1035                    SimTime::from_minutes(1),
1036                    vec![],
1037                    vec![],
1038                )],
1039            )
1040            .expect("retired holder history should remain addressable");
1041        assert!(ledger.for_holder(&successor).is_none());
1042
1043        assert!(
1044            ledger
1045                .query(
1046                    successor.clone(),
1047                    &KnowledgeQuery {
1048                        limit: 1_000,
1049                        ..KnowledgeQuery::default()
1050                    },
1051                    read_cut("successor-root"),
1052                )
1053                .is_ok()
1054        );
1055        assert_eq!(
1056            ledger.query(
1057                successor,
1058                &KnowledgeQuery {
1059                    limit: 1_001,
1060                    ..KnowledgeQuery::default()
1061                },
1062                read_cut("successor-root"),
1063            ),
1064            Err(KnowledgeQueryError::InvalidLimit)
1065        );
1066    }
1067}