Skip to main content

canwu_sim/runtime/
knowledge.rs

1use super::records::{DomainRecordClass, DomainRecordSchemas};
2use super::{
3    CanwuError, ErrorCode, PayloadSchema, PersistentDomainRecordStore, RuntimeCurrentState,
4    SimulationSnapshot, canonical_text, component_key, is_canonical_hash,
5};
6use canwu_core::{
7    CoreEntityKind, DomainRecordKind, EntityRef, KnowledgeHolderPolicy, KnowledgeHolderRef,
8    KnowledgeRecordKind, KnowledgeSchemaId,
9};
10use canwu_knowledge::{
11    DEFAULT_KNOWLEDGE_PAGE_SIZE, KnowledgeRecord, KnowledgeRecordDraft, KnowledgeSubject,
12    KnowledgeSubjectTarget, MAX_KNOWLEDGE_PAGE_SIZE,
13};
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16
17#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
18pub struct KnowledgeLimitsV1 {
19    pub schemas_per_plugin: usize,
20    pub records_per_batch: usize,
21    pub batches_per_system_boundary: usize,
22    pub records_per_boundary: usize,
23    pub payload_bytes_per_record: usize,
24    pub relations_per_record: usize,
25    pub text_bytes: usize,
26    pub max_page_size: u32,
27    pub queries_per_batch: usize,
28    pub ids_per_direct_get: usize,
29    pub default_page_size: u32,
30    pub relation_trace_depth: u32,
31    pub relation_graph_records: usize,
32}
33
34impl KnowledgeLimitsV1 {
35    pub const CURRENT: Self = Self {
36        schemas_per_plugin: 256,
37        records_per_batch: 1_000,
38        batches_per_system_boundary: 64,
39        records_per_boundary: 10_000,
40        payload_bytes_per_record: 65_536,
41        relations_per_record: 64,
42        text_bytes: 1_024,
43        max_page_size: MAX_KNOWLEDGE_PAGE_SIZE,
44        queries_per_batch: 64,
45        ids_per_direct_get: 1_000,
46        default_page_size: DEFAULT_KNOWLEDGE_PAGE_SIZE,
47        relation_trace_depth: 32,
48        relation_graph_records: 10_000,
49    };
50}
51
52#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
53#[serde(tag = "type", content = "kind", rename_all = "snake_case")]
54pub enum KnowledgeSubjectTargetKind {
55    Core(CoreEntityKind),
56    Domain(DomainRecordKind),
57    AnyEntity,
58    Event,
59}
60
61#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62pub struct KnowledgeSubjectSchema {
63    pub role: String,
64    pub targets: Vec<KnowledgeSubjectTargetKind>,
65    pub required: bool,
66    pub multiple: bool,
67}
68
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
70pub struct PluginKnowledgeSchema {
71    pub id: KnowledgeSchemaId,
72    pub schema_hash: String,
73    pub writable: bool,
74    pub payload_schema: PayloadSchema,
75    pub subjects: Vec<KnowledgeSubjectSchema>,
76}
77
78impl PluginKnowledgeSchema {
79    pub(crate) fn canonicalize(&mut self) {
80        for subject in &mut self.subjects {
81            subject.targets.sort();
82            subject.targets.dedup();
83        }
84        self.subjects
85            .sort_by(|left, right| left.role.cmp(&right.role));
86    }
87
88    pub(crate) fn validate(&self) -> Result<(), CanwuError> {
89        validate_kind(&self.id.kind)?;
90        if self.id.version == 0 {
91            return Err(invalid_schema("knowledge schema versions must be nonzero"));
92        }
93        if !is_canonical_hash(&self.schema_hash) {
94            return Err(invalid_schema(
95                "knowledge schemas require a canonical semantic hash",
96            ));
97        }
98        if self
99            .subjects
100            .windows(2)
101            .any(|pair| pair[0].role >= pair[1].role)
102        {
103            return Err(invalid_schema(
104                "knowledge subject roles must be unique and sorted",
105            ));
106        }
107        for subject in &self.subjects {
108            if !canonical_text(&subject.role)
109                || subject.targets.is_empty()
110                || subject.targets.windows(2).any(|pair| pair[0] >= pair[1])
111            {
112                return Err(invalid_schema(
113                    "knowledge subjects require canonical roles and unique sorted targets",
114                ));
115            }
116            for target in &subject.targets {
117                if let KnowledgeSubjectTargetKind::Domain(kind) = target {
118                    validate_domain_kind(kind)?;
119                }
120            }
121        }
122        Ok(())
123    }
124}
125
126pub(crate) type KnowledgeSchemas = BTreeMap<KnowledgeSchemaId, (String, PluginKnowledgeSchema)>;
127pub(crate) type KnowledgeKindOwners = BTreeMap<KnowledgeRecordKind, String>;
128
129pub(crate) fn validate_schema_set(
130    schemas: &KnowledgeSchemas,
131    owners: &KnowledgeKindOwners,
132) -> Result<(), CanwuError> {
133    let mut writable = BTreeMap::<KnowledgeRecordKind, usize>::new();
134    for (id, (owner, schema)) in schemas {
135        if id != &schema.id || owners.get(&id.kind) != Some(owner) {
136            return Err(invalid_schema(
137                "knowledge schema registry ownership is inconsistent",
138            ));
139        }
140        schema.validate()?;
141        if schema.writable {
142            *writable.entry(id.kind.clone()).or_default() += 1;
143        }
144    }
145    if owners.keys().any(|kind| {
146        !schemas.keys().any(|schema| &schema.kind == kind)
147            || writable.get(kind).copied().unwrap_or_default() != 1
148    }) {
149        return Err(invalid_schema(
150            "each knowledge kind requires exactly one writable version",
151        ));
152    }
153    Ok(())
154}
155
156pub(crate) fn validate_draft(
157    draft: &KnowledgeRecordDraft,
158    schema: &PluginKnowledgeSchema,
159    holder: &KnowledgeHolderRef,
160    current: &RuntimeCurrentState,
161    record_schemas: &DomainRecordSchemas,
162) -> Result<(), CanwuError> {
163    validate_record_shape(draft, schema, holder, current, record_schemas, true)
164}
165
166fn validate_record_shape(
167    draft: &KnowledgeRecordDraft,
168    schema: &PluginKnowledgeSchema,
169    holder: &KnowledgeHolderRef,
170    current: &RuntimeCurrentState,
171    record_schemas: &DomainRecordSchemas,
172    require_writable: bool,
173) -> Result<(), CanwuError> {
174    if draft.schema != schema.id {
175        return Err(invalid_knowledge(
176            "knowledge draft names the wrong schema version",
177        ));
178    }
179    if require_writable && !schema.writable {
180        return Err(invalid_knowledge(
181            "knowledge draft uses a read-only schema version",
182        ));
183    }
184    if require_writable {
185        validate_holder_for_publication(holder, current, record_schemas)?;
186    } else {
187        validate_historical_holder(holder, current, record_schemas)?;
188    }
189    if draft.confidence_per_mille > 1_000 {
190        return Err(invalid_knowledge(
191            "knowledge confidence exceeds 1000 per mille",
192        ));
193    }
194    if !canonical_text(&draft.origin.method)
195        || draft.origin.method.len() > KnowledgeLimitsV1::CURRENT.text_bytes
196    {
197        return Err(invalid_knowledge(
198            "knowledge origin method is not canonical or exceeds its limit",
199        ));
200    }
201    if draft.subjects.len() > KnowledgeLimitsV1::CURRENT.relations_per_record
202        || draft.origin.evidence.len() > KnowledgeLimitsV1::CURRENT.relations_per_record
203        || draft.supersedes.len() > KnowledgeLimitsV1::CURRENT.relations_per_record
204        || draft.contradicts.len() > KnowledgeLimitsV1::CURRENT.relations_per_record
205    {
206        return Err(invalid_knowledge(
207            "knowledge record relation limit exceeded",
208        ));
209    }
210    if !strictly_sorted(&draft.subjects) || !strictly_sorted(&draft.origin.evidence) {
211        return Err(invalid_knowledge(
212            "knowledge subjects and evidence references must be sorted and unique",
213        ));
214    }
215    let payload_bytes = serde_json::to_vec(&draft.payload).map_err(|error| {
216        invalid_knowledge(format!("knowledge payload encoding failed: {error}"))
217    })?;
218    if payload_bytes.len() > KnowledgeLimitsV1::CURRENT.payload_bytes_per_record {
219        return Err(invalid_knowledge("knowledge payload byte limit exceeded"));
220    }
221    schema.payload_schema.validate(&draft.payload)?;
222    validate_subjects(&draft.subjects, schema, current, record_schemas)?;
223    validate_relations(draft)?;
224    Ok(())
225}
226
227pub(crate) fn validate_stored_record(
228    record: &KnowledgeRecord,
229    schemas: &KnowledgeSchemas,
230    current: &RuntimeCurrentState,
231    record_schemas: &DomainRecordSchemas,
232) -> Result<(), CanwuError> {
233    let Some((_, schema)) = schemas.get(&record.schema) else {
234        return Err(invalid_knowledge(
235            "knowledge record uses an unregistered schema",
236        ));
237    };
238    let draft = KnowledgeRecordDraft {
239        schema: record.schema.clone(),
240        subjects: record.subjects.clone(),
241        payload: record.payload.clone(),
242        as_of: record.as_of,
243        confidence_per_mille: record.confidence_per_mille,
244        origin: record.origin.clone(),
245        supersedes: record.supersedes.clone(),
246        contradicts: record.contradicts.clone(),
247    };
248    validate_record_shape(
249        &draft,
250        schema,
251        &record.holder,
252        current,
253        record_schemas,
254        false,
255    )
256}
257
258pub(crate) fn validate_snapshot_records(
259    snapshot: &SimulationSnapshot,
260    schemas: &KnowledgeSchemas,
261    record_schemas: &DomainRecordSchemas,
262) -> Result<u64, CanwuError> {
263    let current = RuntimeCurrentState {
264        entities: snapshot.entities.iter().cloned().collect(),
265        people: snapshot
266            .world
267            .people
268            .iter()
269            .cloned()
270            .map(|value| (value.id, value))
271            .collect(),
272        person_availability: snapshot.person_availability.clone(),
273        created_persons: snapshot.created_persons.clone(),
274        letters: snapshot
275            .world
276            .letters
277            .iter()
278            .cloned()
279            .map(|value| (value.id, value))
280            .collect(),
281        governments: snapshot
282            .world
283            .governments
284            .iter()
285            .cloned()
286            .map(|value| (value.id, value))
287            .collect(),
288        territories: snapshot
289            .world
290            .territories
291            .iter()
292            .cloned()
293            .map(|value| (value.id, value))
294            .collect(),
295        routes: snapshot
296            .world
297            .routes
298            .iter()
299            .cloned()
300            .map(|value| (value.id, value))
301            .collect(),
302        armies: snapshot
303            .world
304            .armies
305            .iter()
306            .cloned()
307            .map(|value| (value.id, value))
308            .collect(),
309        knowledge: snapshot.knowledge.clone(),
310        plugin_components: snapshot
311            .plugin_components
312            .iter()
313            .cloned()
314            .map(|record| {
315                (
316                    component_key(
317                        &record.plugin,
318                        &record.state,
319                        &record.entity,
320                        &record.component,
321                    ),
322                    record,
323                )
324            })
325            .collect(),
326        domain_records: PersistentDomainRecordStore::from_records(
327            snapshot
328                .domain_records
329                .iter()
330                .cloned()
331                .map(|record| (record.reference.clone(), record))
332                .collect(),
333        )?,
334        decisions: snapshot.decisions.clone(),
335        root_seed: snapshot.root_seed,
336        authority_root_seed: snapshot.authority_root_seed,
337        random_streams: snapshot
338            .random_streams
339            .iter()
340            .cloned()
341            .map(|stream| (stream.key.clone(), stream))
342            .collect(),
343    };
344    let mut global_ids = std::collections::BTreeSet::new();
345    let mut max_id = 0_u64;
346    for (holder, records) in &snapshot.knowledge.records {
347        for (id, record) in records {
348            if *id != record.id || &record.holder != holder || !global_ids.insert(*id) {
349                return Err(invalid_knowledge(
350                    "knowledge ledger keys, holders, and global IDs must be consistent and unique",
351                ));
352            }
353            if record.learned_at > snapshot.now {
354                return Err(invalid_knowledge(
355                    "knowledge records cannot be learned after the snapshot time",
356                ));
357            }
358            if record
359                .supersedes
360                .iter()
361                .chain(&record.contradicts)
362                .any(|related| !records.contains_key(related))
363            {
364                return Err(invalid_knowledge(
365                    "knowledge relations must resolve within the same holder ledger",
366                ));
367            }
368            validate_stored_record(record, schemas, &current, record_schemas)?;
369            max_id = max_id.max(id.get());
370        }
371    }
372    Ok(max_id)
373}
374
375pub(crate) fn validate_holder_for_publication(
376    holder: &KnowledgeHolderRef,
377    current: &RuntimeCurrentState,
378    record_schemas: &DomainRecordSchemas,
379) -> Result<(), CanwuError> {
380    let allowed = match holder {
381        KnowledgeHolderRef::Person(id) => {
382            current.entities.contains(&EntityRef::Person(*id)) || current.people.contains_key(id)
383        }
384        KnowledgeHolderRef::Entity(EntityRef::Army(id)) => {
385            current.entities.contains(&EntityRef::Army(*id)) || current.armies.contains_key(id)
386        }
387        KnowledgeHolderRef::Entity(EntityRef::Government(id)) => {
388            current.entities.contains(&EntityRef::Government(*id))
389                || current.governments.contains_key(id)
390        }
391        KnowledgeHolderRef::Entity(
392            EntityRef::Organization(_)
393            | EntityRef::Person(_)
394            | EntityRef::Resource(_)
395            | EntityRef::Route(_)
396            | EntityRef::Territory(_),
397        ) => false,
398        KnowledgeHolderRef::Entity(EntityRef::Domain(reference)) => {
399            current.domain_records.get(reference).is_some_and(|record| {
400                !record.is_deleted()
401                    && record.is_active()
402                    && record.class == DomainRecordClass::Entity
403                    && record_schemas
404                        .get(&reference.kind)
405                        .is_some_and(|(_, schema)| {
406                            schema.holder_policy == KnowledgeHolderPolicy::Allowed
407                        })
408            })
409        }
410    };
411    if !allowed {
412        return Err(CanwuError::new(
413            ErrorCode::InvalidKnowledgeHolder,
414            "knowledge holder is missing, retired, deleted, duplicated as a person entity, or ineligible",
415        ));
416    }
417    if let KnowledgeHolderRef::Person(id) = holder
418        && current
419            .person_availability
420            .get(id)
421            .is_some_and(|availability| availability.life == super::LifeState::Dead)
422    {
423        return Err(CanwuError::new(
424            ErrorCode::InvalidKnowledgeHolder,
425            "knowledge cannot be published to a dead person's holder ledger",
426        ));
427    }
428    Ok(())
429}
430
431fn validate_historical_holder(
432    holder: &KnowledgeHolderRef,
433    current: &RuntimeCurrentState,
434    record_schemas: &DomainRecordSchemas,
435) -> Result<(), CanwuError> {
436    let allowed = match holder {
437        KnowledgeHolderRef::Person(id) => {
438            current.entities.contains(&EntityRef::Person(*id)) || current.people.contains_key(id)
439        }
440        KnowledgeHolderRef::Entity(EntityRef::Army(id)) => {
441            current.entities.contains(&EntityRef::Army(*id)) || current.armies.contains_key(id)
442        }
443        KnowledgeHolderRef::Entity(EntityRef::Government(id)) => {
444            current.entities.contains(&EntityRef::Government(*id))
445                || current.governments.contains_key(id)
446        }
447        KnowledgeHolderRef::Entity(
448            EntityRef::Organization(_)
449            | EntityRef::Person(_)
450            | EntityRef::Resource(_)
451            | EntityRef::Route(_)
452            | EntityRef::Territory(_),
453        ) => false,
454        KnowledgeHolderRef::Entity(EntityRef::Domain(reference)) => {
455            current.domain_records.get(reference).is_some_and(|record| {
456                record.class == DomainRecordClass::Entity
457                    && record_schemas
458                        .get(&reference.kind)
459                        .is_some_and(|(_, schema)| {
460                            schema.holder_policy == KnowledgeHolderPolicy::Allowed
461                        })
462            })
463        }
464    };
465    if !allowed {
466        return Err(CanwuError::new(
467            ErrorCode::InvalidKnowledgeHolder,
468            "knowledge record names an unknown or ineligible historical holder",
469        ));
470    }
471    Ok(())
472}
473
474fn validate_subjects(
475    subjects: &[KnowledgeSubject],
476    schema: &PluginKnowledgeSchema,
477    current: &RuntimeCurrentState,
478    record_schemas: &DomainRecordSchemas,
479) -> Result<(), CanwuError> {
480    let mut counts = BTreeMap::<&str, usize>::new();
481    for subject in subjects {
482        if !canonical_text(&subject.role) {
483            return Err(invalid_knowledge(
484                "knowledge subject roles must be canonical",
485            ));
486        }
487        let Some(declaration) = schema
488            .subjects
489            .iter()
490            .find(|item| item.role == subject.role)
491        else {
492            return Err(invalid_knowledge(
493                "knowledge record contains an undeclared subject role",
494            ));
495        };
496        if !subject_matches(subject, declaration, current, record_schemas) {
497            return Err(invalid_knowledge(
498                "knowledge subject target does not match its schema role",
499            ));
500        }
501        let count = counts.entry(&subject.role).or_default();
502        *count += 1;
503        if !declaration.multiple && *count > 1 {
504            return Err(invalid_knowledge(
505                "knowledge singleton subject role is repeated",
506            ));
507        }
508    }
509    if schema.subjects.iter().any(|declaration| {
510        declaration.required
511            && counts
512                .get(declaration.role.as_str())
513                .copied()
514                .unwrap_or_default()
515                == 0
516    }) {
517        return Err(invalid_knowledge(
518            "knowledge record is missing a required subject role",
519        ));
520    }
521    Ok(())
522}
523
524fn subject_matches(
525    subject: &KnowledgeSubject,
526    schema: &KnowledgeSubjectSchema,
527    current: &RuntimeCurrentState,
528    _record_schemas: &DomainRecordSchemas,
529) -> bool {
530    match &subject.target {
531        KnowledgeSubjectTarget::Event(_) => {
532            schema.targets.contains(&KnowledgeSubjectTargetKind::Event)
533        }
534        KnowledgeSubjectTarget::Entity(entity) => {
535            let exact = entity.core_kind().is_some_and(|kind| {
536                schema
537                    .targets
538                    .contains(&KnowledgeSubjectTargetKind::Core(kind))
539            }) && entity_exists(current, entity);
540            let any = schema
541                .targets
542                .contains(&KnowledgeSubjectTargetKind::AnyEntity)
543                && entity_exists(current, entity);
544            exact || any
545        }
546        KnowledgeSubjectTarget::DomainRecord(reference) => {
547            let exact = schema
548                .targets
549                .contains(&KnowledgeSubjectTargetKind::Domain(reference.kind.clone()));
550            let any = schema
551                .targets
552                .contains(&KnowledgeSubjectTargetKind::AnyEntity)
553                && current.domain_records.get(reference).is_some_and(|record| {
554                    !record.is_deleted() && record.class == DomainRecordClass::Entity
555                });
556            (exact || any) && current.domain_records.contains_key(reference)
557        }
558    }
559}
560
561fn entity_exists(current: &RuntimeCurrentState, entity: &EntityRef) -> bool {
562    match entity {
563        EntityRef::Army(id) => current.entities.contains(entity) || current.armies.contains_key(id),
564        EntityRef::Government(id) => {
565            current.entities.contains(entity) || current.governments.contains_key(id)
566        }
567        EntityRef::Organization(_) | EntityRef::Resource(_) => current.entities.contains(entity),
568        EntityRef::Person(id) => {
569            current.entities.contains(entity) || current.people.contains_key(id)
570        }
571        EntityRef::Domain(reference) => {
572            current.domain_records.get(reference).is_some_and(|record| {
573                !record.is_deleted() && record.class == DomainRecordClass::Entity
574            })
575        }
576        EntityRef::Route(id) => {
577            current.entities.contains(entity) || current.routes.contains_key(id)
578        }
579        EntityRef::Territory(id) => {
580            current.entities.contains(entity) || current.territories.contains_key(id)
581        }
582    }
583}
584
585fn validate_relations(draft: &KnowledgeRecordDraft) -> Result<(), CanwuError> {
586    if !strictly_sorted(&draft.supersedes)
587        || !strictly_sorted(&draft.contradicts)
588        || draft
589            .supersedes
590            .iter()
591            .any(|id| draft.contradicts.contains(id))
592    {
593        return Err(invalid_knowledge(
594            "knowledge supersedes and contradicts relations must be sorted, unique, and disjoint",
595        ));
596    }
597    Ok(())
598}
599
600fn strictly_sorted<T: Ord>(values: &[T]) -> bool {
601    values.windows(2).all(|pair| pair[0] < pair[1])
602}
603
604fn validate_kind(kind: &KnowledgeRecordKind) -> Result<(), CanwuError> {
605    if !canonical_text(&kind.namespace) || !canonical_text(&kind.name) {
606        return Err(invalid_schema("knowledge schema kind must be canonical"));
607    }
608    Ok(())
609}
610
611fn validate_domain_kind(kind: &DomainRecordKind) -> Result<(), CanwuError> {
612    if !canonical_text(&kind.namespace) || !canonical_text(&kind.name) {
613        return Err(invalid_schema(
614            "knowledge subject domain kind must be canonical",
615        ));
616    }
617    Ok(())
618}
619
620fn invalid_schema(message: impl Into<String>) -> CanwuError {
621    CanwuError::new(ErrorCode::InvalidKnowledgeSchema, message)
622}
623
624fn invalid_knowledge(message: impl Into<String>) -> CanwuError {
625    CanwuError::new(ErrorCode::InvalidKnowledgeRecord, message)
626}
627
628#[cfg(test)]
629mod tests {
630    use super::super::records::{DomainRecord, DomainRecordLifecycle, DomainRecordSchema};
631    use super::*;
632    use crate::DecisionState;
633    use crate::runtime::{Government, MapPoint, Route, Territory};
634    use canwu_core::{
635        DomainRecordRef, EventId, EvidenceRef, GovernmentId, KnowledgeRecordId, PersonId,
636        ResourceId, RouteId, TerritoryId,
637    };
638    use canwu_knowledge::{KnowledgeOrigin, KnowledgeSnapshot};
639    use canwu_time::SimTime;
640    use serde_json::json;
641    use std::collections::{BTreeMap, BTreeSet};
642
643    fn knowledge_kind() -> KnowledgeRecordKind {
644        KnowledgeRecordKind::new("fixture.knowledge", "assessment")
645    }
646
647    fn knowledge_schema(subjects: Vec<KnowledgeSubjectSchema>) -> PluginKnowledgeSchema {
648        PluginKnowledgeSchema {
649            id: KnowledgeSchemaId::new(knowledge_kind(), 1),
650            schema_hash: "1000000000000000000000000000000000000000000000000000000000000000"
651                .to_owned(),
652            writable: true,
653            payload_schema: PayloadSchema::Any,
654            subjects,
655        }
656    }
657
658    fn current_state(domain_records: Vec<DomainRecord>) -> RuntimeCurrentState {
659        RuntimeCurrentState {
660            entities: BTreeSet::new(),
661            people: BTreeMap::new(),
662            person_availability: BTreeMap::new(),
663            created_persons: Vec::new(),
664            letters: BTreeMap::new(),
665            governments: BTreeMap::new(),
666            territories: BTreeMap::new(),
667            routes: BTreeMap::new(),
668            armies: BTreeMap::new(),
669            knowledge: KnowledgeSnapshot::default(),
670            plugin_components: BTreeMap::new(),
671            domain_records: PersistentDomainRecordStore::from_records(
672                domain_records
673                    .into_iter()
674                    .map(|record| (record.reference.clone(), record))
675                    .collect(),
676            )
677            .unwrap(),
678            decisions: DecisionState::default(),
679            root_seed: 1,
680            authority_root_seed: 1,
681            random_streams: BTreeMap::new(),
682        }
683    }
684
685    fn domain_record(
686        reference: DomainRecordRef,
687        class: DomainRecordClass,
688        lifecycle: DomainRecordLifecycle,
689    ) -> DomainRecord {
690        DomainRecord {
691            reference,
692            owner: "fixture-domain".to_owned(),
693            class,
694            version: 1,
695            lifecycle,
696            payload: json!(null),
697            references: vec![],
698        }
699    }
700
701    fn holder_schemas(
702        kind: &DomainRecordKind,
703        policy: KnowledgeHolderPolicy,
704    ) -> DomainRecordSchemas {
705        let mut schema = DomainRecordSchema::new(kind.clone(), DomainRecordClass::Entity);
706        schema.holder_policy = policy;
707        BTreeMap::from([(kind.clone(), ("fixture-domain".to_owned(), schema))])
708    }
709
710    fn base_draft() -> KnowledgeRecordDraft {
711        KnowledgeRecordDraft {
712            schema: KnowledgeSchemaId::new(knowledge_kind(), 1),
713            subjects: vec![],
714            payload: json!(null),
715            as_of: None,
716            confidence_per_mille: 1_000,
717            origin: KnowledgeOrigin {
718                method: "fixture".to_owned(),
719                evidence: vec![],
720            },
721            supersedes: vec![],
722            contradicts: vec![],
723        }
724    }
725
726    fn government_current() -> (KnowledgeHolderRef, RuntimeCurrentState) {
727        let government = Government {
728            id: GovernmentId::new(1),
729            name: "Fixture".to_owned(),
730            capital: TerritoryId::new(1),
731        };
732        let mut current = current_state(vec![]);
733        current.governments.insert(government.id, government);
734        (
735            KnowledgeHolderRef::Entity(EntityRef::Government(GovernmentId::new(1))),
736            current,
737        )
738    }
739
740    #[test]
741    fn holder_eligibility_distinguishes_publication_from_retained_history() {
742        let kind = DomainRecordKind::new("fixture.organization", "office");
743        let active_ref = DomainRecordRef::new(&kind.namespace, &kind.name, "active");
744        let retired_ref = DomainRecordRef::new(&kind.namespace, &kind.name, "retired");
745        let deleted_ref = DomainRecordRef::new(&kind.namespace, &kind.name, "deleted");
746        let current = current_state(vec![
747            domain_record(
748                active_ref.clone(),
749                DomainRecordClass::Entity,
750                DomainRecordLifecycle::Active,
751            ),
752            domain_record(
753                retired_ref.clone(),
754                DomainRecordClass::Entity,
755                DomainRecordLifecycle::Retired {
756                    at: SimTime::EPOCH,
757                    successor: Some(active_ref.clone()),
758                },
759            ),
760            domain_record(
761                deleted_ref.clone(),
762                DomainRecordClass::Entity,
763                DomainRecordLifecycle::Deleted { at: SimTime::EPOCH },
764            ),
765        ]);
766        let schemas = holder_schemas(&kind, KnowledgeHolderPolicy::Allowed);
767        let active = KnowledgeHolderRef::Entity(EntityRef::Domain(active_ref));
768        let retired = KnowledgeHolderRef::Entity(EntityRef::Domain(retired_ref));
769        let deleted = KnowledgeHolderRef::Entity(EntityRef::Domain(deleted_ref));
770
771        assert!(validate_holder_for_publication(&active, &current, &schemas).is_ok());
772        assert!(validate_holder_for_publication(&retired, &current, &schemas).is_err());
773        assert!(validate_holder_for_publication(&deleted, &current, &schemas).is_err());
774        assert!(validate_historical_holder(&retired, &current, &schemas).is_ok());
775        assert!(validate_historical_holder(&deleted, &current, &schemas).is_ok());
776
777        let disallowed = holder_schemas(&kind, KnowledgeHolderPolicy::Disallowed);
778        assert!(validate_holder_for_publication(&active, &current, &disallowed).is_err());
779        for holder in [
780            KnowledgeHolderRef::Entity(EntityRef::Person(PersonId::new(1))),
781            KnowledgeHolderRef::Entity(EntityRef::Resource(ResourceId::new(1))),
782            KnowledgeHolderRef::Entity(EntityRef::Route(RouteId::new(1))),
783            KnowledgeHolderRef::Entity(EntityRef::Territory(TerritoryId::new(1))),
784        ] {
785            let error = validate_holder_for_publication(&holder, &current, &schemas)
786                .expect_err("ineligible core holder aliases must be rejected");
787            assert_eq!(error.code, ErrorCode::InvalidKnowledgeHolder);
788        }
789    }
790
791    #[test]
792    fn generic_person_registry_is_an_eligible_person_holder() {
793        let person = PersonId::new(7);
794        let holder = KnowledgeHolderRef::Person(person);
795        let mut current = current_state(vec![]);
796        current.entities.insert(EntityRef::Person(person));
797
798        assert!(validate_holder_for_publication(&holder, &current, &BTreeMap::new()).is_ok());
799        assert!(validate_historical_holder(&holder, &current, &BTreeMap::new()).is_ok());
800    }
801
802    #[test]
803    fn any_entity_accepts_future_entity_kinds_but_rejects_value_records() {
804        let entity_kind = DomainRecordKind::new("future.organization", "institution");
805        let value_kind = DomainRecordKind::new("future.organization", "memo");
806        let entity_ref = DomainRecordRef::new(&entity_kind.namespace, &entity_kind.name, "one");
807        let value_ref = DomainRecordRef::new(&value_kind.namespace, &value_kind.name, "one");
808        let mut current = current_state(vec![
809            domain_record(
810                entity_ref.clone(),
811                DomainRecordClass::Entity,
812                DomainRecordLifecycle::Active,
813            ),
814            domain_record(
815                value_ref.clone(),
816                DomainRecordClass::Record,
817                DomainRecordLifecycle::Active,
818            ),
819        ]);
820        current.territories.insert(
821            TerritoryId::new(1),
822            Territory {
823                id: TerritoryId::new(1),
824                name: "Territory".to_owned(),
825                controller: GovernmentId::new(1),
826                position: MapPoint::default(),
827            },
828        );
829        current.routes.insert(
830            RouteId::new(1),
831            Route {
832                id: RouteId::new(1),
833                name: "Route".to_owned(),
834                from: TerritoryId::new(1),
835                to: TerritoryId::new(2),
836                travel_minutes: 1,
837                terrain: "road".to_owned(),
838            },
839        );
840        let schemas = holder_schemas(&entity_kind, KnowledgeHolderPolicy::Allowed);
841        let schema = knowledge_schema(vec![KnowledgeSubjectSchema {
842            role: "subject".to_owned(),
843            targets: vec![KnowledgeSubjectTargetKind::AnyEntity],
844            required: true,
845            multiple: false,
846        }]);
847        let holder = KnowledgeHolderRef::Entity(EntityRef::Domain(entity_ref.clone()));
848        let mut draft = base_draft();
849        draft.subjects = vec![KnowledgeSubject {
850            role: "subject".to_owned(),
851            target: KnowledgeSubjectTarget::Entity(EntityRef::Domain(entity_ref)),
852        }];
853        assert!(validate_draft(&draft, &schema, &holder, &current, &schemas).is_ok());
854
855        draft.subjects[0].target =
856            KnowledgeSubjectTarget::Entity(EntityRef::Territory(TerritoryId::new(1)));
857        assert!(validate_draft(&draft, &schema, &holder, &current, &schemas).is_ok());
858        draft.subjects[0].target =
859            KnowledgeSubjectTarget::Entity(EntityRef::Route(RouteId::new(1)));
860        assert!(validate_draft(&draft, &schema, &holder, &current, &schemas).is_ok());
861
862        draft.subjects[0].target = KnowledgeSubjectTarget::DomainRecord(value_ref);
863        let error = validate_draft(&draft, &schema, &holder, &current, &schemas)
864            .expect_err("AnyEntity must not accept ordinary domain value records");
865        assert_eq!(error.code, ErrorCode::InvalidKnowledgeRecord);
866
867        let exact_person = knowledge_schema(vec![KnowledgeSubjectSchema {
868            role: "subject".to_owned(),
869            targets: vec![KnowledgeSubjectTargetKind::Core(CoreEntityKind::Person)],
870            required: true,
871            multiple: false,
872        }]);
873        draft.subjects[0].target =
874            KnowledgeSubjectTarget::Entity(EntityRef::Person(PersonId::new(999)));
875        let error = validate_draft(&draft, &exact_person, &holder, &current, &schemas)
876            .expect_err("an exact core target must still name an existing entity");
877        assert_eq!(error.code, ErrorCode::InvalidKnowledgeRecord);
878    }
879
880    #[test]
881    fn schema_set_requires_exact_ownership_and_one_writable_version() {
882        let writable = knowledge_schema(vec![]);
883        let read_only = PluginKnowledgeSchema {
884            id: KnowledgeSchemaId::new(knowledge_kind(), 2),
885            writable: false,
886            ..writable.clone()
887        };
888        let owners = BTreeMap::from([(knowledge_kind(), "fixture".to_owned())]);
889        let schemas = BTreeMap::from([
890            (
891                writable.id.clone(),
892                ("fixture".to_owned(), writable.clone()),
893            ),
894            (
895                read_only.id.clone(),
896                ("fixture".to_owned(), read_only.clone()),
897            ),
898        ]);
899        assert!(validate_schema_set(&schemas, &owners).is_ok());
900
901        let only_read_only =
902            BTreeMap::from([(read_only.id.clone(), ("fixture".to_owned(), read_only))]);
903        assert!(validate_schema_set(&only_read_only, &owners).is_err());
904
905        let second_writable = PluginKnowledgeSchema {
906            id: KnowledgeSchemaId::new(knowledge_kind(), 2),
907            ..writable.clone()
908        };
909        let two_writable = BTreeMap::from([
910            (writable.id.clone(), ("fixture".to_owned(), writable)),
911            (
912                second_writable.id.clone(),
913                ("fixture".to_owned(), second_writable),
914            ),
915        ]);
916        assert!(validate_schema_set(&two_writable, &owners).is_err());
917    }
918
919    #[test]
920    fn per_record_limits_accept_boundary_and_reject_boundary_plus_one() {
921        let (holder, current) = government_current();
922        let schemas = BTreeMap::new();
923        let no_subjects = knowledge_schema(vec![]);
924
925        let mut payload = base_draft();
926        payload.payload =
927            json!("a".repeat(KnowledgeLimitsV1::CURRENT.payload_bytes_per_record - 2));
928        assert!(validate_draft(&payload, &no_subjects, &holder, &current, &schemas).is_ok());
929        payload.payload =
930            json!("a".repeat(KnowledgeLimitsV1::CURRENT.payload_bytes_per_record - 1));
931        assert!(validate_draft(&payload, &no_subjects, &holder, &current, &schemas).is_err());
932
933        let mut method = base_draft();
934        method.origin.method = "m".repeat(KnowledgeLimitsV1::CURRENT.text_bytes);
935        assert!(validate_draft(&method, &no_subjects, &holder, &current, &schemas).is_ok());
936        method.origin.method.push('m');
937        assert!(validate_draft(&method, &no_subjects, &holder, &current, &schemas).is_err());
938
939        let mut evidence = base_draft();
940        evidence.origin.evidence = (1..=KnowledgeLimitsV1::CURRENT.relations_per_record)
941            .map(|id| EvidenceRef::Event(EventId::new(id as u64)))
942            .collect();
943        assert!(validate_draft(&evidence, &no_subjects, &holder, &current, &schemas).is_ok());
944        evidence
945            .origin
946            .evidence
947            .push(EvidenceRef::Event(EventId::new(
948                KnowledgeLimitsV1::CURRENT.relations_per_record as u64 + 1,
949            )));
950        assert!(validate_draft(&evidence, &no_subjects, &holder, &current, &schemas).is_err());
951
952        let mut supersedes = base_draft();
953        supersedes.supersedes = (1..=KnowledgeLimitsV1::CURRENT.relations_per_record)
954            .map(|id| KnowledgeRecordId::new(id as u64))
955            .collect();
956        assert!(validate_draft(&supersedes, &no_subjects, &holder, &current, &schemas).is_ok());
957        supersedes.supersedes.push(KnowledgeRecordId::new(
958            KnowledgeLimitsV1::CURRENT.relations_per_record as u64 + 1,
959        ));
960        assert!(validate_draft(&supersedes, &no_subjects, &holder, &current, &schemas).is_err());
961
962        let mut contradicts = base_draft();
963        contradicts.contradicts = (1..=KnowledgeLimitsV1::CURRENT.relations_per_record)
964            .map(|id| KnowledgeRecordId::new(id as u64))
965            .collect();
966        assert!(validate_draft(&contradicts, &no_subjects, &holder, &current, &schemas).is_ok());
967        contradicts.contradicts.push(KnowledgeRecordId::new(
968            KnowledgeLimitsV1::CURRENT.relations_per_record as u64 + 1,
969        ));
970        assert!(validate_draft(&contradicts, &no_subjects, &holder, &current, &schemas).is_err());
971
972        let event_subject = knowledge_schema(vec![KnowledgeSubjectSchema {
973            role: "subject".to_owned(),
974            targets: vec![KnowledgeSubjectTargetKind::Event],
975            required: true,
976            multiple: true,
977        }]);
978        let mut subjects = base_draft();
979        subjects.subjects = (1..=KnowledgeLimitsV1::CURRENT.relations_per_record)
980            .map(|id| KnowledgeSubject {
981                role: "subject".to_owned(),
982                target: KnowledgeSubjectTarget::Event(EventId::new(id as u64)),
983            })
984            .collect();
985        assert!(validate_draft(&subjects, &event_subject, &holder, &current, &schemas).is_ok());
986        subjects.subjects.push(KnowledgeSubject {
987            role: "subject".to_owned(),
988            target: KnowledgeSubjectTarget::Event(EventId::new(
989                KnowledgeLimitsV1::CURRENT.relations_per_record as u64 + 1,
990            )),
991        });
992        assert!(validate_draft(&subjects, &event_subject, &holder, &current, &schemas).is_err());
993    }
994}