1use 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
62pub 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)]
173pub struct GenericKnowledgeLedger {
175 pub records: BTreeMap<KnowledgeHolderRef, BTreeMap<KnowledgeRecordId, KnowledgeRecord>>,
176}
177
178#[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 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 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]
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 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 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 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(¤t.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}