# 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)** ```rust #[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, /// Non-entity facts (locations discovered, events witnessed, abstract knowledge). pub facts: BTreeMap, } ``` 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` 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** ```rust #[derive(Resource)] pub struct WorldKnowledge { pub graphs: BTreeMap, } ``` 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` 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)** ```rust // 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>, } ``` 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 ```rust 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, /// 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, } /// 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 }, /// 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, pub facts: BTreeMap, } 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 { 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_details` becomes `Rumor` < `Inferred` < `Observed`. - Monologue prerequisite checks (D-035 `prerequisite` tag) are exact comparisons, not threshold tuning. - Dialogue access tiers (D-028) map cleanly: `surface` available at Rumor+, `real` at Observed+, `secret` at 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?" ```rust /// 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, /// Tiles visible to the observer (for fog rendering). pub visible_tiles: Vec, /// Sound events the observer can hear (D-018). pub audible_events: Vec, /// Knowledge-driven data: entities the observer KNOWS ABOUT /// but cannot currently see (last known position for fog-of-war). pub remembered_entities: Vec, } /// 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, } /// 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. ```rust // 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, // ... ) { /* 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) 1. **`KnowledgeGraph` component** with `BTreeMap` and `BTreeMap`. Full struct definitions as above. 2. **`KnowledgeConfidence` enum** with all 5 levels. Resolves Q-016. 3. **`KnowledgeSource` enum** with `DirectObservation` and `Background` variants only. Other sources (`Heard`, `ToldBy`, `Inferred`) exist as enum variants but are not generated by any system yet. 4. **`observe_entity()` method** that writes Direct confidence when an entity is in LOS. Called by the perception system after shadowcasting. 5. **`relationship_with()` query** that returns `RelationshipState` for D-033 entity color derivation. 6. **`knows_fact()` and `fact_at_least()` queries** for monologue prerequisite checks (D-035 `prerequisite` tag). 7. **Player character gets a `KnowledgeGraph` component** populated from background data at game start. 8. **NPCs get stub `KnowledgeGraph` components** with empty knowledge (or minimal background data for THE FRIEND's starting state). 9. **Integration with `compute_observer_visibility`** (#112): visible entities update the player's KnowledgeGraph. VisibilityResult includes `remembered_entities` from the knowledge graph. 10. **Basic decay** runs once per game-minute (every 10 ticks). Configurable via `DecayThresholds` resource. ### Sprint 3+ (Defer) - NPC-to-NPC knowledge propagation (gossip, reports) - `ToldBy` and `Inferred` source generation - `KnowledgeIndex` reverse 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 = 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 = ~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 ```rust /// 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: ```rust #[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. ```rust #[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: ```rust #[derive(Resource, Default)] pub struct EntityRegistry { /// StableId -> bevy Entity (for systems that have a StableId and need the Entity) pub by_stable_id: BTreeMap, /// bevy Entity -> StableId (for systems that have an Entity and need the StableId) pub by_entity: BTreeMap, } ``` 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) ```rust // 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 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 // 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 ```rust // 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, 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 1. **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. 2. **Q-016 is resolved by the KnowledgeConfidence enum.** The hierarchy `Stale < Rumor < Inferred < Observed < Direct` maps to the requirement `suspects < knows_of < knows_details`. Document the mapping explicitly. 3. **Q-019 is partially resolved by StableId.** The `EntityRegistry` resource plus `StableEntityId` component 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). 4. **Ticket #358 (ObserverSnapshot v2)** should incorporate `RelationshipState` and `EntityVisibility` as described in section 10. 5. **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) 6. **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/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/npc/mod.rs` -- Current NPC component model, InformationInventory placeholder (line 47-49) - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/perception/mod.rs` -- PerceptionPlugin stub - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/bridge/types.rs` -- ObserverSnapshot, VisibleEntity, wire protocol types - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/simulation/movement.rs` -- TilePosition, WalkabilityMap, validate_movement - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/simulation/tier.rs` -- SimulationTier, ScopeTag, LastInteraction - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/cause_chain.rs` -- CauseChain, CauseKind (aligns with KnowledgeSource) - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/Cargo.toml` -- Dependency inventory - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/architecture.md` -- D-010, D-020, D-026, D-030, D-031 - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/perception.md` -- D-011, D-015, D-017, D-018, D-033 - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/content.md` -- D-028, D-034, D-035 - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/questions.md` -- Q-016, Q-017, Q-018, Q-019 - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/audits/architecture-review-2026-02-11.md` -- Architecture review consensus - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/sprints/sprint-2/server.md` -- Sprint 2 server tasks - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/sprints/sprint-2/joint.md` -- Sprint 2 joint tasks, integration proof - `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/workshop-brief.md` -- Workshop brief