//! KnowledgeGraph ECS component per D-041. //! //! Per-entity knowledge component. Attached to every entity that has knowledge //! (player character, Active-tier NPCs, Background-tier NPCs). use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use crate::simulation::movement::TilePosition; use super::types::*; /// Per-entity knowledge component. THE core data structure. /// BTreeMap for deterministic iteration (D-010 principle 4). #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct KnowledgeGraph { /// What this entity knows about other entities. /// Key: StableId of the known entity. pub entities: BTreeMap, /// Non-entity facts this entity knows. /// Key: FactId in "category.topic" format. pub facts: BTreeMap, } impl KnowledgeGraph { pub fn new() -> Self { Self { entities: BTreeMap::new(), facts: BTreeMap::new(), } } /// Construct with starting facts (character background per D-013). pub fn with_background(facts: Vec<(FactId, FactKnowledge)>) -> Self { Self { entities: BTreeMap::new(), facts: facts.into_iter().collect(), } } // --- Read Queries --- /// Does this entity know about another entity at all? pub fn knows_entity(&self, id: &StableId) -> bool { self.entities.contains_key(id) } /// What confidence level for a known entity? pub fn confidence_of(&self, id: &StableId) -> Option { self.entities.get(id).map(|k| k.confidence) } /// What is the relationship state with a known entity? /// Returns Unknown for entities not in the graph. pub fn relationship_with(&self, id: &StableId) -> RelationshipState { self.entities .get(id) .map(|k| k.relationship) .unwrap_or(RelationshipState::Unknown) } /// Does this entity know a specific fact? pub fn knows_fact(&self, id: &FactId) -> bool { self.facts.contains_key(id) } /// Is fact confidence at or above a threshold? /// This is the monologue prerequisite check (D-035 `prerequisite` tag). pub fn fact_at_least(&self, id: &FactId, min: KnowledgeConfidence) -> bool { self.facts .get(id) .map(|f| f.confidence >= min) .unwrap_or(false) } /// Get entity knowledge entry (read-only). pub fn entity_knowledge(&self, id: &StableId) -> Option<&EntityKnowledge> { self.entities.get(id) } /// Iterate all known entities (deterministic order via BTreeMap). pub fn known_entities_iter(&self) -> impl Iterator { self.entities.iter() } /// Number of known entities. pub fn entity_count(&self) -> usize { self.entities.len() } /// Number of known facts. pub fn fact_count(&self) -> usize { self.facts.len() } /// Whether the knowledge graph has no entries at all. pub fn is_empty(&self) -> bool { self.entities.is_empty() && self.facts.is_empty() } /// Iterate all known facts (deterministic order via BTreeMap). pub fn known_facts_iter(&self) -> impl Iterator { self.facts.iter() } // --- Write Operations --- /// Record a direct observation of another entity (entity is in LOS). /// /// Returns `Some(ContradictionClaim)` if a position contradiction was /// detected against a recent `ToldBy` source (D-083). The caller should /// push a `ContradictionDetected` event when this returns `Some`. pub fn observe_entity( &mut self, target: StableId, position: TilePosition, tick: u64, ) -> Option { // --- Pre-overwrite contradiction check (D-083) --- // // If the existing entry has a ToldBy source with a different position, // and the told-tick is within CONTRADICTION_WINDOW_TICKS of now, // this is a contradiction: someone lied or was wrong about where // this entity would be. let contradiction = self.entities.get(&target).and_then(|existing| { if let KnowledgeSource::ToldBy { source_id, tick: told_tick, } = &existing.source { let age = tick.saturating_sub(*told_tick); let claimed_pos = existing.last_known_position?; if age <= CONTRADICTION_WINDOW_TICKS && claimed_pos != position { Some(ContradictionClaim { told_by: *source_id, told_tick: *told_tick, claimed_position: claimed_pos, observed_position: position, detected_tick: tick, }) } else { None } } else { None } }); let entry = self .entities .entry(target) .or_insert_with(|| EntityKnowledge { last_known_position: None, last_observed_tick: 0, last_updated_tick: 0, confidence: KnowledgeConfidence::Direct, source: KnowledgeSource::DirectObservation { tick }, state: KnowledgeState::Active, relationship: RelationshipState::Unknown, known_attributes: BTreeMap::new(), contradicted_claim: None, }); entry.last_known_position = Some(position); entry.last_observed_tick = tick; entry.last_updated_tick = tick; entry.confidence = KnowledgeConfidence::Direct; entry.source = KnowledgeSource::DirectObservation { tick }; if contradiction.is_some() { entry.state = KnowledgeState::Contradicted; entry.contradicted_claim = contradiction.clone(); } else if entry.state == KnowledgeState::Stale { // Stale entries become Active again on fresh observation. entry.state = KnowledgeState::Active; } // Contradicted entries without a new contradiction stay Contradicted — // the previous contradiction is still unresolved. contradiction } /// Entity has left the observer's LOS. Downgrade from Direct. pub fn observe_entity_leaving_los(&mut self, target: &StableId, tick: u64) { if let Some(entry) = self.entities.get_mut(target) { if entry.confidence == KnowledgeConfidence::Direct { entry.confidence = KnowledgeConfidence::KnowsDetails; entry.last_updated_tick = tick; } } } /// Record an incomplete interaction with an entity (D-064 walk-away). /// /// Appends to known_attributes["incomplete_interactions"] as a /// comma-separated list of "tick:type" entries. Creates the entity /// entry if it doesn't exist (at Suspects confidence). pub fn record_incomplete_interaction( &mut self, target: &StableId, interaction_type: super::events::InteractionType, tick: u64, ) { let entry = self .entities .entry(*target) .or_insert_with(|| EntityKnowledge { last_known_position: None, last_observed_tick: 0, last_updated_tick: 0, confidence: KnowledgeConfidence::Suspects, source: KnowledgeSource::DirectObservation { tick }, state: KnowledgeState::Active, relationship: RelationshipState::Unknown, known_attributes: BTreeMap::new(), contradicted_claim: None, }); let type_str = match interaction_type { super::events::InteractionType::Talk => "talk", super::events::InteractionType::Confront => "confront", }; let record = format!("{}:{}", tick, type_str); entry .known_attributes .entry("incomplete_interactions".to_string()) .and_modify(|v| { v.push(','); v.push_str(&record); }) .or_insert(record); entry.last_updated_tick = tick; } /// Check if the observer has any incomplete interactions with an entity. /// /// Returns true if known_attributes["incomplete_interactions"] exists /// and is non-empty. Used by dialogue/monologue systems to gate /// post-conversation reactions (D-064 phase 3). pub fn has_incomplete_interaction(&self, target: &StableId) -> bool { self.entities .get(target) .and_then(|e| e.known_attributes.get("incomplete_interactions")) .is_some_and(|v| !v.is_empty()) } /// Set relationship state for an entity. pub fn set_relationship(&mut self, target: &StableId, state: RelationshipState) { if let Some(entry) = self.entities.get_mut(target) { entry.relationship = state; } } /// Run knowledge decay pass. Called periodically (once per game-minute). pub fn decay(&mut self, current_tick: u64, thresholds: &DecayThresholds) { for (_id, knowledge) in self.entities.iter_mut() { // Direct confidence is managed by perception system, not decay. if knowledge.confidence == KnowledgeConfidence::Direct { continue; } let age = current_tick.saturating_sub(knowledge.last_observed_tick); if age > thresholds.stale_after { knowledge.state = KnowledgeState::Stale; } else if age > thresholds.decay_after { knowledge.confidence = knowledge.confidence.decayed(); knowledge.last_updated_tick = current_tick; } } } } impl Default for KnowledgeGraph { fn default() -> Self { Self::new() } } // --- Access control filter (#139, D-010 principle 2) --- /// Component-level access control filter. /// /// Called by the observer snapshot builder before including a component's /// sensitive data in the snapshot. Returns `true` if `observer_id` is /// permitted to read a component tagged with `rule` on entity `target_id`. /// /// Design: coarse-grained component-level check. A component either passes /// or fails as a whole. See `ObserverAccess` for available rules. /// /// # Arguments /// - `observer_id`: StableId of the entity requesting access. /// - `target_id`: StableId of the entity that owns the component. /// - `rule`: The access rule attached to the component via `AccessRule`. /// - `kg`: The observer's `KnowledgeGraph` (used for relationship and knowledge checks). pub fn filter_by_access( observer_id: StableId, target_id: StableId, rule: &ObserverAccess, kg: &KnowledgeGraph, ) -> bool { match rule { // Public data is always readable. ObserverAccess::Public => true, // OwnerOnly: only the entity that owns the component can read it. // Primary use case: player's own inventory (D-065). ObserverAccess::OwnerOnly => observer_id == target_id, // FactionOnly: observer must have a recorded faction match with the target. // Stored as a "faction_id" key in the target's known_attributes. // Full faction system deferred; approximation via knowledge attributes. ObserverAccess::FactionOnly(faction_id) => kg .entities .get(&target_id) .and_then(|k| k.known_attributes.get("faction_id")) .and_then(|v| match v.parse::() { Ok(id) => Some(id), Err(_) => { tracing::warn!( target_id = target_id.0, value = %v, "FactionOnly: non-numeric faction_id attribute, denying access" ); None } }) .is_some_and(|id| id == faction_id.0), // RelationshipGated: observer must have a relationship score >= threshold. // Threshold is 0–100; maps to RelationshipState enum values. ObserverAccess::RelationshipGated(threshold) => { let score: i32 = match kg.relationship_with(&target_id) { RelationshipState::Unknown => 0, RelationshipState::Known => 25, RelationshipState::PersonOfInterest => 40, RelationshipState::Friendly => 75, RelationshipState::Hostile => 5, }; score >= *threshold } // KnowledgeGated: observer must have a specific fact in their knowledge graph. // Used for "you only see this if you know about it" information walls. ObserverAccess::KnowledgeGated(flag) => { let fact_id = FactId(flag.clone()); kg.knows_fact(&fact_id) } } } #[cfg(test)] mod tests { use super::*; fn make_position(x: i32, y: i32) -> TilePosition { TilePosition::new(x, y, 0) } #[test] fn new_graph_is_empty() { let g = KnowledgeGraph::new(); assert_eq!(g.entity_count(), 0); assert_eq!(g.fact_count(), 0); } #[test] fn observe_entity_creates_direct_entry() { let mut g = KnowledgeGraph::new(); let target = StableId(1); g.observe_entity(target, make_position(5, 10), 100); assert!(g.knows_entity(&target)); assert_eq!(g.confidence_of(&target), Some(KnowledgeConfidence::Direct)); let entry = g.entity_knowledge(&target).unwrap(); assert_eq!(entry.last_known_position, Some(make_position(5, 10))); assert_eq!(entry.last_observed_tick, 100); } #[test] fn leaving_los_downgrades_to_knows_details() { let mut g = KnowledgeGraph::new(); let target = StableId(1); g.observe_entity(target, make_position(5, 10), 100); g.observe_entity_leaving_los(&target, 110); assert_eq!( g.confidence_of(&target), Some(KnowledgeConfidence::KnowsDetails) ); let entry = g.entity_knowledge(&target).unwrap(); assert_eq!(entry.last_updated_tick, 110); } #[test] fn leaving_los_no_op_if_not_direct() { let mut g = KnowledgeGraph::new(); let target = StableId(1); g.observe_entity(target, make_position(5, 10), 100); g.observe_entity_leaving_los(&target, 110); // Now at KnowsDetails — leaving LOS again should not downgrade further g.observe_entity_leaving_los(&target, 120); assert_eq!( g.confidence_of(&target), Some(KnowledgeConfidence::KnowsDetails) ); } #[test] fn relationship_default_is_unknown() { let g = KnowledgeGraph::new(); assert_eq!( g.relationship_with(&StableId(999)), RelationshipState::Unknown ); } #[test] fn set_relationship_updates_entry() { let mut g = KnowledgeGraph::new(); let target = StableId(1); g.observe_entity(target, make_position(5, 10), 100); g.set_relationship(&target, RelationshipState::Hostile); assert_eq!(g.relationship_with(&target), RelationshipState::Hostile); } #[test] fn fact_operations() { let fact_id = FactId("contraband.ring_exists".to_string()); let fact = FactKnowledge { confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }; let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]); assert!(g.knows_fact(&fact_id)); assert!(g.fact_at_least(&fact_id, KnowledgeConfidence::Suspects)); assert!(g.fact_at_least(&fact_id, KnowledgeConfidence::KnowsOf)); assert!(!g.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails)); } #[test] fn confidence_ordering() { assert!(KnowledgeConfidence::Suspects < KnowledgeConfidence::KnowsOf); assert!(KnowledgeConfidence::KnowsOf < KnowledgeConfidence::KnowsDetails); assert!(KnowledgeConfidence::KnowsDetails < KnowledgeConfidence::Direct); } #[test] fn confidence_decay_chain() { assert_eq!( KnowledgeConfidence::Direct.decayed(), KnowledgeConfidence::KnowsDetails ); assert_eq!( KnowledgeConfidence::KnowsDetails.decayed(), KnowledgeConfidence::KnowsOf ); assert_eq!( KnowledgeConfidence::KnowsOf.decayed(), KnowledgeConfidence::Suspects ); assert_eq!( KnowledgeConfidence::Suspects.decayed(), KnowledgeConfidence::Suspects ); } #[test] fn decay_skips_direct_confidence() { let mut g = KnowledgeGraph::new(); let target = StableId(1); g.observe_entity(target, make_position(5, 10), 100); let thresholds = DecayThresholds { decay_after: 10, stale_after: 100, }; g.decay(200, &thresholds); // Direct entries are managed by perception, not decay assert_eq!(g.confidence_of(&target), Some(KnowledgeConfidence::Direct)); } #[test] fn decay_downgrades_non_direct() { let mut g = KnowledgeGraph::new(); let target = StableId(1); g.observe_entity(target, make_position(5, 10), 100); g.observe_entity_leaving_los(&target, 110); // Now KnowsDetails let thresholds = DecayThresholds { decay_after: 10, stale_after: 1000, }; // Age = 200 - 100 = 100, which is > decay_after (10) g.decay(200, &thresholds); assert_eq!(g.confidence_of(&target), Some(KnowledgeConfidence::KnowsOf)); } #[test] fn decay_marks_stale_when_very_old() { let mut g = KnowledgeGraph::new(); let target = StableId(1); g.observe_entity(target, make_position(5, 10), 100); g.observe_entity_leaving_los(&target, 110); let thresholds = DecayThresholds { decay_after: 10, stale_after: 50, }; // Age = 200 - 100 = 100, which is > stale_after (50) g.decay(200, &thresholds); let entry = g.entity_knowledge(&target).unwrap(); assert_eq!(entry.state, KnowledgeState::Stale); } #[test] fn observe_preserves_contradicted_state() { let mut g = KnowledgeGraph::new(); let target = StableId(1); g.observe_entity(target, make_position(5, 10), 100); // Manually set contradicted (would be done by contradiction detector in Sprint 3) g.entities.get_mut(&target).unwrap().state = KnowledgeState::Contradicted; // Re-observe — state should remain Contradicted g.observe_entity(target, make_position(6, 10), 200); let entry = g.entity_knowledge(&target).unwrap(); assert_eq!(entry.state, KnowledgeState::Contradicted); assert_eq!(entry.confidence, KnowledgeConfidence::Direct); assert_eq!(entry.last_known_position, Some(make_position(6, 10))); } #[test] fn observe_resets_stale_to_active() { let mut g = KnowledgeGraph::new(); let target = StableId(1); g.observe_entity(target, make_position(5, 10), 100); // Mark as stale (would be done by decay system) g.entities.get_mut(&target).unwrap().state = KnowledgeState::Stale; // Re-observe — stale should reset to Active g.observe_entity(target, make_position(6, 10), 200); let entry = g.entity_knowledge(&target).unwrap(); assert_eq!(entry.state, KnowledgeState::Active); assert_eq!(entry.confidence, KnowledgeConfidence::Direct); } #[test] fn with_background_creates_facts() { let facts = vec![ ( FactId("location.restricted".to_string()), FactKnowledge { confidence: KnowledgeConfidence::KnowsDetails, source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }, ), ( FactId("contraband.exists".to_string()), FactKnowledge { confidence: KnowledgeConfidence::Suspects, source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }, ), ]; let g = KnowledgeGraph::with_background(facts); assert_eq!(g.fact_count(), 2); assert_eq!(g.entity_count(), 0); } #[test] fn known_entities_iter_deterministic() { let mut g = KnowledgeGraph::new(); // Insert in reverse order — BTreeMap should iterate in sorted order for id in (0..5).rev() { g.observe_entity(StableId(id), make_position(id as i32, 0), 100); } let ids: Vec = g.known_entities_iter().map(|(id, _)| id.0).collect(); assert_eq!(ids, vec![0, 1, 2, 3, 4]); } // --- filter_by_access tests (#139, D-010 principle 2) --- // // Sprint 12 test focus: "negative tests — blocked component not returned // for non-owner observer". These tests verify each ObserverAccess variant // and confirm the critical negative case: OwnerOnly blocks non-owner. #[test] fn filter_by_access_public_always_passes() { let observer = StableId(1); let target = StableId(2); let kg = KnowledgeGraph::new(); assert!( filter_by_access(observer, target, &ObserverAccess::Public, &kg), "Public access rule must always return true" ); // Public is symmetric — even self-observation passes assert!(filter_by_access(observer, observer, &ObserverAccess::Public, &kg)); } #[test] fn filter_by_access_owner_only_blocks_non_owner() { // THE critical negative test (Sprint 12 joint briefing). // A non-owner observer must NOT get access to OwnerOnly data. let observer = StableId(1); // some other entity let target = StableId(2); // owns the component let kg = KnowledgeGraph::new(); assert!( !filter_by_access(observer, target, &ObserverAccess::OwnerOnly, &kg), "OwnerOnly must block a non-owner observer" ); } #[test] fn filter_by_access_owner_only_allows_owner() { // The entity observing its own component must be allowed. let owner = StableId(5); let kg = KnowledgeGraph::new(); assert!( filter_by_access(owner, owner, &ObserverAccess::OwnerOnly, &kg), "OwnerOnly must allow the owner to read their own component" ); } #[test] fn filter_by_access_owner_only_distinct_ids_always_block() { // Additional negative: even adjacent IDs are different owners. let kg = KnowledgeGraph::new(); for id in 1u64..=10 { assert!( !filter_by_access(StableId(id), StableId(id + 1), &ObserverAccess::OwnerOnly, &kg), "StableId({id}) should not match StableId({})", id + 1 ); } } #[test] fn filter_by_access_knowledge_gated_blocks_without_knowledge() { let observer = StableId(1); let target = StableId(2); let kg = KnowledgeGraph::new(); // empty — no facts known let rule = ObserverAccess::KnowledgeGated("contraband.ring_exists".to_string()); assert!( !filter_by_access(observer, target, &rule, &kg), "KnowledgeGated must block when observer lacks the required fact" ); } #[test] fn filter_by_access_knowledge_gated_passes_with_knowledge() { let observer = StableId(1); let target = StableId(2); let flag = "contraband.ring_exists"; let kg = KnowledgeGraph::with_background(vec![( FactId(flag.to_string()), FactKnowledge { confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }, )]); let rule = ObserverAccess::KnowledgeGated(flag.to_string()); assert!( filter_by_access(observer, target, &rule, &kg), "KnowledgeGated must pass when observer has the required fact" ); } #[test] fn filter_by_access_knowledge_gated_wrong_flag_blocks() { let observer = StableId(1); let target = StableId(2); let kg = KnowledgeGraph::with_background(vec![( FactId("contraband.ring_exists".to_string()), FactKnowledge { confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::Background, state: KnowledgeState::Active, acquired_tick: 0, disclosure_blocked: false, }, )]); // Gated on a DIFFERENT flag — observer doesn't have this one let rule = ObserverAccess::KnowledgeGated("conspiracy.mastermind".to_string()); assert!( !filter_by_access(observer, target, &rule, &kg), "KnowledgeGated must block when observer has a different fact, not this one" ); } #[test] fn filter_by_access_relationship_gated_blocks_unknown() { let observer = StableId(1); let target = StableId(2); let kg = KnowledgeGraph::new(); // observer has no knowledge of target // Threshold 25 = Known level — Unknown (score=0) should fail let rule = ObserverAccess::RelationshipGated(25); assert!( !filter_by_access(observer, target, &rule, &kg), "RelationshipGated must block when observer's relationship is Unknown (score 0)" ); } #[test] fn filter_by_access_relationship_gated_passes_for_friendly() { let observer = StableId(1); let target = StableId(2); let mut kg = KnowledgeGraph::new(); kg.observe_entity(target, make_position(5, 5), 100); kg.set_relationship(&target, RelationshipState::Friendly); // Threshold 50 — Friendly (score=75) should pass let rule = ObserverAccess::RelationshipGated(50); assert!( filter_by_access(observer, target, &rule, &kg), "RelationshipGated must pass when observer has Friendly relationship (score 75 >= 50)" ); } #[test] fn filter_by_access_relationship_gated_blocks_hostile() { let observer = StableId(1); let target = StableId(2); let mut kg = KnowledgeGraph::new(); kg.observe_entity(target, make_position(5, 5), 100); kg.set_relationship(&target, RelationshipState::Hostile); // Threshold 25 — Hostile (score=5) should fail let rule = ObserverAccess::RelationshipGated(25); assert!( !filter_by_access(observer, target, &rule, &kg), "RelationshipGated must block Hostile relationship (score 5 < threshold 25)" ); } #[test] fn filter_by_access_faction_only_blocks_without_faction_attribute() { let observer = StableId(1); let target = StableId(2); let kg = KnowledgeGraph::new(); // no knowledge of target let faction = StableId(99); let rule = ObserverAccess::FactionOnly(faction); assert!( !filter_by_access(observer, target, &rule, &kg), "FactionOnly must block when faction attribute is not known" ); } #[test] fn filter_by_access_faction_only_passes_with_matching_faction() { let observer = StableId(1); let target = StableId(2); let mut kg = KnowledgeGraph::new(); // Observer knows target's faction via known_attributes kg.observe_entity(target, make_position(5, 5), 10); kg.entities .get_mut(&target) .unwrap() .known_attributes .insert("faction_id".into(), "99".into()); let faction = StableId(99); let rule = ObserverAccess::FactionOnly(faction); assert!( filter_by_access(observer, target, &rule, &kg), "FactionOnly must pass when observer knows the matching faction_id" ); } // --- Contradiction detection tests (D-083, #547) --- #[test] fn contradiction_detected_when_told_by_position_differs() { let mut g = KnowledgeGraph::new(); let target = StableId(1); let informant = StableId(99); // Someone told us the target is at (10, 10) at tick 100 g.entities.insert( target, EntityKnowledge { last_known_position: Some(make_position(10, 10)), last_observed_tick: 0, last_updated_tick: 100, confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::ToldBy { source_id: informant, tick: 100, }, state: KnowledgeState::Active, relationship: RelationshipState::Known, known_attributes: BTreeMap::new(), contradicted_claim: None, }, ); // Direct observation at (15, 10) at tick 200 — within window (600 ticks) let result = g.observe_entity(target, make_position(15, 10), 200); // Contradiction should be detected assert!(result.is_some(), "Should detect contradiction"); let claim = result.unwrap(); assert_eq!(claim.told_by, informant); assert_eq!(claim.told_tick, 100); assert_eq!(claim.claimed_position, make_position(10, 10)); assert_eq!(claim.observed_position, make_position(15, 10)); assert_eq!(claim.detected_tick, 200); // Entry should be Contradicted let entry = g.entity_knowledge(&target).unwrap(); assert_eq!(entry.state, KnowledgeState::Contradicted); assert!(entry.contradicted_claim.is_some()); // But confidence is upgraded to Direct (we're looking at them) assert_eq!(entry.confidence, KnowledgeConfidence::Direct); } #[test] fn no_contradiction_when_told_by_position_matches() { let mut g = KnowledgeGraph::new(); let target = StableId(1); let informant = StableId(99); // Told target is at (10, 10) g.entities.insert( target, EntityKnowledge { last_known_position: Some(make_position(10, 10)), last_observed_tick: 0, last_updated_tick: 100, confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::ToldBy { source_id: informant, tick: 100, }, state: KnowledgeState::Active, relationship: RelationshipState::Known, known_attributes: BTreeMap::new(), contradicted_claim: None, }, ); // Observe at SAME position — no contradiction let result = g.observe_entity(target, make_position(10, 10), 200); assert!(result.is_none(), "Same position should not be a contradiction"); let entry = g.entity_knowledge(&target).unwrap(); assert_eq!(entry.state, KnowledgeState::Active); assert!(entry.contradicted_claim.is_none()); } #[test] fn no_contradiction_outside_time_window() { let mut g = KnowledgeGraph::new(); let target = StableId(1); let informant = StableId(99); // Told at tick 100, position (10, 10) g.entities.insert( target, EntityKnowledge { last_known_position: Some(make_position(10, 10)), last_observed_tick: 0, last_updated_tick: 100, confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::ToldBy { source_id: informant, tick: 100, }, state: KnowledgeState::Active, relationship: RelationshipState::Known, known_attributes: BTreeMap::new(), contradicted_claim: None, }, ); // Observe at different position BUT outside window (100 + 601 = 701) let result = g.observe_entity(target, make_position(15, 10), 701); assert!( result.is_none(), "Outside CONTRADICTION_WINDOW_TICKS should not trigger contradiction" ); } #[test] fn contradiction_at_exact_window_boundary() { let mut g = KnowledgeGraph::new(); let target = StableId(1); let informant = StableId(99); g.entities.insert( target, EntityKnowledge { last_known_position: Some(make_position(10, 10)), last_observed_tick: 0, last_updated_tick: 100, confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::ToldBy { source_id: informant, tick: 100, }, state: KnowledgeState::Active, relationship: RelationshipState::Known, known_attributes: BTreeMap::new(), contradicted_claim: None, }, ); // Exactly at window boundary: 100 + 600 = 700 (age == CONTRADICTION_WINDOW_TICKS) let result = g.observe_entity(target, make_position(15, 10), 700); assert!( result.is_some(), "Exactly at window boundary (age == 600) should still detect contradiction" ); } #[test] fn no_contradiction_for_direct_observation_source() { let mut g = KnowledgeGraph::new(); let target = StableId(1); // Previous knowledge from DirectObservation (not ToldBy) g.observe_entity(target, make_position(10, 10), 100); // New observation at different position — NOT a contradiction // (we just moved, or they moved; no one lied) let result = g.observe_entity(target, make_position(15, 10), 200); assert!( result.is_none(), "DirectObservation source should never trigger contradiction" ); let entry = g.entity_knowledge(&target).unwrap(); assert_eq!(entry.state, KnowledgeState::Active); } #[test] fn no_contradiction_for_background_source() { let mut g = KnowledgeGraph::new(); let target = StableId(1); // Background knowledge with a position g.entities.insert( target, EntityKnowledge { last_known_position: Some(make_position(10, 10)), last_observed_tick: 0, last_updated_tick: 0, confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::Background, state: KnowledgeState::Active, relationship: RelationshipState::Known, known_attributes: BTreeMap::new(), contradicted_claim: None, }, ); let result = g.observe_entity(target, make_position(15, 10), 100); assert!( result.is_none(), "Background source should not trigger contradiction" ); } #[test] fn no_contradiction_when_told_by_has_no_position() { let mut g = KnowledgeGraph::new(); let target = StableId(1); let informant = StableId(99); // ToldBy but no position was claimed g.entities.insert( target, EntityKnowledge { last_known_position: None, // no position claimed last_observed_tick: 0, last_updated_tick: 100, confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::ToldBy { source_id: informant, tick: 100, }, state: KnowledgeState::Active, relationship: RelationshipState::Known, known_attributes: BTreeMap::new(), contradicted_claim: None, }, ); let result = g.observe_entity(target, make_position(15, 10), 200); assert!( result.is_none(), "ToldBy without position should not trigger contradiction" ); } #[test] fn contradicted_claim_entry_field_matches_returned_claim() { // Verify that entry.contradicted_claim is populated with identical // data to the ContradictionClaim returned by observe_entity (D-083). let mut g = KnowledgeGraph::new(); let target = StableId(1); let informant = StableId(42); g.entities.insert( target, EntityKnowledge { last_known_position: Some(make_position(5, 5)), last_observed_tick: 0, last_updated_tick: 50, confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::ToldBy { source_id: informant, tick: 50, }, state: KnowledgeState::Active, relationship: RelationshipState::Known, known_attributes: BTreeMap::new(), contradicted_claim: None, }, ); let returned = g .observe_entity(target, make_position(12, 5), 300) .expect("contradiction should be detected"); let entry = g.entity_knowledge(&target).unwrap(); let stored = entry.contradicted_claim.as_ref().expect("field should be populated"); assert_eq!(stored.told_by, returned.told_by); assert_eq!(stored.told_tick, returned.told_tick); assert_eq!(stored.claimed_position, returned.claimed_position); assert_eq!(stored.observed_position, returned.observed_position); assert_eq!(stored.detected_tick, returned.detected_tick); } #[test] fn second_observation_keeps_contradicted_state_when_no_new_told_by() { // After a contradiction is detected, subsequent DirectObservation // does NOT clear the Contradicted state (D-083: "unresolved"). let mut g = KnowledgeGraph::new(); let target = StableId(7); let informant = StableId(8); // Set up ToldBy knowledge g.entities.insert( target, EntityKnowledge { last_known_position: Some(make_position(3, 3)), last_observed_tick: 0, last_updated_tick: 10, confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::ToldBy { source_id: informant, tick: 10, }, state: KnowledgeState::Active, relationship: RelationshipState::Known, known_attributes: BTreeMap::new(), contradicted_claim: None, }, ); // First observation: contradiction detected let claim = g.observe_entity(target, make_position(9, 3), 200); assert!(claim.is_some(), "contradiction should fire"); assert_eq!( g.entity_knowledge(&target).unwrap().state, KnowledgeState::Contradicted ); // Second observation (now source is DirectObservation, different position): // state must stay Contradicted — contradiction is still unresolved. let claim2 = g.observe_entity(target, make_position(11, 3), 300); assert!(claim2.is_none(), "no new contradiction: DirectObservation source"); assert_eq!( g.entity_knowledge(&target).unwrap().state, KnowledgeState::Contradicted, "Contradicted state must persist until explicitly resolved" ); } #[test] fn multiple_entities_only_told_by_one_contradicts() { // Edge case: observer knows two entities. // Entity A has ToldBy source, Entity B has DirectObservation. // Only Entity A should produce a contradiction. let mut g = KnowledgeGraph::new(); let entity_a = StableId(10); let entity_b = StableId(20); let informant = StableId(99); // Entity A: ToldBy with position g.entities.insert( entity_a, EntityKnowledge { last_known_position: Some(make_position(1, 1)), last_observed_tick: 0, last_updated_tick: 100, confidence: KnowledgeConfidence::KnowsOf, source: KnowledgeSource::ToldBy { source_id: informant, tick: 100, }, state: KnowledgeState::Active, relationship: RelationshipState::Known, known_attributes: BTreeMap::new(), contradicted_claim: None, }, ); // Entity B: DirectObservation (no informant to lie) g.observe_entity(entity_b, make_position(5, 5), 100); // Observe both at different positions at tick 200 let a_result = g.observe_entity(entity_a, make_position(8, 1), 200); let b_result = g.observe_entity(entity_b, make_position(9, 5), 200); assert!(a_result.is_some(), "Entity A (ToldBy source) should contradict"); assert!(b_result.is_none(), "Entity B (DirectObservation) should not contradict"); assert_eq!( g.entity_knowledge(&entity_a).unwrap().state, KnowledgeState::Contradicted ); assert_eq!( g.entity_knowledge(&entity_b).unwrap().state, KnowledgeState::Active ); } }