Standardized YAML frontmatter on all 10 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
41 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Round 1: Tyre (Technical Architect) — Knowledge Graph & Information Boundaries | Tyre's architectural analysis of knowledge graph data model, ECS integration, and Sprint 2 stub design | workshop | archived | knowledge-graph-information-boundaries | tyre | 1 | 2026-02-11 |
Round 1: Tyre (Technical Architect) -- Knowledge Graph & Information Boundaries
Workshop: Knowledge Graph & Information Boundaries (Epic #351) Perspective: Architecture & Feasibility Date: 2026-02-11
Executive Summary
cracks knuckles
Let me be honest about what this means technically. The knowledge graph is the hardest piece of architecture remaining in the project. Not because any individual part is difficult -- each piece is a well-understood data structure problem -- but because this system sits at the intersection of EVERY other system: perception, dialogue, monologue, entity color, observer snapshots, simulation tiers, save/load, and the IPC bridge. A bad data model here cascades everywhere. A good one simplifies everything downstream.
The good news: the constraints actually align well. D-010 principle 2 (information boundaries), D-026 (simulation tiers), and the existing bevy_ecs component model all push toward the SAME design. The knowledge graph should be a per-entity ECS component holding a BTreeMap of knowledge entries keyed by a stable entity ID. Not a centralized resource. Not a graph database. A component.
The even better news: Sprint 2 can ship with a stub that is architecturally correct but feature-incomplete. The interface contract matters more than the implementation depth. I will define exactly what that stub looks like.
Tier assessment:
- Data structure design: Feasible. Challenging but doable. The core struct is ~60 lines of Rust.
- Integration with observer snapshots (#112): Feasible. Single function signature, feeds directly into existing pipeline.
- Knowledge decay (D-011): Moderate difficulty. Needs tick-based aging, but the time system already exists.
- Full knowledge propagation (gossip, inference): Extremely difficult. Sprint 3+ at the earliest. Do not attempt in Sprint 2.
- Sprint 2 stub: Easy. 2-3 days of implementation for a correct, testable, extensible skeleton.
1. Integration with bevy_ecs Architecture (D-020)
Component vs Resource vs Hybrid
This is the first architecture decision and it determines everything else.
Option A: Per-entity Component (recommended)
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeGraph {
/// What this entity knows about other entities.
/// BTreeMap for deterministic iteration (D-010 principle 4).
pub entities: BTreeMap<StableId, EntityKnowledge>,
/// Non-entity facts (locations discovered, events witnessed, abstract knowledge).
pub facts: BTreeMap<FactId, FactKnowledge>,
}
Advantages:
- Natural ECS pattern. Query
(&KnowledgeGraph, &TilePosition)in perception systems. - Automatically participates in bevy_ecs archetype storage -- entities with knowledge graphs are grouped together.
Changed<KnowledgeGraph>dirty-flagging works out of the box for efficient observer snapshot delta compression.- Entity despawn automatically cleans up the knowledge graph. No orphaned references in a central store.
- Serialization for save/load is per-entity, matching D-026 tier serialization (State-saved NPCs serialize their components individually).
- Multiplayer: each observer's knowledge graph is already isolated. No shared mutable state to synchronize.
Disadvantages:
- Cross-entity queries ("who knows about entity X?") require iterating all KnowledgeGraphs. O(N) at worst.
- Memory is distributed, not cache-local for batch operations.
Option B: Centralized Resource
#[derive(Resource)]
pub struct WorldKnowledge {
pub graphs: BTreeMap<Entity, KnowledgeGraph>,
}
Advantages:
- Single place to query cross-entity knowledge.
- Potentially better cache behavior for batch updates.
Disadvantages:
- Does NOT participate in bevy_ecs archetype queries. Cannot use
Query<&KnowledgeGraph>. - Does NOT get
Changed<T>dirty tracking. - Manual lifecycle management: must sync with entity spawn/despawn.
- Complicates save/load: must serialize the entire resource separately from entity components.
- Breaks the per-entity component model established by D-024.
- Multiplayer: shared mutable resource across all observers is a synchronization nightmare.
Option C: Hybrid (Component + secondary index Resource)
// Component (source of truth)
#[derive(Component)]
pub struct KnowledgeGraph { /* ... */ }
// Resource (read-only index, rebuilt periodically)
#[derive(Resource)]
pub struct KnowledgeIndex {
/// Reverse lookup: who knows about entity X?
pub known_by: BTreeMap<StableId, Vec<Entity>>,
}
This is the correct long-term architecture. The Component is authoritative. The Resource is a secondary index rebuilt every N ticks (or on change detection). But the index is a Sprint 3+ optimization -- Sprint 2 does not need reverse lookups.
Decision: Component (Option A) for Sprint 2. Add reverse index Resource (Option C) when cross-entity queries become a measured bottleneck.
2. The Data Model
Core Types
use std::collections::BTreeMap;
use serde::{Serialize, Deserialize};
/// Stable entity identifier that survives save/load cycles.
/// NOT a bevy_ecs Entity (which is a generational index).
/// Resolves Q-019 for knowledge graph purposes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct StableId(pub u64);
/// Typed fact identifier for non-entity knowledge.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct FactId(pub String);
/// What entity A knows about entity B.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityKnowledge {
/// Last position this entity was observed at.
pub last_known_position: Option<TilePosition>,
/// Tick when this entity was last directly observed.
pub last_observed_tick: u64,
/// Tick when this knowledge entry was last updated (by any source).
pub last_updated_tick: u64,
/// How confident is this knowledge?
pub confidence: KnowledgeConfidence,
/// How did this entity learn this?
pub source: KnowledgeSource,
/// Relationship assessment (drives D-033 entity color).
pub relationship: RelationshipState,
/// Known attributes of the target (name, role, faction, etc.)
pub known_attributes: BTreeMap<AttributeKey, AttributeValue>,
}
/// Knowledge confidence level (resolves Q-016).
/// Discrete enum, NOT a continuous float.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum KnowledgeConfidence {
/// Outdated information -- haven't seen/heard in a long time.
/// Knowledge has decayed. May still be used but with low trust.
Stale,
/// Third-hand information. Someone mentioned it.
Rumor,
/// Reasonable inference from available information.
Inferred,
/// First-hand observation or direct conversation.
Observed,
/// Currently in line of sight. Maximum confidence.
Direct,
}
/// How the knowledge was acquired. Tracked per-entry, not per-graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum KnowledgeSource {
/// Directly seen by this entity's LOS.
DirectObservation { tick: u64 },
/// Heard (D-018 sound model -- medium/long range).
Heard { tick: u64, range: SoundRange },
/// Told by another entity (dialogue, gossip).
ToldBy { source_id: StableId, tick: u64 },
/// Inferred from other knowledge.
Inferred { basis: Vec<FactId> },
/// Starting knowledge (character background, D-013 insert data).
Background,
}
/// Sound range classification from D-018.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum SoundRange {
Close,
Medium,
Long,
}
/// Relationship state drives D-033 entity color.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationshipState {
Unknown,
Known,
Friendly,
PersonOfInterest,
Hostile,
}
/// Non-entity fact knowledge.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FactKnowledge {
pub confidence: KnowledgeConfidence,
pub source: KnowledgeSource,
pub acquired_tick: u64,
}
/// Attribute keys for known entity properties.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum AttributeKey {
Name,
Role,
Faction,
Workplace,
Routine,
Custom(String),
}
/// Attribute values -- typed for common cases, string fallback.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AttributeValue {
Text(String),
Flag(bool),
Number(f32),
}
/// The per-entity knowledge component.
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeGraph {
pub entities: BTreeMap<StableId, EntityKnowledge>,
pub facts: BTreeMap<FactId, FactKnowledge>,
}
impl KnowledgeGraph {
pub fn new() -> Self {
Self {
entities: BTreeMap::new(),
facts: BTreeMap::new(),
}
}
/// Query: does this entity know about another entity at all?
pub fn knows_entity(&self, id: &StableId) -> bool {
self.entities.contains_key(id)
}
/// Query: what confidence level for a known entity?
pub fn confidence_of(&self, id: &StableId) -> Option<KnowledgeConfidence> {
self.entities.get(id).map(|k| k.confidence)
}
/// Query: 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)
}
/// Query: does this entity know a specific fact?
pub fn knows_fact(&self, id: &FactId) -> bool {
self.facts.contains_key(id)
}
/// Query: 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)
}
/// Record a direct observation of another entity.
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 },
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 };
}
/// Decay knowledge based on elapsed ticks since last observation.
/// Called periodically (not every tick -- see performance section).
pub fn decay(&mut self, current_tick: u64, decay_thresholds: &DecayThresholds) {
for (_id, knowledge) in self.entities.iter_mut() {
let age = current_tick.saturating_sub(knowledge.last_observed_tick);
if knowledge.confidence == KnowledgeConfidence::Direct {
// Direct downgrades to Observed when no longer in LOS.
// This is handled by the perception system, not decay.
continue;
}
if age > decay_thresholds.stale_after {
knowledge.confidence = KnowledgeConfidence::Stale;
} else if age > decay_thresholds.decay_after
&& knowledge.confidence > KnowledgeConfidence::Inferred
{
// Step down one level.
knowledge.confidence = knowledge.confidence.decayed();
}
}
}
}
/// Configuration resource for knowledge decay rates.
#[derive(Resource, Debug, Clone)]
pub struct DecayThresholds {
/// Ticks before knowledge begins decaying (D-011 "fog returns").
/// At 10 tps: 600 ticks = 1 game-hour.
pub decay_after: u64,
/// Ticks before knowledge becomes Stale.
pub stale_after: u64,
}
impl KnowledgeConfidence {
/// Step down one confidence level.
pub fn decayed(self) -> Self {
match self {
Self::Direct => Self::Observed,
Self::Observed => Self::Inferred,
Self::Inferred => Self::Rumor,
Self::Rumor => Self::Stale,
Self::Stale => Self::Stale,
}
}
}
Why Discrete Confidence, Not Continuous Float
This is a deliberate architecture call. Let me be direct about the tradeoffs.
Discrete enum (recommended):
- Maps directly to Q-016 hierarchy:
suspects<knows_of<knows_detailsbecomesRumor<Inferred<Observed. - Monologue prerequisite checks (D-035
prerequisitetag) are exact comparisons, not threshold tuning. - Dialogue access tiers (D-028) map cleanly:
surfaceavailable at Rumor+,realat Observed+,secretat Direct relationship + high trust. - Entity color (D-033) maps cleanly: each RelationshipState + KnowledgeConfidence combo produces a deterministic color.
- Deterministic. No floating-point comparison issues (D-010 principle 4).
- Debuggable. "This NPC has Observed confidence about Kael" is immediately meaningful. "This NPC has 0.73 confidence about Kael" is not.
Continuous float (rejected for now):
- More granular decay curves.
- Allows weighting by source reliability.
- But: introduces floating-point determinism risk, requires threshold tuning, harder to debug, and the game design does not need sub-level granularity.
If we discover later that 5 levels are insufficient: adding a sixth enum variant is a minor change. Converting from enum to float is a major refactor. Start discrete, promote to continuous only if measured need arises. This is a "design for it now, build it later" boundary that works cleanly.
Why Source Is Per-Entry, Not Per-Graph
Each knowledge entry has its own source because the SAME entity can know different things about the same target through different channels. Kael Davan's smuggler character might:
- Know Sera Venn's name from Background (starting knowledge)
- Know Sera's workplace from DirectObservation (saw her at the terminal)
- Know Sera is investigating manifests from ToldBy (gossip at the bar)
Per-graph source would flatten this into one channel. Per-entry preserves provenance, which CauseChain (D-030) needs for monologue trigger explanations.
3. Interaction with Simulation Tiers (D-026)
This is where a single design decision solves multiple problems simultaneously. That is actually elegant.
| Tier | Knowledge Graph Behavior | Serialization |
|---|---|---|
| Active (30-80) | Full KnowledgeGraph component. Updated every perception tick. Decay runs. | In-memory bevy_ecs component |
| Background (500-2K) | KnowledgeGraph component present but NOT updated by perception. Decay runs at background tick rate (1/game-minute). Knowledge frozen at moment of tier transition. | In-memory bevy_ecs component |
| State-saved (10K+) | KnowledgeGraph serialized as part of entity state blob. ~0.5-2KB per entity depending on knowledge count. Deserialized on reactivation. | bincode or MessagePack to disk/memory |
| Ungenerated | No knowledge graph. Generated with starting knowledge on first instantiation. | N/A |
Tier transition protocol:
Active -> Background: No action needed. The KnowledgeGraph component stays attached. Background tick systems just do not run perception queries against it. Decay continues at reduced rate.
Background -> State-saved: Serialize the KnowledgeGraph component along with all other components. The BTreeMap serializes deterministically (sorted key order). Store as part of the entity state blob per D-026.
State-saved -> Active: Deserialize. Run a single decay pass to account for elapsed time since serialization. Mark all Direct confidence entries as Observed (entity was not actually visible during storage).
This works because KnowledgeGraph is just a Component. bevy_ecs component add/remove for tier transitions (D-026 explicitly calls this out) handles the lifecycle automatically. No special knowledge graph tier logic needed -- it rides the existing tier infrastructure.
Serialization Strategy
BTreeMap serializes to a sorted sequence of key-value pairs in both MessagePack and bincode. Deterministic. Stable across runs. No iteration-order ambiguity. This is why BTreeMap, not HashMap -- the architecture review consensus (HashMap ban in simulation code) pays dividends here.
Estimated serialized size per KnowledgeGraph:
- 50 entity entries x ~80 bytes each = ~4KB
- 20 fact entries x ~40 bytes each = ~800B
- Total: ~5KB per entity with a populated knowledge graph
At 10,000 State-saved NPCs (worst case, all with knowledge): ~50MB. Acceptable for a modern game. In practice, most State-saved NPCs are Tier 3 filler with <5 knowledge entries (~400B each).
4. Observer Visibility Query Interface (#112)
This is the concrete function signature that ticket #112 needs. The observer visibility query asks: "given this observer, what can they see, and what do they KNOW about what they see?"
/// Result of an observer visibility query.
/// This feeds directly into ObserverSnapshot assembly.
pub struct VisibilityResult {
/// Entities currently in line of sight.
pub visible_entities: Vec<VisibleEntityData>,
/// Tiles visible to the observer (for fog rendering).
pub visible_tiles: Vec<TilePosition>,
/// Sound events the observer can hear (D-018).
pub audible_events: Vec<AudibleEvent>,
/// Knowledge-driven data: entities the observer KNOWS ABOUT
/// but cannot currently see (last known position for fog-of-war).
pub remembered_entities: Vec<RememberedEntity>,
}
/// Visible entity with knowledge overlay.
pub struct VisibleEntityData {
pub stable_id: StableId,
pub position: TilePosition,
/// Relationship color for D-033, derived from knowledge graph.
pub relationship_state: RelationshipState,
/// What the observer knows about this entity.
/// None if this is the first time seeing them.
pub knowledge: Option<EntityKnowledge>,
}
/// Entity remembered but not currently visible.
/// Rendered as "ghost" or last-known-position marker in fog.
pub struct RememberedEntity {
pub stable_id: StableId,
pub last_known_position: TilePosition,
pub confidence: KnowledgeConfidence,
pub relationship_state: RelationshipState,
/// How old is this memory? Drives rendering (fade with age).
pub ticks_since_observed: u64,
}
/// The observer visibility query system.
/// Runs once per tick for the player character.
/// Runs at reduced frequency for Active-tier NPCs (staggered).
pub fn compute_observer_visibility(
observer: Entity,
observer_pos: &TilePosition,
observer_facing: &FacingDirection,
observer_knowledge: &KnowledgeGraph,
spatial_index: &dyn SpatialIndex,
walkability: &WalkabilityMap,
// shadowcast_fn would be the chosen algorithm from Q-018
current_tick: u64,
) -> VisibilityResult {
// 1. Shadowcast from observer position -> visible tiles
// 2. Vision cone modulation (forward/peripheral/behind per D-015)
// 3. Spatial query for entities in range
// 4. Filter entities by visible tiles -> visible_entities
// 5. For visible entities: update knowledge graph (Direct confidence)
// 6. For entities in knowledge graph but NOT visible: remembered_entities
// 7. Sound events from D-018 three-range model
todo!()
}
Key design choice: The visibility query READS the knowledge graph to produce remembered entities, and a SEPARATE system WRITES to the knowledge graph based on visibility results. This follows the bevy_ecs pattern of separating reads and writes into different systems to avoid borrow conflicts.
// System 1: Read knowledge + compute visibility (runs first)
fn visibility_query_system(
observers: Query<(Entity, &TilePosition, &FacingDirection, &KnowledgeGraph)>,
// ... other params
) { /* produces VisibilityResult, stores in Resource */ }
// System 2: Write knowledge updates based on visibility (runs after)
fn knowledge_update_system(
mut observers: Query<&mut KnowledgeGraph>,
visibility_results: Res<VisibilityResults>,
// ...
) { /* updates knowledge graphs based on what was seen */ }
This two-system split avoids the classic ECS anti-pattern of read-then-write in a single system with mutable borrows on the same component.
5. Sprint 2 Stub -- Exactly What to Ship
Let me be concrete. Here is what Sprint 2 ships versus what waits.
Sprint 2 (Ship)
-
KnowledgeGraphcomponent withBTreeMap<StableId, EntityKnowledge>andBTreeMap<FactId, FactKnowledge>. Full struct definitions as above. -
KnowledgeConfidenceenum with all 5 levels. Resolves Q-016. -
KnowledgeSourceenum withDirectObservationandBackgroundvariants only. Other sources (Heard,ToldBy,Inferred) exist as enum variants but are not generated by any system yet. -
observe_entity()method that writes Direct confidence when an entity is in LOS. Called by the perception system after shadowcasting. -
relationship_with()query that returnsRelationshipStatefor D-033 entity color derivation. -
knows_fact()andfact_at_least()queries for monologue prerequisite checks (D-035prerequisitetag). -
Player character gets a
KnowledgeGraphcomponent populated from background data at game start. -
NPCs get stub
KnowledgeGraphcomponents with empty knowledge (or minimal background data for THE FRIEND's starting state). -
Integration with
compute_observer_visibility(#112): visible entities update the player's KnowledgeGraph. VisibilityResult includesremembered_entitiesfrom the knowledge graph. -
Basic decay runs once per game-minute (every 10 ticks). Configurable via
DecayThresholdsresource.
Sprint 3+ (Defer)
- NPC-to-NPC knowledge propagation (gossip, reports)
ToldByandInferredsource generationKnowledgeIndexreverse lookup resource- Knowledge-driven NPC behavior changes
- Dialogue access tier filtering by knowledge state (D-028 Layer 1)
- Monologue triggering based on knowledge transitions
- Full CauseChain integration (knowledge change -> CauseChain entry -> monologue trigger)
- Knowledge decay tuning per knowledge type
- Misinformation (deliberately wrong knowledge entries)
Why This Split Works
Sprint 2's goal is "fog of perception working through the bridge." The knowledge graph stub enables:
- Observer visibility query (#112) has a concrete data structure to read/write
- Entity color (#D-033) has a relationship state to derive from
- Fog rendering has remembered entities (last known positions)
- Monologue prerequisites have a queryable interface (even if few facts exist yet)
Everything deferred is ADDITIVE. The Sprint 2 stub is the correct foundation -- nothing needs to be rewritten when Sprint 3 features land.
6. Memory Budget: 80 Active NPCs x ~50 Known Entities
Let me put real numbers on this.
Per-Entity Knowledge Entry Size
EntityKnowledge {
last_known_position: Option<TilePosition> = 16 bytes (Option<3xi32+padding>)
last_observed_tick: u64 = 8 bytes
last_updated_tick: u64 = 8 bytes
confidence: KnowledgeConfidence = 1 byte (enum, 5 variants)
source: KnowledgeSource = ~32 bytes (largest variant)
relationship: RelationshipState = 1 byte
known_attributes: BTreeMap<K,V> = ~128 bytes (3-5 entries typical)
}
Total per entry: ~200 bytes (with BTreeMap node overhead and alignment)
Per-NPC Knowledge Graph Size
At 50 known entities per NPC:
- Entity entries: 50 x 200 bytes = 10,000 bytes (~10KB)
- BTreeMap overhead: ~50 x 48 bytes (node pointers) = 2,400 bytes
- Fact entries (estimate 20): 20 x 80 bytes = 1,600 bytes
- Component overhead: ~64 bytes
- Total per NPC: ~14KB
Total Memory at Scale
| Scale | NPCs with Knowledge | Memory |
|---|---|---|
| v0.1 (15 NPCs) | 15 | ~210KB |
| Active tier (80 NPCs) | 80 | ~1.1MB |
| Active + Background (2,080 NPCs) | 2,080 | ~29MB |
| Full population (10,000+) | In-memory: 2,080 + serialized: 10K | ~29MB live + ~50MB serialized |
Verdict: Memory is a non-issue. Even at maximum scale, the knowledge graph consumes <100MB. Modern systems have 16-64GB RAM. The game's total memory footprint will be dominated by map data and textures, not knowledge graphs.
7. Query Time Budget Within 100ms Tick
The 100ms tick budget (D-026, 10 tps) must accommodate ALL systems: movement, perception, AI, knowledge updates, snapshot generation. Let me allocate.
Budget Allocation (at 80 Active NPCs)
| System | Budget | Notes |
|---|---|---|
| Movement + collision | 5ms | Already benchmarked as fast |
| Shadowcasting (1 player) | 2-5ms | 150x150 map, depends on Q-018 algorithm |
| Shadowcasting (NPCs, staggered) | 10-15ms | 8-10 NPCs per tick, round-robin |
| Knowledge update (from visibility) | 2-3ms | BTreeMap insert/lookup for visible entities |
| Knowledge decay | 0.5ms | Runs 1/game-minute, amortized ~0.05ms/tick |
| Observer snapshot assembly | 2-3ms | Serialize visible + remembered entities |
| IPC serialization + send | 1-5ms | MessagePack, measured in Sprint 1 |
| Headroom | ~65ms | For AI, pathfinding, storyteller (Sprint 3+) |
Knowledge Graph Query Performance
BTreeMap lookup: O(log N) where N = number of known entities.
- At N=50: ~6 comparisons per lookup. StableId comparison is a single u64 compare. Nanoseconds.
- At N=200 (extreme case): ~8 comparisons. Still nanoseconds.
BTreeMap iteration (for decay): O(N).
- At N=50: iterating 50 entries with simple comparisons. Sub-microsecond.
- 80 NPCs x 50 entries = 4,000 decay checks per game-minute = 0.1ms total.
Knowledge graph operations are NOT on the critical path. The expensive operations are shadowcasting and spatial queries. Knowledge lookups and updates are negligible by comparison.
Where Time Actually Goes
The real concern is not knowledge graph queries but the NUMBER of perception queries per tick. At 80 Active NPCs, if every NPC runs full shadowcasting every tick:
- 80 x 3ms = 240ms. Exceeds budget.
Solution (already identified in architecture review): staggered updates. Player runs every tick. NPCs run round-robin: 8-10 per tick, full cycle every 8-10 ticks. NPC knowledge updates are slightly delayed (up to 1 second game-time) but this is invisible to the player.
8. Spatial Partitioning for Knowledge Updates
Does knowledge update need spatial partitioning? No. But perception does, and knowledge rides perception.
Knowledge updates happen AFTER perception computes visibility. The flow is:
SpatialIndex.entities_in_range(observer, range) // spatial query
-> shadowcast(observer, nearby_entities) // LOS check
-> observer.knowledge.observe_entity(visible) // knowledge update
The spatial partitioning is in step 1, not step 3. By the time we reach the knowledge graph, we already have a small filtered set of entities (typically 5-20 visible entities, not 80). Writing 5-20 BTreeMap entries is trivial.
The SpatialIndex trait defined in the architecture review (Round 2 synthesis) is the correct abstraction here. Knowledge graph does not need its own spatial structure.
9. Determinism (D-010 Principle 4)
HashMap Ban: BTreeMap Solves It
The architecture review consensus is: no HashMap in simulation code. BTreeMap gives deterministic iteration order (sorted by key). Since StableId is u64 with derived Ord, BTreeMap iteration is deterministic across runs, platforms, and compilations.
FactId is a String with derived Ord. String ordering is byte-lexicographic, also deterministic.
Knowledge Decay Determinism
Decay is driven by tick count (integer arithmetic), not wall-clock time. Combined with BTreeMap iteration order, decay produces identical results given identical state. No floating-point operations in the decay path.
Knowledge Update Ordering
When multiple entities observe each other on the same tick, the update order matters for determinism. The system processes entities in bevy_ecs query iteration order, which is deterministic within an archetype. Since all NPCs with KnowledgeGraph share the same archetype (same component set), iteration is stable.
But: if NPC A observes NPC B and this changes A's behavior, which then changes what B observes A doing -- that is a circular dependency. In a single tick, this is resolved by system ordering: visibility query runs first (reads current state), knowledge update runs second (writes new state). No circular dependency within a single tick. Cross-tick effects propagate naturally.
10. Observer Snapshot Pipeline Integration
The observer snapshot (D-020) is the ONLY data crossing the IPC bridge. Knowledge graph data must be projected into the snapshot, not sent raw.
What Crosses the Bridge
/// Extended VisibleEntity for ObserverSnapshot v2 (#358).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibleEntity {
pub entity_id: u64, // StableId value
pub x: f32,
pub y: f32,
pub z: i32,
pub kind: EntityKind,
/// NEW: Relationship state for D-033 entity color.
pub relationship: RelationshipState,
/// NEW: Whether this entity is currently visible or remembered.
pub visibility: EntityVisibility,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EntityVisibility {
/// Currently in line of sight.
Visible,
/// Not in LOS but remembered from knowledge graph.
Remembered { confidence: KnowledgeConfidence, age_ticks: u64 },
}
What Does NOT Cross the Bridge
- The raw KnowledgeGraph struct
- KnowledgeSource details (these are server-internal)
- BTreeMap structure
- FactId/FactKnowledge (the client does not need to know about abstract facts)
- Other NPCs' knowledge graphs (information boundary enforcement!)
This is the information boundary in practice. The client receives exactly what the observer knows -- visible entities with relationship color, remembered entities with confidence/age for fog rendering. Nothing more. D-010 principle 2 enforced at the protocol level.
11. IPC Bridge Implications (D-020)
Snapshot Size Impact
Adding relationship: RelationshipState (1 byte) and visibility: EntityVisibility (~9 bytes for Remembered variant) to each VisibleEntity:
- Current VisibleEntity: ~25 bytes serialized (MessagePack)
- Expanded VisibleEntity: ~35 bytes serialized
- At 30 visible + 20 remembered entities: 50 x 35 = 1,750 bytes per snapshot
- At 10 tps: ~17.5KB/s
No bandwidth concern. This is well within the pipe buffer and IPC latency budget.
Protocol Evolution
The ObserverSnapshot struct will grow over sprints. MessagePack's named-field encoding (which the codec already handles per D-020) supports additive changes: new fields can be added without breaking the client. Old clients ignore unknown fields. This is why MessagePack was chosen over protobuf -- schema evolution without .proto files.
The EntityVisibility enum is the first test of this. If the client does not understand Remembered, it should render it as invisible (safe fallback). The GDScript Protocol class should already handle unknown fields gracefully -- verify this as part of #358.
12. Save/Load Implications (Q-019)
StableId Strategy
StableId(u64) is assigned once at entity creation and NEVER changes. It is stored as a component on the entity:
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StableEntityId(pub u64);
Generation strategy: monotonically incrementing counter from a Resource. Deterministic if game seed determines starting count.
#[derive(Resource)]
pub struct EntityIdGenerator {
next_id: u64,
}
impl EntityIdGenerator {
pub fn new(seed_offset: u64) -> Self {
Self { next_id: seed_offset }
}
pub fn next(&mut self) -> StableEntityId {
let id = StableEntityId(self.next_id);
self.next_id += 1;
id
}
}
Save/Load Round-Trip
On save: serialize each entity's StableEntityId + KnowledgeGraph + other components.
On load: deserialize into new bevy_ecs entities. The bevy Entity handle changes (generational index reset), but StableEntityId is preserved. Knowledge graphs reference StableIds, not bevy Entities, so all cross-references remain valid.
This is the critical insight: Knowledge graphs must reference StableId, NOT bevy Entity. If knowledge graphs stored bevy Entity handles, save/load would invalidate every cross-reference. StableId is the bridge between volatile ECS handles and persistent identity.
The Relationship.target_id: u64 in the existing NPC model (server/src/npc/mod.rs line 30) is already using wire-format u64 for the same reason. The knowledge graph follows the same pattern.
Mapping StableId <-> Entity at Runtime
A bidirectional lookup resource:
#[derive(Resource, Default)]
pub struct EntityRegistry {
/// StableId -> bevy Entity (for systems that have a StableId and need the Entity)
pub by_stable_id: BTreeMap<StableId, Entity>,
/// bevy Entity -> StableId (for systems that have an Entity and need the StableId)
pub by_entity: BTreeMap<Entity, StableId>,
}
Updated on entity spawn/despawn. Used by the knowledge update system to translate between ECS query results (Entity handles) and knowledge graph keys (StableIds).
13-14. Architecture Decisions Summary
| Question | Decision | Rationale |
|---|---|---|
| Component vs Resource? | Component | Natural ECS, Changed, save/load, multiplayer-ready |
| Confidence: enum vs float? | Enum (5 levels) | Deterministic, maps to Q-016, debuggable |
| Source: per-entry vs per-graph? | Per-entry | Multi-channel knowledge, CauseChain provenance |
| HashMap vs BTreeMap? | BTreeMap | Determinism (D-010 p4), architecture review consensus |
| StableId vs Entity reference? | StableId | Save/load stability (Q-019), tier transitions |
| Knowledge decay model? | Tick-based, discrete levels | Deterministic, configurable, matches D-011 |
| Reverse index? | Deferred to Sprint 3+ | Not needed until NPC-NPC knowledge queries |
| Sprint 2 scope? | Stub with correct interface | Foundation, not features |
15. Minimum Viable Interface (Sprint 2) vs Full System (Sprint 3+)
Sprint 2 Public API (Minimum Viable)
// Construction
KnowledgeGraph::new() -> Self
KnowledgeGraph::with_background(facts: Vec<(FactId, FactKnowledge)>) -> Self
// Queries (read-only, used by visibility system + snapshot assembly)
KnowledgeGraph::knows_entity(&self, id: &StableId) -> bool
KnowledgeGraph::confidence_of(&self, id: &StableId) -> Option<KnowledgeConfidence>
KnowledgeGraph::relationship_with(&self, id: &StableId) -> RelationshipState
KnowledgeGraph::knows_fact(&self, id: &FactId) -> bool
KnowledgeGraph::fact_at_least(&self, id: &FactId, min: KnowledgeConfidence) -> bool
KnowledgeGraph::known_entities(&self) -> impl Iterator<Item = (&StableId, &EntityKnowledge)>
// Mutations (used by perception system)
KnowledgeGraph::observe_entity(&mut self, target: StableId, position: TilePosition, tick: u64)
KnowledgeGraph::observe_entity_leaving_los(&mut self, target: &StableId, tick: u64)
KnowledgeGraph::decay(&mut self, current_tick: u64, thresholds: &DecayThresholds)
KnowledgeGraph::set_relationship(&mut self, target: &StableId, state: RelationshipState)
// Serialization (for save/load and tier transitions)
// Derived via serde Serialize/Deserialize -- no custom code needed.
Sprint 3+ API Additions
// Knowledge propagation
KnowledgeGraph::told_about_entity(&mut self, target: StableId, source: StableId, info: EntityKnowledge, tick: u64)
KnowledgeGraph::hear_entity(&mut self, target: StableId, position: TilePosition, range: SoundRange, tick: u64)
KnowledgeGraph::infer(&mut self, fact: FactId, basis: Vec<FactId>, tick: u64)
// Dialogue integration (D-028)
KnowledgeGraph::access_tier_for(&self, target: &StableId) -> AccessTier
KnowledgeGraph::disclosure_tier_for(&self, target: &StableId) -> DisclosureTier
// CauseChain integration
KnowledgeGraph::last_change(&self) -> Option<(StableId, KnowledgeSource, u64)>
Performance Risks and Mitigations
| Risk | Severity | Mitigation |
|---|---|---|
| BTreeMap slower than HashMap for lookups | LOW | At N=50, difference is ~2 nanoseconds. Not measurable. |
| Knowledge graph bloat (NPC knows too many entities) | LOW | Cap at 200 entries with LRU eviction of Stale entries. |
| Staggered NPC perception causes visible "popping" | MEDIUM | Smooth over with interpolation on client side. Remembered entities fade gradually. |
| StableId generation not deterministic across save/load | MEDIUM | Use monotonic counter seeded from game seed. Document invariant. |
| KnowledgeGraph serialization too large for State-saved tier | LOW | At ~5KB per entity, 10K entities = 50MB. Acceptable. Compress if needed. |
| Cross-entity knowledge queries (who knows about X?) too slow | MEDIUM (Sprint 3+) | Reverse index Resource. Not needed Sprint 2. |
Dependencies
| This Workshop Produces | Needed By | Sprint |
|---|---|---|
| KnowledgeGraph struct definition | #112 Observer visibility query | Sprint 2 |
| StableId type and EntityRegistry | #360 Q-019 Entity ID stability | Sprint 2 |
| EntityVisibility enum | #358 ObserverSnapshot v2 schema | Sprint 2 |
| RelationshipState enum | D-033 Entity color rendering | Sprint 2 |
fact_at_least() query interface |
Monologue prerequisite system (#119) | Sprint 3 |
| Access tier derivation | D-028 dialogue filtering | Sprint 3+ |
| Knowledge propagation API | NPC gossip / investigation | Sprint 3+ |
Recommended Next Steps
-
This workshop produces a D-0XX decision with the struct definitions and API from this analysis. The data model should be confirmed in Round 2, not deferred.
-
Q-016 is resolved by the KnowledgeConfidence enum. The hierarchy
Stale < Rumor < Inferred < Observed < Directmaps to the requirementsuspects < knows_of < knows_details. Document the mapping explicitly. -
Q-019 is partially resolved by StableId. The
EntityRegistryresource plusStableEntityIdcomponent provides the bridge between bevy Entity handles and persistent identifiers. Full Q-019 resolution also needs client-side entity lifecycle (how Godot maps StableId to scene nodes). -
Ticket #358 (ObserverSnapshot v2) should incorporate
RelationshipStateandEntityVisibilityas described in section 10. -
Implementation order for Sprint 2:
- Week 1: StableEntityId component + EntityRegistry resource + KnowledgeGraph struct with tests
- Week 1: DecayThresholds resource + decay system
- Week 2: Integration with #112 (observer visibility query reads/writes knowledge)
- Week 2: Integration with #358 (snapshot includes relationship + visibility state)
-
Sprint 2 test requirements (D-030 Phase 2 alignment):
- Unit tests: KnowledgeGraph CRUD operations, decay logic, confidence ordering
- Integration test: observation updates knowledge, leaving LOS downgrades to Observed
- Negative test: entity A cannot query entity B's KnowledgeGraph (information boundary)
Files Referenced
/var/mnt/data/projects/settled-reach/planning/server/src/npc/mod.rs-- Current NPC component model, InformationInventory placeholder (line 47-49)/var/mnt/data/projects/settled-reach/planning/server/src/perception/mod.rs-- PerceptionPlugin stub/var/mnt/data/projects/settled-reach/planning/server/src/bridge/types.rs-- ObserverSnapshot, VisibleEntity, wire protocol types/var/mnt/data/projects/settled-reach/planning/server/src/simulation/movement.rs-- TilePosition, WalkabilityMap, validate_movement/var/mnt/data/projects/settled-reach/planning/server/src/simulation/tier.rs-- SimulationTier, ScopeTag, LastInteraction/var/mnt/data/projects/settled-reach/planning/server/src/cause_chain.rs-- CauseChain, CauseKind (aligns with KnowledgeSource)/var/mnt/data/projects/settled-reach/planning/server/Cargo.toml-- Dependency inventory/var/mnt/data/projects/settled-reach/planning/decisions/architecture.md-- D-010, D-020, D-026, D-030, D-031/var/mnt/data/projects/settled-reach/planning/decisions/perception.md-- D-011, D-015, D-017, D-018, D-033/var/mnt/data/projects/settled-reach/planning/decisions/content.md-- D-028, D-034, D-035/var/mnt/data/projects/settled-reach/planning/decisions/questions.md-- Q-016, Q-017, Q-018, Q-019/var/mnt/data/projects/settled-reach/planning/docs/audits/architecture-review-2026-02-11.md-- Architecture review consensus/var/mnt/data/projects/settled-reach/planning/docs/sprints/sprint-2/server.md-- Sprint 2 server tasks/var/mnt/data/projects/settled-reach/planning/docs/sprints/sprint-2/joint.md-- Sprint 2 joint tasks, integration proof/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/workshop-brief.md-- Workshop brief