Files
settled-reach/server/src/knowledge/graph.rs
T
jpmschweitzerandClaude Opus 4.6 3d636fd4bb feat(simulation): knowledge grant schema, events, and contradiction detection (#545, #546, #547)
KnowledgeGrant untagged enum (Fact + Entity variants), ContentEntityRegistry
resource, KnowledgeGranted event processing, ContradictionClaim struct with
600-tick window detection in observe_entity. Wires knowledge_grant field in
dialogue line selection. Implements D-079, D-083. Closes Q-026.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:14:53 +01:00

1160 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<StableId, EntityKnowledge>,
/// Non-entity facts this entity knows.
/// Key: FactId in "category.topic" format.
pub facts: BTreeMap<FactId, FactKnowledge>,
}
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<KnowledgeConfidence> {
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<Item = (&StableId, &EntityKnowledge)> {
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<Item = (&FactId, &FactKnowledge)> {
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<ContradictionClaim> {
// --- 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::<u64>() {
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 0100; 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<u64> = 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
);
}
}