feat(simulation): add knowledge graph system per D-041 (#361, #362, #363, #365)

Implement per-entity KnowledgeGraph component with BTreeMap storage
for deterministic iteration (D-010). StableEntityId + EntityRegistry
for entity identity across save/load. KnowledgeEventQueue + processing
system for decoupled knowledge updates. Decay system runs once per
game-minute. All types conform to D-041 canonical structs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 00:48:43 +01:00
co-authored by Claude Opus 4.6
parent a9f1cbd865
commit e733a4c223
6 changed files with 1041 additions and 0 deletions
+395
View File
@@ -0,0 +1,395 @@
//! 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()
}
// --- Write Operations ---
/// Record a direct observation of another entity (entity is in LOS).
pub fn observe_entity(
&mut self,
target: StableId,
position: TilePosition,
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::Direct,
source: KnowledgeSource::DirectObservation { tick },
state: KnowledgeState::Active,
relationship: RelationshipState::Unknown,
known_attributes: BTreeMap::new(),
});
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 };
// Do NOT reset state here -- a Contradicted entry stays Contradicted
// even if you're looking at the entity right now.
}
/// 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;
}
}
}
/// 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()
}
}
#[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,
};
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 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,
},
),
(
FactId("contraband.exists".to_string()),
FactKnowledge {
confidence: KnowledgeConfidence::Suspects,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
},
),
];
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]);
}
}