Files
settled-reach/docs/workshops/knowledge-graph-information-boundaries/round1-dudley.md
T
jpmschweitzerandClaude Opus 4.6 93abd9b9d0 docs(workshops): complete Knowledge Graph & Information Boundaries workshop
Two-round workshop producing D-041 (Knowledge Graph Data Model):
- Round 1: independent analyses from Dudley, Gestalt, SI, Tyre, Paula
- Round 2: synthesis resolving debates + Gestalt mechanics validation

Key decisions:
- 4-level confidence hierarchy (Suspects < KnowsOf < KnowsDetails < Direct)
- BTreeMap for deterministic iteration (D-010 principle 4)
- Per-entity Component model, not centralized Resource
- StableEntityId + EntityRegistry for save/load stability (partial Q-019)
- Sprint 2 stub: structs + direct observation + basic decay (~6.5 dev-days)

Resolved Q-016 (knowledge hierarchy), raised Q-024/Q-025/Q-026.
Created tickets #361-#368 under epic #351, reconciled #49 children.
Updated sprint 2 briefings, agent briefings, and decision files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:24:37 +01:00

28 KiB
Raw Blame History

Knowledge Graph & Information Boundaries — Round 1 Implementation Analysis

Author: Dudley (Server Implementation Specialist) Date: 2026-02-11 Workshop: Knowledge Graph & Information Boundaries Focus: Implementation practicality, ECS integration, performance, buildability


Executive Summary

The simulation guarantees deterministic knowledge state, but the current InformationInventory { known_facts: Vec<String> } is a placeholder that cannot support the design requirements. I need to verify the data structure before committing to an implementation path.

Key findings:

  1. ECS Integration: Knowledge graph should be a per-entity Component, not a centralized Resource — fits bevy_ecs model and supports tier serialization
  2. Stable entity references: Wire-format u64 entity IDs solve the save/load stability problem — already in the protocol
  3. Performance at scale: 80 Active NPCs × 50 known entities = 4,000 knowledge entries — feasible with proper indexing
  4. Complexity landmine: Knowledge decay implementation has hidden state explosion — needs batching
  5. Simplest buildable path: Start with HashMap-based graph per entity, add spatial queries later

Critical dependencies:

  • Q-019 (entity ID stability) must be resolved before knowledge references work
  • Q-016 (knowledge hierarchy) blocks the KnowledgeConfidence enum design
  • Spatial partitioning (for observer queries) is a prerequisite

1. ECS Integration (Questions 1-4)

1.1 Component vs Resource: Knowledge Graph Placement

Recommendation: Per-entity Component.

#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeGraph {
    /// What this entity knows about other entities
    /// Key: wire-format entity_id (u64), not bevy Entity
    pub entity_knowledge: HashMap<u64, EntityKnowledge>,
    /// Non-entity facts (location observations, overheard events)
    pub world_facts: Vec<WorldFact>,
    /// Last decay pass (for batch processing)
    pub last_decay_tick: u64,
}

Why Component, not Resource?

  • Each entity has its own knowledge state — natural fit for Component model
  • Tier serialization (D-026) requires per-entity serialization — Components serialize with their entity bundle
  • bevy_ecs Changed<KnowledgeGraph> queries enable efficient snapshot generation (only entities with knowledge updates)
  • No lock contention — each entity's knowledge is independent
  • Background tier NPCs can have simplified knowledge (fewer entries) without affecting Active tier

Why not Resource?

  • Centralized HashMap<Entity, KnowledgeGraph> requires lock for every knowledge update
  • Harder to serialize for tier transitions (must extract per-entity subgraphs)
  • Loses bevy_ecs change detection benefits

1.2 Migration Path from InformationInventory

Current state (server/src/npc/mod.rs line 47):

#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct InformationInventory {
    pub known_facts: Vec<String>,
}

Migration strategy:

  1. Keep InformationInventory as deprecated component for v0.1 compatibility
  2. Add KnowledgeGraph component to new NPCs
  3. Add migration system that runs once at spawn:
    fn migrate_information_inventory(
        mut commands: Commands,
        query: Query<(Entity, &InformationInventory), Without<KnowledgeGraph>>,
    ) {
        for (entity, inventory) in query.iter() {
            // Parse string facts into structured knowledge
            let graph = KnowledgeGraph::from_string_facts(&inventory.known_facts);
            commands.entity(entity).insert(graph);
        }
    }
    
  4. Remove InformationInventory after migration complete

v0.1 scope: Only KnowledgeGraph. No migration — fresh world generation.

1.3 Concrete Rust Struct Proposal

use std::collections::HashMap;
use serde::{Deserialize, Serialize};

/// Knowledge this entity has about another entity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntityKnowledge {
    /// Wire-format ID of the known entity (stable across save/load)
    pub subject_id: u64,
    /// Last observed position (if known)
    pub last_seen_position: Option<(f32, f32, i32)>,
    /// Tick when last observed
    pub last_seen_tick: u64,
    /// Confidence/hierarchy level (Q-016)
    pub confidence: KnowledgeConfidence,
    /// How this knowledge was acquired
    pub source: KnowledgeSource,
    /// Known facts about this entity's state
    pub facts: Vec<KnownFact>,
}

/// Knowledge confidence hierarchy (resolves Q-016)
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum KnowledgeConfidence {
    /// "I think someone might be involved" (lowest)
    Suspects = 0,
    /// "I know this person exists and their basic role"
    KnowsOf = 1,
    /// "I know specific details about their activities"
    KnowsDetails = 2,
    /// "I directly observed this" (highest)
    DirectObservation = 3,
}

/// How knowledge was acquired (affects trust/decay)
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum KnowledgeSource {
    DirectObservation,
    Told { by_entity_id: u64, trust: f32 },
    Inferred { confidence: f32 },
    Overheard,
}

/// A specific fact about an entity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnownFact {
    pub fact_type: FactType,
    pub learned_tick: u64,
    pub confidence: f32, // 0.0-1.0
}

/// Types of facts an entity can know
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FactType {
    /// Entity's current activity
    CurrentActivity(String),
    /// Entity's routine
    UsualRoutine(String),
    /// Entity's relationship to another
    Relationship { with: u64, kind: String },
    /// Entity's involvement in something
    Involvement(String),
    /// Entity's location habits
    FrequentsLocation { x: i32, y: i32, z: i32 },
}

/// Knowledge about locations/events (not tied to entities)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorldFact {
    pub description: String,
    pub location: Option<(i32, i32, i32)>,
    pub learned_tick: u64,
    pub confidence: f32,
}

Key design choices:

  • subject_id: u64 uses wire-format IDs (stable), not bevy Entity (unstable across save/load)
  • KnowledgeConfidence as enum with Ord enables hierarchy queries (>= KnowsOf)
  • KnowledgeSource carries context (who told me? how much do I trust them?)
  • confidence: f32 enables gradual decay (not binary)
  • FactType enum is extensible without breaking serialization

1.4 Entity ID Stability Problem (Q-019)

The problem: bevy_ecs Entity is a generational index (e.g., Entity { index: 42, generation: 3 }). Across save/load, entity IDs can change. Knowledge graph references would break.

Solution (already in protocol): Wire-format u64 entity IDs in VisibleEntity (server/src/bridge/types.rs line 26). The simulation needs a bidirectional mapping:

#[derive(Resource, Debug, Default)]
pub struct EntityIdMap {
    /// bevy Entity -> stable u64 ID
    to_wire: HashMap<Entity, u64>,
    /// stable u64 ID -> bevy Entity
    from_wire: HashMap<u64, Entity>,
    /// Next ID to assign
    next_id: u64,
}

impl EntityIdMap {
    pub fn register(&mut self, entity: Entity) -> u64 {
        if let Some(id) = self.to_wire.get(&entity) {
            return *id;
        }
        let id = self.next_id;
        self.next_id += 1;
        self.to_wire.insert(entity, id);
        self.from_wire.insert(id, entity);
        id
    }

    pub fn get_wire_id(&self, entity: Entity) -> Option<u64> {
        self.to_wire.get(&entity).copied()
    }

    pub fn get_entity(&self, wire_id: u64) -> Option<Entity> {
        self.from_wire.get(&wire_id).copied()
    }
}

Save/Load strategy:

  • Save: EntityIdMap.next_id + to_wire mapping serialize with World
  • Load: Restore mapping, bevy Entities get new indices but same wire IDs
  • Knowledge graph references stay valid (use wire IDs, not Entity handles)

This resolves Q-019. Stable IDs = monotonic counter, mapped to bevy entities at runtime.


2. Implementation Practicalities

2.1 Data Structure Choice

Options evaluated:

Structure Lookup Memory Use Case
HashMap<u64, EntityKnowledge> O(1) ~24 bytes overhead per entry Best for sparse graphs (not every entity knows every other)
Sparse matrix (Vec<Vec<Option>>) O(1) ~8 bytes per cell (mostly None) Best for dense graphs (every entity knows most others)
Adjacency list (Vec<(u64, EntityKnowledge)>) O(n) scan Minimal Best for very small graphs (<10 entries)

At 80 Active NPCs, ~50 known entities each:

  • HashMap: 80 × 50 × (~200 bytes per EntityKnowledge + 24 overhead) = ~900KB
  • Sparse matrix: 80 × 80 × 8 bytes = ~51KB, but needs 6,400 slots (wasteful if only 4,000 entries)

Recommendation: HashMap per entity. Knowledge graphs are sparse (an NPC doesn't know about entities they've never encountered), and HashMap lookup is O(1) for observer snapshot generation.

2.2 Knowledge Decay Implementation

D-011 requirement: "Fog returns when you leave." Knowledge decays over time.

Naive approach (WRONG):

fn decay_knowledge(
    mut query: Query<&mut KnowledgeGraph>,
    time: Res<SimulationTime>,
) {
    for mut graph in query.iter_mut() {
        for entry in graph.entity_knowledge.values_mut() {
            let ticks_since = time.tick - entry.last_seen_tick;
            if ticks_since > DECAY_THRESHOLD {
                entry.confidence *= DECAY_RATE;
            }
        }
    }
}

Problem: At 80 NPCs × 50 entries = 4,000 decay calculations per tick × 10 tps = 40,000 ops/sec. Unnecessary when knowledge doesn't change every tick.

Batch decay approach (CORRECT):

fn decay_knowledge_batch(
    mut query: Query<&mut KnowledgeGraph>,
    time: Res<SimulationTime>,
) {
    // Only decay once per game-minute (600 ticks at 10 tps)
    if time.tick % 600 != 0 {
        return;
    }

    for mut graph in query.iter_mut() {
        // Only process if knowledge changed since last decay
        if graph.last_decay_tick + 600 > time.tick {
            continue;
        }
        graph.last_decay_tick = time.tick;

        graph.entity_knowledge.retain(|_, entry| {
            let ticks_since = time.tick - entry.last_seen_tick;
            entry.confidence *= DECAY_RATE.powf((ticks_since / 600) as f32);
            entry.confidence > 0.1 // Drop very low confidence knowledge
        });
    }
}

Benefits:

  • Runs 1/600th as often (once per game-minute instead of every tick)
  • Skips entities whose knowledge hasn't changed
  • retain() removes decayed entries in-place (no Vec reallocation)

Complexity landmine avoided: Per-tick decay has hidden O(N×M) cost.

2.3 Observer Snapshot Query Efficiency

The query: "What does entity A know about all entities in this spatial region?"

fn generate_observer_snapshot(
    observer: Entity,
    observer_knowledge: &KnowledgeGraph,
    spatial_partition: &SpatialPartition, // Prerequisite system
    entity_id_map: &EntityIdMap,
    world: &World,
) -> ObserverSnapshot {
    // 1. Get entities in observer's perception radius
    let nearby_entities = spatial_partition.query_radius(
        observer_pos,
        PERCEPTION_RADIUS
    );

    // 2. Filter by LOS (shadowcasting, Q-018)
    let visible_entities = nearby_entities
        .into_iter()
        .filter(|e| has_line_of_sight(observer_pos, e.pos))
        .collect::<Vec<_>>();

    // 3. Build VisibleEntity list with knowledge filtering
    let entities = visible_entities
        .into_iter()
        .filter_map(|e| {
            let wire_id = entity_id_map.get_wire_id(e.entity)?;
            let knowledge = observer_knowledge.entity_knowledge.get(&wire_id);

            // What the observer knows determines what detail is revealed
            Some(VisibleEntity {
                entity_id: wire_id,
                x: e.x,
                y: e.y,
                z: e.z,
                kind: classify_entity(e.entity, knowledge, world),
            })
        })
        .collect();

    ObserverSnapshot {
        tick: world.resource::<SimulationTime>().tick,
        entities,
    }
}

Performance profile:

  • Spatial query: O(log N) with grid partitioning
  • LOS checks: O(visible entities × ray length) — typically 10-30 entities
  • Knowledge lookups: O(1) per entity (HashMap)
  • Total: ~1-2ms per observer at 80 NPCs (fits within 100ms tick budget for 30-40 observers)

Critical dependency: Spatial partitioning must exist before observer queries work. Currently missing (architecture audit section 3.1).


3. Performance (Questions 9-11)

3.1 Memory Budget

Active tier (80 NPCs, 50 known entities each):

EntityKnowledge struct size: ~200 bytes
  - subject_id: 8 bytes
  - last_seen_position: 13 bytes (Option<(f32, f32, i32)>)
  - last_seen_tick: 8 bytes
  - confidence: 1 byte (enum)
  - source: 16 bytes (enum with variants)
  - facts: Vec (24 bytes ptr + ~50 bytes per fact × 3 facts avg) = ~174 bytes

HashMap overhead: ~24 bytes per entry

Per NPC: 50 entries × (200 + 24) = ~11KB
80 NPCs: 80 × 11KB = ~880KB

WorldFact storage: ~100 facts per NPC × ~80 bytes = ~8KB per NPC = 640KB

Total: ~1.5MB for Active tier knowledge graphs

Background tier (500-2,000 NPCs): Simplified representation — only high-confidence entries:

#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct BackgroundKnowledge {
    /// Only high-confidence (>0.7) knowledge
    pub known_entities: Vec<u64>, // Just IDs, no detail
}

Per NPC: 10 known entities × 8 bytes = 80 bytes 2,000 NPCs: 2,000 × 80 = ~160KB

State-saved tier: Full serialization via serde, stored as blob in save file. Not in memory.

Total memory budget: ~2MB (Active + Background) — negligible on modern hardware.

3.2 Query Patterns and Indices

Primary queries:

  1. Snapshot generation: "What does observer A know about entities in region R?"

    • Requires: Spatial partition (grid-based, ~200 lines)
    • Frequency: Once per tick per observer (10-30 observers)
    • Cost: O(visible entities) × O(1) knowledge lookup = ~1-2ms
  2. Knowledge update: "Entity A observed entity B at tick T"

    • Direct HashMap insert/update
    • Cost: O(1)
  3. Gossip propagation: "Entity A tells entity B about entity C"

    • Lookup A's knowledge of C, insert into B's graph with source=Told
    • Cost: O(1) + O(1) = O(1)
  4. Decay pass: "Remove low-confidence knowledge"

    • Batch operation, once per game-minute
    • Cost: O(entities × knowledge entries) but amortized

No complex indices needed. HashMap per entity + spatial partition for perception = sufficient.

3.3 Batch Knowledge Updates

Observation events can be queued:

#[derive(Debug, Clone)]
pub struct KnowledgeEvent {
    pub observer: Entity,
    pub subject: Entity,
    pub event_type: KnowledgeEventType,
    pub tick: u64,
}

#[derive(Debug, Clone)]
pub enum KnowledgeEventType {
    DirectObservation { position: (f32, f32, i32) },
    Overheard { content: String },
    ToldBy { source: Entity },
}

#[derive(Resource, Default)]
pub struct KnowledgeEventQueue {
    pub events: Vec<KnowledgeEvent>,
}

System processes queue in batch:

fn process_knowledge_events(
    mut queue: ResMut<KnowledgeEventQueue>,
    mut query: Query<&mut KnowledgeGraph>,
    entity_id_map: Res<EntityIdMap>,
    time: Res<SimulationTime>,
) {
    for event in queue.events.drain(..) {
        if let Ok(mut graph) = query.get_mut(event.observer) {
            let subject_id = entity_id_map.get_wire_id(event.subject).unwrap();

            match event.event_type {
                KnowledgeEventType::DirectObservation { position } => {
                    graph.entity_knowledge
                        .entry(subject_id)
                        .and_modify(|e| {
                            e.last_seen_position = Some(position);
                            e.last_seen_tick = time.tick;
                            e.confidence = KnowledgeConfidence::DirectObservation;
                        })
                        .or_insert_with(|| EntityKnowledge {
                            subject_id,
                            last_seen_position: Some(position),
                            last_seen_tick: time.tick,
                            confidence: KnowledgeConfidence::DirectObservation,
                            source: KnowledgeSource::DirectObservation,
                            facts: vec![],
                        });
                }
                // Handle other event types...
            }
        }
    }
}

Benefits:

  • Perception system emits events without blocking
  • Knowledge updates happen in dedicated system phase
  • Batching reduces query overhead

4. System Architecture

4.1 bevy_ecs Systems Needed

pub struct KnowledgePlugin;

impl Plugin for KnowledgePlugin {
    fn build(&self, app: &mut App) {
        app
            .init_resource::<EntityIdMap>()
            .init_resource::<KnowledgeEventQueue>()
            .add_systems(Update, (
                process_knowledge_events
                    .after(perception_system) // After perception emits events
                    .before(generate_snapshot), // Before snapshot reads knowledge
                decay_knowledge_batch
                    .after(advance_tick),
            ));
    }
}

System ordering dependencies:

advance_tick (time system)
  ↓
perception_system (emits KnowledgeEvents)
  ↓
process_knowledge_events (consumes queue, updates KnowledgeGraph)
  ↓
decay_knowledge_batch (runs periodically)
  ↓
generate_snapshot (reads KnowledgeGraph for filtering)

4.2 Error Handling: Stale Entity References

Problem: Entity A knows about entity B (wire ID 42). Entity B despawns. Knowledge graph still references ID 42.

Solution 1: Tombstone entities

#[derive(Component)]
pub struct Despawned {
    pub despawn_tick: u64,
}

// When entity despawns, mark instead of removing
fn mark_despawned(mut commands: Commands, query: Query<Entity, With<DespawnRequested>>) {
    for entity in query.iter() {
        commands.entity(entity)
            .remove::<DespawnRequested>()
            .insert(Despawned { despawn_tick: /* current tick */ });
    }
}

// Cleanup tombstones after sufficient time (e.g., 1 game-hour)
fn cleanup_tombstones(
    mut commands: Commands,
    query: Query<(Entity, &Despawned)>,
    time: Res<SimulationTime>,
) {
    for (entity, despawned) in query.iter() {
        if time.tick - despawned.despawn_tick > 36000 { // 1 hour at 10 tps
            commands.entity(entity).despawn();
        }
    }
}

Solution 2: Clean references on despawn

fn clean_knowledge_references(
    mut commands: Commands,
    despawned: Query<Entity, With<Despawned>>,
    mut all_knowledge: Query<&mut KnowledgeGraph>,
    entity_id_map: Res<EntityIdMap>,
) {
    for entity in despawned.iter() {
        let wire_id = entity_id_map.get_wire_id(entity).unwrap();

        // Remove this entity from all knowledge graphs
        for mut graph in all_knowledge.iter_mut() {
            graph.entity_knowledge.remove(&wire_id);
        }

        // Now safe to despawn
        commands.entity(entity).despawn();
    }
}

Recommendation: Solution 1 (tombstones). Preserves NPC memory ("I knew someone who disappeared") — narratively interesting. Solution 2 causes knowledge to vanish mysteriously.


5. Tier Serialization (D-026)

5.1 Active Tier: Full Serialization

// KnowledgeGraph derives Serialize/Deserialize
// Serializes with entity bundle automatically

#[derive(Bundle)]
pub struct NpcBundle {
    pub npc: Npc,
    pub knowledge: KnowledgeGraph,
    pub tier: SimulationTier,
    // ... other components
}

When entity saves: entire KnowledgeGraph serializes via serde.

Serialized size estimate:

  • 50 EntityKnowledge entries × ~200 bytes = ~10KB per NPC
  • 80 Active NPCs = ~800KB in save file
  • Compressed (bincode): ~400-500KB

5.2 Background Tier: Compressed Knowledge

Transition from Active → Background:

fn downgrade_to_background(
    mut commands: Commands,
    query: Query<(Entity, &KnowledgeGraph), With<SimulationTier>>,
) {
    for (entity, knowledge) in query.iter() {
        // Compress: keep only high-confidence entries
        let compressed = BackgroundKnowledge {
            known_entities: knowledge.entity_knowledge
                .iter()
                .filter(|(_, entry)| entry.confidence >= KnowledgeConfidence::KnowsOf as u8)
                .map(|(id, _)| *id)
                .collect(),
        };

        commands.entity(entity)
            .remove::<KnowledgeGraph>()
            .insert(compressed)
            .insert(SimulationTier::Background);
    }
}

Transition from Background → Active:

fn upgrade_to_active(
    mut commands: Commands,
    query: Query<(Entity, &BackgroundKnowledge)>,
) {
    for (entity, bg_knowledge) in query.iter() {
        // Restore minimal knowledge graph
        let mut entity_knowledge = HashMap::new();
        for id in &bg_knowledge.known_entities {
            entity_knowledge.insert(*id, EntityKnowledge {
                subject_id: *id,
                last_seen_position: None,
                last_seen_tick: 0, // Unknown
                confidence: KnowledgeConfidence::KnowsOf,
                source: KnowledgeSource::Inferred { confidence: 0.7 },
                facts: vec![],
            });
        }

        commands.entity(entity)
            .remove::<BackgroundKnowledge>()
            .insert(KnowledgeGraph {
                entity_knowledge,
                world_facts: vec![],
                last_decay_tick: 0,
            })
            .insert(SimulationTier::Active);
    }
}

Memory savings: 11KB per NPC → 80 bytes per NPC = ~99% reduction for Background tier.

5.3 State-Saved Tier: Blob Serialization

#[derive(Component, Serialize, Deserialize)]
pub struct SerializedState {
    pub blob: Vec<u8>, // bincode-serialized entity bundle
}

fn save_to_state_saved(
    mut commands: Commands,
    query: Query<Entity, With<StateSavedTransition>>,
    world: &World,
) {
    for entity in query.iter() {
        // Serialize entire entity bundle
        let bundle = extract_bundle(world, entity);
        let blob = bincode::serialize(&bundle).unwrap();

        commands.entity(entity)
            .insert(SerializedState { blob })
            .insert(SimulationTier::StateSaved)
            // Remove all other components
            .remove::<KnowledgeGraph>()
            .remove::<Position>()
            // ...
    }
}

Restoration: Deserialize blob, reconstruct entity.


6. Critical Gaps and Dependencies

6.1 Blocking Issues

Must be resolved before knowledge system works:

  1. Q-019 (Entity ID stability): Wire-format IDs must be generated/mapped. Proposed EntityIdMap above solves this.
  2. Spatial partitioning: Observer queries need spatial index. Architecture audit identified this as CRITICAL (section 3.1).
  3. Q-018 (Shadowcasting algorithm): LOS checks required for perception → knowledge updates.

6.2 Design Blockers

Must be decided before implementation:

  1. Q-016 (Knowledge hierarchy): KnowledgeConfidence enum needs concrete levels. Proposed: Suspects < KnowsOf < KnowsDetails < DirectObservation.
  2. Q-017 (Triangle pressure): How does knowledge of triangles affect behavior? Knowledge system needs to expose triangle state for AI queries.

6.3 Nice-to-Have but Deferrable

  • Knowledge graph visualization tool (debugging)
  • Knowledge diff between two entities (for testing information asymmetry)
  • Knowledge graph serialization format (JSON for save files?)

7. Simplest Buildable Implementation

Phase 1: Minimal Knowledge Graph (Sprint 2)

  1. Replace InformationInventory with KnowledgeGraph component
  2. Implement EntityIdMap resource for stable IDs
  3. Add KnowledgeEvent queue and processing system
  4. Direct observation only (no gossip, no inference)
  5. No decay (constant knowledge)

Deliverable: NPC A observes NPC B → knowledge graph entry created → observer snapshot filters based on knowledge.

Phase 2: Knowledge Flow (Sprint 3)

  1. Add gossip (entity A tells entity B about C)
  2. Add inference (entity A saw entity B enter building → knows B is inside)
  3. Add knowledge decay (batch system, once per game-minute)

Phase 3: Advanced Features (Sprint 4+)

  1. Knowledge confidence hierarchy (Q-016 resolved)
  2. Misinformation (wrong facts, discoverable)
  3. Triangle pressure integration (Q-017 resolved)

8. Implementation Warnings

8.1 HashMap Iteration Order is a Determinism Time Bomb

Current Cargo.toml has rand = "0.9" but no explicit HashMap replacement. Standard Rust HashMap has non-deterministic iteration order (uses randomized SipHash).

Critical fix:

[dependencies]
indexmap = "2" # Or BTreeMap for smaller maps

Replace HashMap<u64, EntityKnowledge> with IndexMap<u64, EntityKnowledge> for deterministic iteration.

This affects D-010 principle 4 (deterministic simulation). Non-deterministic iteration breaks replay.

8.2 Serialization Version Compatibility

KnowledgeGraph will evolve (new fields, new FactType variants). Save file compatibility requires:

#[derive(Serialize, Deserialize)]
pub struct KnowledgeGraph {
    #[serde(default)] // New fields get default values
    pub entity_knowledge: HashMap<u64, EntityKnowledge>,

    #[serde(default)]
    pub world_facts: Vec<WorldFact>,

    // Version tag for future migrations
    #[serde(default)]
    pub version: u32,
}

8.3 Knowledge Graph Size Can Explode

Without limits, an NPC could accumulate unbounded knowledge. Add a cap:

const MAX_ENTITY_KNOWLEDGE: usize = 100; // Per NPC
const MAX_WORLD_FACTS: usize = 50;

// In update system
if graph.entity_knowledge.len() > MAX_ENTITY_KNOWLEDGE {
    // Evict lowest-confidence entries
    let mut entries: Vec<_> = graph.entity_knowledge.iter().collect();
    entries.sort_by_key(|(_, e)| e.confidence);
    let to_remove: Vec<_> = entries.into_iter().take(10).map(|(id, _)| *id).collect();
    for id in to_remove {
        graph.entity_knowledge.remove(&id);
    }
}

  1. Workshop Round 2: Resolve Q-016 (knowledge hierarchy) with concrete enum values
  2. Architecture spike: Implement EntityIdMap + basic KnowledgeGraph in a test harness (~2-3 days)
  3. Ticket breakdown:
    • #351-A: Entity ID stability (EntityIdMap resource)
    • #351-B: KnowledgeGraph component (core structs)
    • #351-C: KnowledgeEvent queue + processing system
    • #351-D: Direct observation flow (perception → knowledge)
    • #351-E: Observer snapshot filtering (knowledge → visibility)
  4. Performance validation: Benchmark at 80 Active NPCs × 50 known entities (target: <5ms for all knowledge updates per tick)

10. Summary

The simulation guarantees deterministic knowledge state. The proposed architecture:

  • Uses per-entity KnowledgeGraph Component (fits bevy_ecs, supports tier serialization)
  • Wire-format u64 entity IDs solve save/load stability (resolves Q-019)
  • HashMap-based storage is O(1) lookup, ~2MB memory for full Active tier
  • Batch decay prevents hidden performance explosion
  • Knowledge event queue decouples perception from updates
  • Tier transitions compress Background knowledge (99% size reduction)

Critical dependencies: Spatial partitioning, Q-016 resolution, Q-019 resolution.

Simplest path: Start with direct observation only, add gossip/inference/decay in phases.

Buildable in Sprint 2. No architectural blockers.


Files referenced:

  • /var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/npc/mod.rs (lines 47-49: InformationInventory)
  • /var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/bridge/types.rs (line 26: entity_id)
  • /var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/simulation/tier.rs (tier system)
  • /var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/architecture.md (D-010, D-020, D-026, D-030)
  • /var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/perception.md (D-011, D-017)
  • /var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/questions.md (Q-016, Q-019)