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>
This commit is contained in:
2026-02-24 12:14:53 +01:00
co-authored by Claude Opus 4.6
parent 381526ff74
commit 3d636fd4bb
13 changed files with 1121 additions and 12 deletions
+390 -1
View File
@@ -4,6 +4,7 @@
//! KnowledgeEvents; the processing system drains them per tick.
use bevy_ecs::prelude::*;
use std::collections::BTreeMap;
use crate::simulation::movement::TilePosition;
@@ -11,6 +12,41 @@ use super::graph::KnowledgeGraph;
use super::registry::EntityRegistry;
use super::types::*;
// ---------------------------------------------------------------------------
// Processed knowledge grant types (D-079)
// ---------------------------------------------------------------------------
/// Processed Fact grant — confidence string parsed to typed enum at creation time.
/// Used in `KnowledgeEventType::KnowledgeGranted`.
#[derive(Debug, Clone)]
pub struct ProcessedFactGrant {
pub fact_id: FactId,
pub confidence: KnowledgeConfidence,
}
/// Processed Entity grant — entity_ref resolved to StableId at creation time.
/// Used in `KnowledgeEventType::KnowledgeGranted`.
///
/// Creates an `EntityKnowledge` entry in the observer's KG with `ToldBy` source,
/// enabling contradiction detection when a subsequent `DirectObservation` disagrees.
#[derive(Debug, Clone)]
pub struct ProcessedEntityGrant {
pub target_id: StableId,
pub attributes: BTreeMap<String, String>,
pub confidence: KnowledgeConfidence,
}
/// Typed knowledge grant payload — all string fields resolved at event creation.
#[derive(Debug, Clone)]
pub enum ProcessedKnowledgeGrant {
Fact(ProcessedFactGrant),
Entity(ProcessedEntityGrant),
}
// ---------------------------------------------------------------------------
// Knowledge event types
// ---------------------------------------------------------------------------
/// Events that modify knowledge graphs. Produced by perception and
/// other systems. Consumed by the knowledge update system.
#[derive(Debug, Clone)]
@@ -36,6 +72,16 @@ pub enum KnowledgeEventType {
target: Entity,
interaction_type: InteractionType,
},
/// Knowledge granted to observer via dialogue line selection (D-079).
///
/// Fires at line selection time in `process_talk_interaction`.
/// Source is `ToldBy { source_id, tick }` for NPC testimony.
/// For Fact grants, the granting NPC's KG must contain the fact (guardrail enforced
/// at event creation time — event is only pushed if guardrail passes).
KnowledgeGranted {
grant: ProcessedKnowledgeGrant,
source: KnowledgeSource,
},
}
/// Type of interaction for walk-away recording (D-064).
@@ -57,6 +103,55 @@ pub struct KnowledgeEventQueue {
pub(crate) events: Vec<KnowledgeEvent>,
}
// ---------------------------------------------------------------------------
// Contradiction detection output (D-083)
// ---------------------------------------------------------------------------
/// Event emitted when `observe_entity()` detects a position contradiction
/// between a `ToldBy` source and a `DirectObservation`.
///
/// Consumed by the monologue system (D-083 → monologue trigger) and
/// potentially the storyteller. One event per detected contradiction per tick.
///
/// Display names are pre-resolved by `process_knowledge_events` via EntityRegistry
/// and NpcName, so downstream consumers (monologue) are pure string consumers.
#[derive(Debug, Clone)]
pub struct ContradictionDetectedEvent {
/// The observer who detected the contradiction.
pub observer: Entity,
/// The entity whose position was contradicted.
pub target: StableId,
/// Full contradiction details (who told what, where observed, when).
pub claim: ContradictionClaim,
/// Pre-resolved display name of the NPC who told the false position (told_by source).
pub source_display_name: String,
/// Pre-resolved display name of the entity whose position was contradicted (target).
pub subject_display_name: String,
}
/// Resource: queue of contradictions detected this tick.
///
/// Populated by `process_knowledge_events` when `observe_entity()` returns
/// a `ContradictionClaim`. Drained by downstream systems (monologue, storyteller).
#[derive(Resource, Default)]
pub struct ContradictionDetectedQueue {
events: Vec<ContradictionDetectedEvent>,
}
impl ContradictionDetectedQueue {
pub fn push(&mut self, event: ContradictionDetectedEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<ContradictionDetectedEvent> {
std::mem::take(&mut self.events)
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
impl KnowledgeEventQueue {
/// Push a knowledge event into the queue.
pub fn push(&mut self, event: KnowledgeEvent) {
@@ -82,9 +177,15 @@ impl KnowledgeEventQueue {
/// System: process pending knowledge events.
/// Runs once per tick, drains KnowledgeEventQueue and applies updates
/// to the relevant KnowledgeGraph components.
///
/// On contradiction detection (D-083):
/// - Shifts the ToldBy source entity to PersonOfInterest in the observer's KG.
/// - Pre-resolves display names for downstream monologue consumer.
pub fn process_knowledge_events(
mut queue: ResMut<KnowledgeEventQueue>,
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
registry: Res<EntityRegistry>,
npc_names: Query<&crate::simulation::conversation::NpcName>,
mut knowledge_query: Query<&mut KnowledgeGraph>,
) {
let events = queue.drain();
@@ -96,7 +197,59 @@ pub fn process_knowledge_events(
match event.event_type {
KnowledgeEventType::DirectObservation { target, position } => {
if let Some(stable_id) = registry.to_stable(target) {
observer_kg.observe_entity(stable_id, position, event.tick);
if let Some(claim) =
observer_kg.observe_entity(stable_id, position, event.tick)
{
// StableId is Copy — capture before moving claim into event.
let told_by = claim.told_by;
// Relationship shift (D-083): NPC who provided false info
// becomes PersonOfInterest in the observer's knowledge graph.
// Upsert: create a minimal entry if the source isn't yet known.
observer_kg
.entities
.entry(told_by)
.and_modify(|e| e.relationship = RelationshipState::PersonOfInterest)
.or_insert_with(|| EntityKnowledge {
last_known_position: None,
last_observed_tick: 0,
last_updated_tick: event.tick,
confidence: KnowledgeConfidence::Suspects,
source: KnowledgeSource::Inferred { basis: vec![] },
state: KnowledgeState::Active,
relationship: RelationshipState::PersonOfInterest,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
});
// Pre-resolve display names for the monologue consumer.
let source_entity = registry.to_entity(&told_by);
let source_display_name = source_entity
.and_then(|e| npc_names.get(e).ok())
.map(|n| n.0.clone())
.unwrap_or_else(|| format!("#{}", told_by.0));
let subject_display_name = npc_names
.get(target)
.ok()
.map(|n| n.0.clone())
.unwrap_or_else(|| format!("#{}", stable_id.0));
tracing::info!(
observer = ?event.observer,
target = stable_id.0,
told_by = told_by.0,
source = source_display_name,
subject = subject_display_name,
"Contradiction detected: ToldBy position differs from direct observation (D-083)"
);
contradiction_queue.push(ContradictionDetectedEvent {
observer: event.observer,
target: stable_id,
claim,
source_display_name,
subject_display_name,
});
}
} else {
debug_assert!(
false,
@@ -135,6 +288,68 @@ pub fn process_knowledge_events(
);
}
}
KnowledgeEventType::KnowledgeGranted { grant, source } => {
match grant {
ProcessedKnowledgeGrant::Fact(fg) => {
let should_insert = observer_kg
.facts
.get(&fg.fact_id)
.map(|existing| fg.confidence > existing.confidence)
.unwrap_or(true);
if should_insert {
observer_kg.facts.insert(
fg.fact_id.clone(),
FactKnowledge {
confidence: fg.confidence,
source,
state: KnowledgeState::Active,
acquired_tick: event.tick,
disclosure_blocked: false,
},
);
tracing::debug!(
"KnowledgeGranted(Fact): {:?} at confidence {:?}, tick {}",
fg.fact_id,
fg.confidence,
event.tick,
);
}
}
ProcessedKnowledgeGrant::Entity(eg) => {
// Insert or upgrade entity knowledge entry.
// Always use ToldBy source — entity grants come from NPC testimony.
let entry = observer_kg.entities.entry(eg.target_id).or_insert_with(|| {
EntityKnowledge {
last_known_position: None,
last_observed_tick: 0,
last_updated_tick: event.tick,
confidence: eg.confidence,
source: source.clone(),
state: KnowledgeState::Active,
relationship: RelationshipState::Unknown,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
}
});
// Upgrade confidence and source if new grant is higher.
if eg.confidence > entry.confidence {
entry.confidence = eg.confidence;
entry.source = source;
entry.last_updated_tick = event.tick;
}
// Merge attributes (grant may supply partial attribute set).
for (k, v) in eg.attributes {
entry.known_attributes.insert(k, v);
}
tracing::debug!(
"KnowledgeGranted(Entity): StableId {:?} at confidence {:?}, tick {}",
eg.target_id,
eg.confidence,
event.tick,
);
}
}
}
}
}
}
@@ -196,6 +411,7 @@ mod tests {
let _ = observer_sid; // registered for completeness
world.insert_resource(registry);
world.insert_resource(ContradictionDetectedQueue::default());
let mut queue = KnowledgeEventQueue::default();
queue.push(KnowledgeEvent {
@@ -236,6 +452,7 @@ mod tests {
registry.register(observer);
world.insert_resource(registry);
world.insert_resource(ContradictionDetectedQueue::default());
let mut queue = KnowledgeEventQueue::default();
queue.push(KnowledgeEvent {
@@ -261,6 +478,7 @@ mod tests {
let mut world = World::new();
let registry = EntityRegistry::new(0);
world.insert_resource(registry);
world.insert_resource(ContradictionDetectedQueue::default());
let fake_observer = world.spawn_empty().id(); // no KnowledgeGraph
let fake_target = world.spawn_empty().id();
@@ -339,4 +557,175 @@ mod tests {
"decay should run on tick 10 and downgrade confidence"
);
}
#[test]
fn process_direct_observation_detects_contradiction() {
let mut world = World::new();
let mut registry = EntityRegistry::new(0);
let target_ecs = world.spawn_empty().id();
let target_sid = registry.register(target_ecs);
let informant_sid = StableId(999);
// Observer has ToldBy knowledge: target at (10, 10) at tick 100
let mut kg = KnowledgeGraph::new();
kg.entities.insert(
target_sid,
EntityKnowledge {
last_known_position: Some(TilePosition::new(10, 10, 0)),
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant_sid,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
let observer = world.spawn(kg).id();
registry.register(observer);
world.insert_resource(registry);
world.insert_resource(ContradictionDetectedQueue::default());
// Push DirectObservation at DIFFERENT position, within window
let mut queue = KnowledgeEventQueue::default();
queue.push(KnowledgeEvent {
observer,
tick: 200,
event_type: KnowledgeEventType::DirectObservation {
target: target_ecs,
position: TilePosition::new(15, 10, 0),
},
});
world.insert_resource(queue);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_knowledge_events);
schedule.run(&mut world);
// Verify: KG entry is Contradicted
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
let entry = kg.entity_knowledge(&target_sid).unwrap();
assert_eq!(entry.state, KnowledgeState::Contradicted);
assert!(entry.contradicted_claim.is_some());
let claim = entry.contradicted_claim.as_ref().unwrap();
assert_eq!(claim.told_by, informant_sid);
assert_eq!(claim.claimed_position, TilePosition::new(10, 10, 0));
assert_eq!(claim.observed_position, TilePosition::new(15, 10, 0));
// Verify: ContradictionDetectedQueue has the event
let cq = world.resource::<ContradictionDetectedQueue>();
assert_eq!(cq.events.len(), 1);
assert_eq!(cq.events[0].target, target_sid);
assert_eq!(cq.events[0].claim.told_by, informant_sid);
}
#[test]
fn no_contradiction_event_when_position_matches() {
// DirectObservation at the SAME position as ToldBy:
// ContradictionDetectedQueue must stay empty.
let mut world = World::new();
let mut registry = EntityRegistry::new(0);
let target_ecs = world.spawn_empty().id();
let target_sid = registry.register(target_ecs);
let informant_sid = StableId(77);
let mut kg = KnowledgeGraph::new();
kg.entities.insert(
target_sid,
EntityKnowledge {
last_known_position: Some(TilePosition::new(10, 10, 0)),
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant_sid,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
let observer = world.spawn(kg).id();
registry.register(observer);
world.insert_resource(registry);
world.insert_resource(ContradictionDetectedQueue::default());
// DirectObservation at the SAME position
let mut queue = KnowledgeEventQueue::default();
queue.push(KnowledgeEvent {
observer,
tick: 200,
event_type: KnowledgeEventType::DirectObservation {
target: target_ecs,
position: TilePosition::new(10, 10, 0), // same position
},
});
world.insert_resource(queue);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_knowledge_events);
schedule.run(&mut world);
let cq = world.resource::<ContradictionDetectedQueue>();
assert!(
cq.is_empty(),
"Matching position should not produce a contradiction event"
);
let kg = world.entity(observer).get::<KnowledgeGraph>().unwrap();
let entry = kg.entity_knowledge(&target_sid).unwrap();
assert_eq!(entry.state, KnowledgeState::Active);
}
#[test]
fn contradiction_detected_queue_drains_correctly() {
// ContradictionDetectedQueue.drain() should empty the queue
// and return all accumulated events.
let mut queue = ContradictionDetectedQueue::default();
assert!(queue.is_empty());
let mut world = World::new();
let e = world.spawn_empty().id();
queue.push(ContradictionDetectedEvent {
observer: e,
target: StableId(1),
claim: ContradictionClaim {
told_by: StableId(99),
told_tick: 50,
claimed_position: TilePosition::new(1, 1, 0),
observed_position: TilePosition::new(5, 5, 0),
detected_tick: 100,
},
source_display_name: "Sera".to_string(),
subject_display_name: "Kael".to_string(),
});
queue.push(ContradictionDetectedEvent {
observer: e,
target: StableId(2),
claim: ContradictionClaim {
told_by: StableId(88),
told_tick: 60,
claimed_position: TilePosition::new(2, 2, 0),
observed_position: TilePosition::new(6, 6, 0),
detected_tick: 100,
},
source_display_name: "NPC_88".to_string(),
subject_display_name: "NPC_2".to_string(),
});
assert!(!queue.is_empty());
let drained = queue.drain();
assert_eq!(drained.len(), 2);
assert!(queue.is_empty(), "Queue should be empty after drain");
}
}