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]
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 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 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 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(¤t.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}