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>
1160 lines
42 KiB
Rust
1160 lines
42 KiB
Rust
//! 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 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<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
|
||
);
|
||
}
|
||
}
|