Files
settled-reach/server/src/knowledge/graph.rs
T
jpmschweitzerandClaude Opus 4.6 03c44bebaf fix(simulation): address PR #42 review — 8 items from Hoshe and Tyre
1. Sound producers: document empty v0.1 pipeline explicitly (critical)
2. Routine tests: add ActiveSim to 3 tests that passed trivially
3. Rename _observer_pos → observer_pos (used at line 191)
4. Add FactionOnly positive test case (matching faction_id)
5. Fix stale doc comment "Current: 9" → 10 in ObserverSnapshot
6. Remove orphaned SimulationTier/LastInteraction/ScopeTag types
7. Add tracing::warn on FactionOnly non-numeric parse failure
8. Document Medium-range occlusion gap as TODO in audible_at
9. Insert SoundEventQueue in observer test setup_world

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:08:32 +01:00

750 lines
26 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).
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 };
// Stale entries become Active again on fresh observation.
// Contradicted entries stay Contradicted even if you're looking
// at the entity right now — the contradiction is still unresolved.
if entry.state == KnowledgeState::Stale {
entry.state = KnowledgeState::Active;
}
}
/// 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(),
});
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,
};
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,
},
),
(
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]);
}
// --- 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,
},
)]);
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,
},
)]);
// 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"
);
}
}