Files
settled-reach/docs/workshops/knowledge-flow-npc-boundaries/tyre-round1.md
T
jpmschweitzerandClaude Opus 4.6 60ffe2e02a docs(docs): add frontmatter to knowledge-flow-npc-boundaries workshop
Standardized YAML frontmatter on all 12 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:40:44 +01:00

38 KiB

title, description, type, status, workshop, agent, round, created
title description type status workshop agent round created
Round 1: Tyre — Architecture Analysis Tyre's Round 1 architecture analysis of grant mechanism wiring, ECS patterns, and performance budgets workshop archived knowledge-flow-npc-boundaries tyre 1 2026-02-23

Round 1: Tyre -- Architecture Analysis

Workshop: Knowledge Flow & NPC Information Boundaries Domain: System architecture, ECS patterns, performance budgets Date: 2026-02-23


Topic 1: Knowledge Flow -- The Grant Mechanism

Position: Single event type with a compound grant payload

cracks knuckles -- let me be honest about what's actually hard here and what isn't.

Sub-question 1: Wiring knowledge_grant on dialogue lines.

The architecture is clean. The KnowledgeEventQueue (server/src/knowledge/events.rs:56) already handles the drain-per-tick pattern. We add one new KnowledgeEventType variant:

KnowledgeEventType::KnowledgeGranted {
    fact_grants: Vec<(FactId, KnowledgeConfidence)>,
    entity_grants: Vec<(StableId, EntityKnowledgeGrant)>,
}

The grant fires at line selection time in process_talk_interaction (dialogue.rs), NOT at client display time. The server is authoritative (D-010 principle 1). The dialogue system already has the player entity, the target NPC entity, the NPC's StableId (via EntityRegistry), and the selected IndexedDialogueLine with its knowledge_grant field (server/src/content/line_pool.rs:297). All the data needed to construct the event is available in one place.

The processing happens in process_knowledge_events (server/src/knowledge/events.rs:85). Add a match arm that calls observer_kg.facts.insert() for fact grants and observer_kg.entities.entry().or_insert_with() for entity grants. This is the same pattern as DirectObservation processing (events.rs:97-110).

Difficulty: Tier 1. ~1-2 days. The hard part is already done (event queue, processing system, line pool indexing). This is plumbing.

Sub-question 2: Entity knowledge vs fact knowledge in grants.

The current KnowledgeGrant schema (server/src/content/types.rs:495-498) is too narrow -- { fact_id: String, confidence: String } only covers facts. The "Kael handles cargo at Dock 7" example from the workshop brief needs BOTH:

  • An EntityKnowledge entry for Kael (the NPC now knows Kael exists and his role)
  • A FactKnowledge entry for "dock_7.kael_assignment" (the specific claim that can later be contradicted)

My recommendation: extend the YAML schema to support a compound grant.

knowledge_grant:
  facts:
    - fact_id: "dock_7.kael_assignment"
      confidence: "knows_of"
  entities:
    - entity_ref: "kael"          # resolved to StableId at content load
      attributes:
        role: "dock-worker"
        location: "dock-7"
      confidence: "knows_of"
      source_type: "told_by"      # auto-fills ToldBy { source_id: speaker_sid }

The entity_ref string maps to StableId via a content-time name registry (similar to how EntityRegistry works at runtime, but for authored content). This is new infrastructure but small -- a BTreeMap<String, StableId> populated at content load.

Risk: The compound grant schema is more complex for content authors. Mitigation: make facts and entities both optional. Most dialogue lines will only grant facts. Entity grants are for key narrative moments (THE FRIEND arc setup, character introductions).

Sub-question 3: POI discovery as knowledge flow.

Option (a) wins: new FactId category "poi.*". Reasons:

  1. FactKnowledge already has confidence, source, state, and acquired_tick -- exactly what POI discovery needs.
  2. filter_by_access already handles KnowledgeGated(String) (server/src/knowledge/graph.rs:299-302) which checks kg.knows_fact(). POI-gated content "just works" with ObserverAccess::KnowledgeGated("poi.dock_7_restricted").
  3. fact_at_least() (graph.rs:71-76) already provides the prerequisite check for dialogue/monologue gating (D-035). A POI fact with Suspects confidence gates differently from one with KnowsDetails.
  4. No new types needed. No new systems needed. The grant mechanism from sub-question 1 handles the write. The existing query infrastructure handles the read.

Extending EntityKnowledge to cover locations (option b) would conflate entities with places, and locations don't have relationship: RelationshipState or last_observed_tick semantics that make sense. Keep the type boundaries clean.

Sub-question 4: Physical evidence discovery.

Same mechanism as dialogue grants. A new KnowledgeEventType::EvidenceDiscovered variant is not necessary and would be premature. The KnowledgeGranted event type already covers it -- the source field distinguishes provenance. Evidence grants would use:

KnowledgeSource::DirectObservation { tick }

...because the player is physically looking at the terminal/document. The confidence is higher (KnowsDetails vs KnowsOf for hearsay) precisely because it's direct evidence. This is a content-authoring decision, not an architecture decision.

If we later need system-specific processing (e.g., "evidence triggers different monologue types"), we can branch on the confidence level or add a tag, not a new event type. Keep the event type enum tight.

Sub-question 5: Content author guardrails.

Runtime enforcement, not authoring-time validation. Here's why:

The NPC's KG is the authoritative source. When the grant mechanism fires, the system should check: does the NPC's KG contain the facts they're about to grant? If not, skip the grant and log a warning. This is a 3-line check in the event processing:

// In the KnowledgeGranted match arm
if let Ok(npc_kg) = knowledge_query.get(npc_entity) {
    for (fact_id, confidence) in &fact_grants {
        if !npc_kg.knows_fact(fact_id) {
            tracing::warn!("NPC {} tried to grant unknown fact {}", npc_sid.0, fact_id.0);
            continue;
        }
    }
}

Authoring-time validation is nice-to-have for the line previewer CLI but should not be the primary guardrail. Runtime enforcement catches edge cases where an NPC's KG changes during gameplay (e.g., they learned something, then forgot it via decay, but the dialogue line is still eligible). This is consistent with D-010 principle 2 -- the server is the authority.

Architecture summary for Topic 1

Component Change Effort
KnowledgeEventType enum Add KnowledgeGranted variant 30 min
process_knowledge_events Add match arm for grant processing 2 hours
KnowledgeGrant YAML schema Extend to compound (facts + entities) 1 day
Content load pipeline Add entity_ref -> StableId resolution 0.5 day
Runtime guardrail NPC KG check before granting 1 hour
Total ~2 days

Topic 2: NPC-to-NPC Knowledge Propagation

Position: Piggyback on existing conversation system, trust-gated, confidence-capped

Sub-question 1: Existing conversation system IS the hook.

Yes. Unambiguously yes. run_npc_conversations (server/src/simulation/conversation.rs:278) already does:

  • Proximity detection (line 340: manhattan_distance, CONVERSATION_PROXIMITY = 3)
  • Deterministic pairing via StableId sorting (line 324: eligible.sort_by_key(|&(_, _, sid)| sid))
  • Conversation lifecycle (start tick, end tick, cooldown)
  • Active-tier scoping (line 295: With<Npc>, With<ActiveSim>)

A "knowledge transfer phase" inserts between conversation start and conversation end. The existing Phase 2 (line 374: tick active conversations) already has the per-conversation-tick loop. We add knowledge transfer on conversation termination, not per-line -- this is a deliberate architectural choice:

  1. Per-conversation, not per-line. Knowledge transfer happens once when the conversation ends (in terminate_conversation, line 569). This is simpler, cheaper, and narratively appropriate -- you learn things from a conversation, not from individual sentences.
  2. Uses the same KnowledgeEventQueue. The existing drain-per-tick pattern handles it. No new event infrastructure.

Building a separate system would violate DRY and create synchronization headaches (two systems managing NPC proximity conversations independently). The conversation system is the natural hook -- use it.

Sub-question 2: Trust-gated filtering.

The RelationshipGraph (server/src/npc/relationships.rs:98) tracks directed trust between entity pairs. RelationshipEdge.trust is an i8 (-10..+10). We need a mapping from trust to eligible knowledge categories:

Trust Range Tier What transfers
-10..0 None Nothing (hostile/neutral, no sharing)
0..3 Surface Public facts only (FactKnowledge with Suspects or KnowsOf)
3..7 Real Facts + entity observations (non-secret EntityKnowledge)
7..10 Secret Everything except KnowledgeGated entries

This maps cleanly to the D-028 trust tier concept. The check is: look up RelationshipGraph.get((speaker_sid, listener_sid)), read the trust value, filter eligible entries.

Implementation note: The trust check requires reading the RelationshipGraph resource during conversation termination. terminate_conversation currently takes Commands, EntityRegistry, and player query. It needs RelationshipGraph access added. This is a signature change, not an architectural one.

Sub-question 3: Confidence downgrade on transfer.

The proposed rule is sound: transferred_confidence = min(source_confidence, KnowsOf).

This means:

  • Direct (I saw it) -> KnowsOf when told to someone else. Correct -- secondhand knowledge.
  • KnowsDetails (I know the specifics) -> KnowsOf. Correct -- the retelling loses precision.
  • KnowsOf -> KnowsOf. Correct -- stays at the same level.
  • Suspects -> Suspects. Correct -- rumors stay rumors.

The KnowledgeConfidence enum already derives Ord (server/src/knowledge/types.rs:32), so min() works natively via the PartialOrd trait. The implementation is literally confidence.min(KnowledgeConfidence::KnowsOf). One line.

This also prevents gossip chains from inflating confidence -- a critical property. If A tells B who tells C who tells D, the confidence never exceeds KnowsOf. Only direct observation can produce KnowsDetails or Direct. The hierarchy is load-bearing and this preserves it.

Sub-question 4: Rate limiting.

Fixed cap of 1-3 facts per conversation. Use rng.random_range(1..=3) for the transfer count, drawing from eligible entries sorted by last_updated_tick (most recent first). Rationale:

  • Prevents knowledge explosion from a single conversation. Two NPCs with 50 entries each shouldn't dump 50 facts in one chat.
  • The random 1-3 range creates natural information asymmetry between NPC pairs -- replay value (Nigel cares about this).
  • Most recent first biases toward current-gamestate knowledge, which is more narratively relevant.
  • At ~50 entries per NPC, selecting 1-3 from the eligible-and-trust-filtered subset is O(N) scan + O(1) selection. Negligible.

Sub-question 5: Observable by player.

When the player overhears an NPC-NPC conversation (D-078), the current system produces ConversationEvent with occluded_line (per-word Bernoulli occlusion, conversation.rs:214). The question is whether the player's KG should be updated from overheard conversations.

Position: Overheard knowledge enters at Suspects confidence, regardless of occlusion fidelity.

Reasoning:

  • Trying to parse the occluded line to determine what "facts" survived is an AI problem, not a game engine problem. We're not building NLP.
  • The Suspects confidence level already means "something seems off about X" or "I've heard the name" (types.rs:34-36). That's exactly what you get from half-hearing a conversation.
  • The grant fires if the conversation involved knowledge about an entity the player doesn't already know about at higher confidence. Use the same KnowledgeGranted event, but cap at Suspects and use KnowledgeSource::Heard { tick, range: SoundRange::Medium } (types.rs:95).
  • ListeningFocus (D-071) could upgrade the confidence to KnowsOf. This creates a meaningful gameplay choice: actively listen to get better intel vs passively overhear for vague leads.

Sub-question 6: ToldBy source construction.

The KnowledgeSource::ToldBy { source_id: StableId, tick: u64 } variant (types.rs:97) is defined and ready. Construction happens in the terminate_conversation knowledge transfer phase:

let source = KnowledgeSource::ToldBy {
    source_id: speaker_sid,  // already available at line 598
    tick: current_tick,       // already available at line 584
};

Both values are already in scope in terminate_conversation. This is plug-and-play.

Performance budget for Topic 2

Per-conversation knowledge transfer:

  • Trust lookup: O(1) hash/btree lookup in RelationshipGraph. ~50ns.
  • Eligible entry scan: O(N) where N = speaker's KG entity count (~50). ~5us per NPC.
  • Transfer writes: 1-3 BTreeMap inserts. O(log N) per insert. ~300ns total.
  • Per-tick overhead: Max 1 conversation terminates per tick (rate limited by conversation pacing). So worst case ~6us per tick. Negligible against the 3ms knowledge budget (D-041 performance note).

This stays well within budget even if we increase conversation frequency later.


Topic 3: Unprompted Disclosure Design (#172)

Position: Disclosure as a filtered KG query feeding Layer 4, not a new system

Sub-question 1: Connection to NPC KG.

tell_state.rs (server/src/npc/tell_state.rs) currently derives tell categories from raw axes: Secret, ToleranceThreshold, Contentment, MoodState, Relationships, and RoutineDeviation. The derive_category function (tell_state.rs:73) reads these components directly -- no KG involvement.

For unprompted disclosure, the tell state system needs to know "what does this NPC know that might be interesting?" This is a KG query, not an axis derivation. My recommendation:

Do NOT add disclosure_candidates: Vec<FactId> to DerivedTellState. That couples the tell derivation system to the knowledge graph in a way that creates awkward data flow. Instead:

Add a new system derive_disclosure_candidates that runs AFTER derive_tell_state and BEFORE Layer 4 line selection. It produces a separate component:

#[derive(Component, Debug)]
pub struct DisclosureCandidates {
    pub facts: Vec<(FactId, KnowledgeConfidence)>,
    pub entities: Vec<(StableId, KnowledgeConfidence)>,
}

This system queries the NPC's KnowledgeGraph, the NPC's DerivedTellState, and the NPC-player trust level. It filters the KG into a candidate list. Layer 4 of the dialogue pipeline then uses DisclosureCandidates to select which dialogue line to volunteer.

Why a separate component? Because the disclosure derivation has different update frequency requirements than tell state. Tell state updates every tick (it's cheap -- axis comparisons). Disclosure candidate derivation involves KG scanning, trust lookups, and filtering -- it should run less frequently. Once per game-minute (every 10 ticks) is sufficient. The component acts as a cache.

Sub-question 2: "Do I know something you don't?"

Option A: NPC only checks own KG. Full stop.

Option B (cross-entity KG query) violates D-010 principle 2 -- information boundaries. An NPC reading the player's KG to decide what to say is omniscient behavior wearing a trenchcoat. The NPC doesn't know what the player knows. Period.

The UX concern (repeated info) is real but solvable without violating info boundaries:

  • The dialogue line cooldown system already exists (DialogueCooldownTracker, dialogue.rs:88). If the NPC volunteers fact X and the player already has it, the line fires but won't repeat for 600 ticks (LINE_COOLDOWN_TICKS).
  • The player hearing "old news" is realistic and can trigger different monologue responses: "I already knew that" vs "Wait, that's new."
  • Paula/Gestalt should weigh in on whether "NPC tells you something you already know" is a bug or a feature narratively.

Sub-question 3: Trait filtering (#173).

Both, in sequence. Traits filter candidate facts FIRST, then modify delivery of surviving candidates.

Architecture:

  1. DisclosureCandidates system applies trait-based filters: a Cautious NPC has a higher confidence threshold before sharing. A Gossipy NPC shares lower-confidence entries. This is a filter on the KG query.
  2. Layer 4 line selection uses the filtered candidates to match against dialogue lines. The line's tags field (IndexedDialogueLine.tags, line_pool.rs:296) already supports this -- add trait tags like "cautious_delivery" or "gossip_delivery".

The trait -> filter mapping is content-authorable, not hard-coded. A traits.yaml configuration that maps trait names to confidence thresholds and tag preferences. This keeps the system generic and content-driven.

Sub-question 4: Trigger conditions.

Layer 4 already has a trigger framework via the dialogue pipeline. Unprompted disclosure triggers when:

  1. Trust threshold met: NPC-player trust >= Surface tier (trust >= 0). From RelationshipGraph.
  2. Mood permits: DerivedTellState.category is not Angry or Guarded. Angry/Guarded NPCs don't volunteer info.
  3. Rate limit not exceeded: Per-NPC disclosure cooldown (separate from line cooldown). Suggested: 300 ticks (30 game-minutes). Stored as a simple DisclosureCooldown { until_tick: u64 } component.
  4. Player in proximity: Manhattan distance <= conversation range (3 tiles). Reuse CONVERSATION_PROXIMITY from conversation.rs:36.
  5. Not in active conversation: NPC is not currently in an NpcConversation (check Option<&NpcConversation> in query).

Presence of other NPCs (witnesses) is a Gestalt/Paula question -- mechanically it's trivial (count nearby ActiveSim NPCs), but whether it affects behavior is a design call.

Sub-question 5: Rate limiting.

Layer the cooldowns:

  • Per-NPC disclosure cooldown: 300 ticks (30 game-minutes). Prevents individual NPCs from being disclosure machines.
  • Per-fact cooldown: Use the existing DialogueCooldownTracker mechanism. Once a fact-granting line fires, that line_id is on cooldown for 600 ticks.
  • Global rate limit across all NPCs: One unprompted disclosure per game-minute (every 10 ticks). This is a Resource counter, not per-entity. Prevents the "five NPCs volunteer info simultaneously" problem.

The global limit is the most important one. Without it, entering a populated area could trigger 5 disclosure attempts in one tick. The per-NPC and per-fact limits handle repetition; the global limit handles spam.

Difficulty assessment

Tier 2. The dialogue pipeline infrastructure exists (layers 1-3 work). The new work is:

  • DisclosureCandidates component + derivation system (~1 day)
  • Global disclosure rate limiter (~0.5 day)
  • Layer 4 integration for unprompted lines (~1.5 days)
  • Trait filter configuration schema (~0.5 day)

Total: ~3.5 days. Depends on Paula/Gestalt defining the candidate selection rules before Dudley can implement.


Topic 4: NPC Information Boundaries (#142)

Position: MVP is tell_state + disclosure only. Do NOT retrofit pathfinding.

Sub-question 1: Priority order for retrofit.

System Retrofit Tier v0.1? Rationale
tell_state.rs Tier 1 Yes Direct read replacement: axes -> KG entries. NPC's own state is always in its KG (self-knowledge).
Unprompted disclosure (#172) Tier 1 Yes Disclosure IS a KG query -- it's KG-native by design.
conversation.rs Tier 2 Sprint 18+ "Does NPC A know NPC B exists?" check before pairing. Adds KG query to pair loop.
routine.rs Tier 3 No Routines are static schedules. KG-driven routine would mean NPCs "forget" their daily schedule. Nonsensical.
path_follow.rs Tier 3 No KG-driven pathfinding means NPCs forget where walls are. Broken gameplay.

Sub-question 2: Minimum viable boundary.

tell_state.rs + unprompted disclosure (#172). Here's why this is the right MVP:

The tell system currently reads Secret, ToleranceThreshold, Contentment, MoodState directly (tell_state.rs:129-141). For an NPC's OWN axes, the KG always reflects ground truth -- the NPC observes itself continuously. So "reading from KG instead of axes" for self-state is a no-op in terms of behavior change. The retrofit is conceptually correct but functionally invisible for self-state.

Where the boundary BECOMES visible is in unprompted disclosure: the NPC uses its KG to decide what to share. This is a real information boundary that creates gameplay. An NPC who hasn't seen Kael today doesn't volunteer "Kael was at the dock" because that knowledge has decayed.

This is the key architectural insight: the MVP boundary isn't about restricting self-knowledge (NPCs always know their own state), it's about restricting knowledge of OTHERS that informs behavior.

Sub-question 3: Simulation tier interaction (D-026).

Clear answer: Background-tier NPCs do not get KG-driven behavior.

D-026 defines three tiers:

  • Active (30-80 NPCs): Full simulation, full KG, full boundaries.
  • Background (500-2,000 NPCs): State machine, minimal KG (10 entries per D-041 budget).
  • State-saved (10,000+): Serialized, no active simulation.

Background NPCs run state machines, not the full AI pipeline. Their KGs exist for snapshot purposes (what does the player know about them?) but are not read for NPC decision-making. The derive_tell_state system already scopes to With<ActiveSim> (tell_state.rs:140). The disclosure system should do the same. Background NPCs don't disclose because they don't have the components driving disclosure.

The tier transition from Background -> Active DOES hydrate the KG. When an NPC enters the Active tier (player approaches), their KG gets populated from Background state + any accumulated gossip. This is a D-026 tier transition concern, not a knowledge boundary concern.

Sub-question 4: What breaks?

tell_state.rs retrofit: Nothing breaks. As noted above, an NPC's KG for its own axes is always up-to-date because the NPC is the direct observer of its own state. The observe_entity call (graph.rs:111) with source: DirectObservation happens continuously for entities in the Active tier's perception range, and an entity is always in its own "perception range."

Actually -- I should flag a subtlety. The current tell_state.rs reads components directly (Secret, Contentment, etc.). These are GROUND TRUTH components. The KG EntityKnowledge struct has known_attributes: BTreeMap<String, String> (types.rs:171) but doesn't currently store axis values. For the tell_state retrofit to work, either:

(a) The NPC's axes are mirrored into its own KG known_attributes (new system), or (b) Tell state continues to read axis components directly for SELF-state, and only uses KG for OTHER-state.

Option (b) is correct. Tell state is about the NPC's own behavior signals. The NPC reads its own axes (ground truth for self). The information boundary applies to what the NPC knows about OTHERS. Don't over-engineer the self-observation path.

path_follow.rs retrofit: Things break badly. If pathfinding reads from KG, an NPC whose knowledge of a corridor has decayed (older than stale_after = 3600 ticks = 6 game-hours) might "forget" that a path exists. This produces stuck NPCs, pathfinding failures, and bizarre behavior. The fallback to ground truth (option a from the brief) would fire constantly, making the boundary meaningless. Don't do this for v0.1. Maybe not ever.

Sub-question 5: Fallback behavior.

For the MVP (tell_state + disclosure): no fallback needed. Tell state reads self-axes directly (option b above). Disclosure reads KG but gracefully handles "NPC has nothing to share" by simply not triggering.

For future retrofits (conversation partner selection): fall through to ground truth with logging (option a). If NPC A's KG doesn't know NPC B exists, it should still be ABLE to start a conversation if they're physically proximate. The KG check adds a bonus ("I recognize this person, let me chat") rather than a gate ("I've never seen this person, I refuse to interact"). This preserves functionality while adding KG-informed behavior.

Option (c) ("ask around" behavior) is Tier 3 at minimum. Cool for emergent gameplay but scope-dangerous. Defer to v0.2+.

Architecture summary for Topic 4

v0.1 scope: tell_state uses own axes for self-state. Disclosure uses KG for other-state. Both scoped to ActiveSim.

No retrofit of path_follow.rs, routine.rs, or conversation.rs in Sprint 17. The conversation system KG check (does A know B?) is a Sprint 18 candidate -- low risk, moderate gameplay value, clean implementation (add kg.knows_entity(&partner_sid) to the pair eligibility filter at conversation.rs:314).


Topic 5: Contradiction Detection Pipeline

Position: Event-driven detection on KG write, location-first, content-authored attributes

Sub-question 1: Location contradiction (simplest case).

This is architecturally straightforward. The contradiction fires when:

  1. NPC A tells player "X was at location L1 at time T" -> EntityKnowledge entry created with source: ToldBy { source_id: A, tick: T } and last_known_position: Some(L1).
  2. Player directly observes X at location L2 at time T' where T' is within a time window of T -> EntityKnowledge entry updated with source: DirectObservation { tick: T' } and last_known_position: Some(L2).
  3. Detection: same StableId, two entries? No -- there's only one EntityKnowledge per StableId in the BTreeMap (graph.rs:20: entities: BTreeMap<StableId, EntityKnowledge>).

Architectural problem. The current EntityKnowledge struct has a SINGLE source field and a SINGLE last_known_position (types.rs:154-172). There's no history. When the player observes X directly, the observe_entity method (graph.rs:111) OVERWRITES the previous ToldBy source with DirectObservation. The ToldBy information is gone. There's nothing to contradict against.

This is the key design issue for contradiction detection. We need source history on EntityKnowledge. Two approaches:

Approach A: Add a previous_sources Vec. When observe_entity or the grant mechanism writes a new source, push the old source onto a previous_sources: Vec<(KnowledgeSource, Option<TilePosition>, u64)> field. Contradiction detection scans previous_sources against the current entry.

Approach B: Detect contradiction at write time. Before overwriting the entry in observe_entity, compare the incoming observation against the current entry. If current.source is ToldBy and current.last_known_position != incoming_position and the time window overlaps, set state = Contradicted BEFORE overwriting.

I recommend Approach B -- detect at write time. It's event-driven (only fires on KG mutation), doesn't grow the struct, and the data needed for comparison is available in the same function call. The existing observe_entity method (graph.rs:111-136) already does conditional state updates (line 133: if entry.state == KnowledgeState::Stale). Adding a contradiction check follows the same pattern:

// In observe_entity, before overwriting source:
if let KnowledgeSource::ToldBy { source_id, tick: told_tick } = &entry.source {
    if let Some(old_pos) = entry.last_known_position {
        if old_pos != position {
            // Location contradiction: told one place, observed another
            entry.state = KnowledgeState::Contradicted;
            // Don't return -- still update position and source
        }
    }
}

The time window check can be added as a configuration: contradictions only fire if |told_tick - observation_tick| < CONTRADICTION_WINDOW_TICKS. This prevents ancient hearsay from triggering contradictions with current observations.

But wait -- there's a subtlety. We need BOTH the old entry (ToldBy) and the new entry (DirectObservation) to exist for the monologue system to reference. "Sera said Kael was at the dock, but I just saw him in B-7" requires knowing both positions.

Revised recommendation: Approach B for detection, but also store the contradicting claim as a new field:

pub struct EntityKnowledge {
    // ... existing fields ...
    /// When Contradicted: the previous claim that conflicts with current observation.
    pub contradicted_claim: Option<ContradictionClaim>,
}

pub struct ContradictionClaim {
    pub source: KnowledgeSource,   // Who said it
    pub position: TilePosition,     // Where they said the entity was
    pub tick: u64,                  // When they said it
}

This preserves the contradiction evidence for downstream consumers (monologue, anomaly marker) without growing a full history Vec.

Sub-question 2: Attribute contradiction.

The known_attributes: BTreeMap<String, String> (types.rs:171) is untyped strings. For contradiction detection, we need to know which attribute keys are "same-subject" comparisons. "role: dock-worker" doesn't contradict "faction: transit-union" -- different keys. But "role: dock-worker" from ToldBy vs "role: smuggler" from DirectObservation is a contradiction.

Content-authored contradiction pairs. A YAML file defining which attribute key+value pairs contradict:

contradictions:
  - key: "role"
    values: ["dock-worker", "smuggler"]    # mutually exclusive
  - key: "allegiance"
    values: ["transit-union", "commission"] # mutually exclusive

This is cleaner than trying to auto-detect contradictions on untyped strings. The content team defines what's contradictory; the engine enforces it. Automatic detection would produce false positives (NPC changed shift? Not a contradiction. NPC changed faction? That IS a contradiction).

Difficulty: Tier 2 for attribute contradiction. Needs the content schema + a lookup system for contradiction rules. ~2 days on top of the location detection work.

Sub-question 3: Content-authored vs automatic.

Type Detection Rationale
Location Automatic Position comparison is objective and mechanical. No content authoring needed.
Attribute Content-authored Attribute semantics are content-defined. The engine can't know that "dock-worker" contradicts "smuggler" without being told.
Fact Hybrid Category-specific. poi.* facts use automatic location checks. contraband.* facts use authored contradiction pairs.

Sub-question 4: Detection timing.

Event-driven, on KG write. This is the clear winner:

  • process_knowledge_events (events.rs:85) already runs once per tick and handles all KG mutations. Adding contradiction detection as a post-write check is natural.
  • Per-tick scanning of all KGs (80 Active NPCs x 50 entries = 4,000 comparisons per tick) is wasteful. 99% of ticks have no new information.
  • Per-game-minute is too slow for the narrative. The player sees Kael in B-7 and the monologue should fire within seconds, not minutes.
  • Event-driven means: detect on the tick when the contradicting observation occurs. Immediate. One comparison per KG write that has a ToldBy predecessor. Virtually free.

Sub-question 5: Event emission chain.

The full chain, system by system:

  1. Perception system (server/src/perception/) detects entity in LOS.
  2. Knowledge event (KnowledgeEventType::DirectObservation) pushed to KnowledgeEventQueue.
  3. Knowledge processing (process_knowledge_events, events.rs:85) applies the observation.
    • In observe_entity (graph.rs:111), contradiction check fires.
    • entry.state = KnowledgeState::Contradicted.
    • entry.contradicted_claim = Some(ContradictionClaim { ... }).
  4. Anomaly detection (detect_anomalies, anomaly.rs:44) runs after knowledge processing.
    • Finds KnowledgeState::Contradicted on the entity (anomaly.rs:64).
    • Inserts AnomalyMarker component.
  5. Monologue system (monologue.rs) detects AnomalyMarker or Contradicted state.
    • Selects "Wait -- that doesn't add up" line (currently hardcoded in ANOMALY_LINES, monologue.rs:52-65).
    • Future: content-pool line with trigger="contradiction" and prerequisite checking the specific fact.
  6. Relationship system reads Contradicted state on the ToldBy source entity.
    • Shifts the ToldBy source (Sera) to RelationshipState::PersonOfInterest.
    • This triggers D-033 amber color via the existing color derivation pipeline.

Systems 4-6 already exist and are tested. They just need the Contradicted state to be SET, which is what this pipeline produces. The downstream chain fires automatically via existing Changed<KnowledgeGraph> patterns and per-tick detection.

Sub-question 6: THE FRIEND arc mechanical sequence.

Walking through D-034 with the proposed architecture:

  1. Sera tells detective "Kael was at Dock 7 during second shift."

    • Dialogue line selected via D-028 pipeline. Line has knowledge_grant: { entities: [{ entity_ref: "kael", attributes: { location: "dock-7" }, confidence: "knows_of", source_type: "told_by" }] }.
    • KnowledgeEventType::KnowledgeGranted pushed to queue.
    • process_knowledge_events creates EntityKnowledge for Kael in player's KG: source: ToldBy { source_id: sera_sid, tick: T1 }, last_known_position: Some(dock_7_pos), confidence: KnowsOf.
  2. Detective walks to B-7 corridor and observes Kael.

    • Perception system fires DirectObservation { target: kael_entity, position: b7_pos }.
    • process_knowledge_events calls observe_entity(kael_sid, b7_pos, T2).
    • Contradiction check fires: entry.source is ToldBy, entry.last_known_position is dock_7_pos, incoming position is b7_pos. They differ. entry.state = KnowledgeState::Contradicted. entry.contradicted_claim = Some(ContradictionClaim { source: ToldBy { sera_sid, T1 }, position: dock_7_pos, tick: T1 }).
    • Entry updated: source: DirectObservation { tick: T2 }, last_known_position: Some(b7_pos), confidence: Direct, state: Contradicted.
  3. Anomaly detection fires. detect_anomalies (anomaly.rs:44) finds Kael's entry has state == Contradicted. AnomalyMarker inserted on Kael's entity.

  4. Monologue fires. Monologue system selects contradiction line: "Sera said Kael was at the dock. I just saw him in B-7." (Future: content-authored line that references contradicted_claim.source to name Sera and contradicted_claim.position for the claimed location.)

  5. Relationship shift. The contradiction involves ToldBy { source_id: sera_sid }. System identifies Sera as the source of the contradicted claim. Sera's RelationshipState shifts to PersonOfInterest. D-033 color pipeline renders Sera in amber (#e8c547).

  6. Player experiences: Kael appears as Direct (visible, in LOS) with Contradicted state (visual indicator TBD). Sera's color shifts to amber even though she's not present. The detective's internal monologue narrates the contradiction. The investigation has begun.

Every system in this chain exists and is tested EXCEPT steps 1 (grant mechanism, Topic 1) and 2 (contradiction detection in observe_entity). The downstream chain (steps 3-6) works today -- all tests manually set Contradicted state and verify the cascade.

Performance budget for Topic 5

  • Location contradiction detection: One Option<TilePosition> comparison per observe_entity call where source is ToldBy. ~10ns. Negligible.
  • Attribute contradiction: One BTreeMap lookup per attribute write into a known_attributes map, checked against authored contradiction pairs. ~100ns per attribute. With 2-5 attributes per entity, ~500ns per contradiction check.
  • Total per-tick: Only fires when KG is written (event-driven). At most 5-10 KG writes per tick during active gameplay. Total: ~5us per tick. Within the 3ms knowledge budget by three orders of magnitude.

Implementation estimate

Component Effort
ContradictionClaim struct + field on EntityKnowledge 1 hour
Location detection in observe_entity 0.5 day
Contradiction event emission for relationship shift 0.5 day
Attribute contradiction pairs (YAML schema + lookup) 1 day
Monologue integration (content-authored contradiction lines) 0.5 day
Tests (location, attribute, THE FRIEND arc sequence) 1 day
Total ~3.5 days

Cross-cutting: Q-025 Closure

Agree with formal closure. Current memory analysis from D-041: ~14 KB per Active NPC KG (50 entities + 20 facts). 80 Active NPCs = ~1.1 MB. 2,000 Background NPCs at 10 entries each = ~5 MB. Total: ~6 MB.

The gossip propagation from Topic 2 increases entry counts slightly (1-3 per conversation, capped). Even if every NPC's KG doubles from gossip, we're at ~12 MB. Not a concern until 500+ Active NPCs, which is beyond v0.2 scope.

Recommendation: Close Q-025. Re-evaluate if Active NPC count exceeds 200 or if profiling shows KG memory exceeding 50 MB.


Summary of Key Architectural Positions

  1. Single KnowledgeGranted event type with compound payload (facts + entities). Don't fragment the event enum.
  2. Piggyback gossip on conversation termination. Don't build a separate propagation system.
  3. Confidence cap at KnowsOf on transfer. Gossip chains never produce high-confidence knowledge.
  4. Disclosure as filtered KG query, separate from tell state derivation. Different update frequencies, different concerns.
  5. NPCs check own KG only for disclosure. No cross-entity KG reads. D-010 principle 2 is non-negotiable.
  6. MVP boundary: tell_state + disclosure only. Don't retrofit pathfinding or routines.
  7. Contradiction detection at KG write time in observe_entity. Event-driven, not polling.
  8. Store contradicted claim for downstream monologue/relationship references. One struct, not a full history.
  9. Close Q-025. Memory is not a concern at current scale.
  10. Total implementation estimate: ~11 days across Topics 1-5. Parallelizable -- grant mechanism and contradiction detection can be developed simultaneously after the compound grant schema is agreed.