Standardized YAML frontmatter on all 12 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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
EntityKnowledgeentry for Kael (the NPC now knows Kael exists and his role) - A
FactKnowledgeentry 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:
FactKnowledgealready hasconfidence,source,state, andacquired_tick-- exactly what POI discovery needs.filter_by_accessalready handlesKnowledgeGated(String)(server/src/knowledge/graph.rs:299-302) which checkskg.knows_fact(). POI-gated content "just works" withObserverAccess::KnowledgeGated("poi.dock_7_restricted").fact_at_least()(graph.rs:71-76) already provides the prerequisite check for dialogue/monologue gating (D-035). A POI fact withSuspectsconfidence gates differently from one withKnowsDetails.- 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
StableIdsorting (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:
- 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. - 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) ->KnowsOfwhen 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
Suspectsconfidence 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
KnowledgeGrantedevent, but cap atSuspectsand useKnowledgeSource::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:
DisclosureCandidatessystem applies trait-based filters: aCautiousNPC has a higher confidence threshold before sharing. AGossipyNPC shares lower-confidence entries. This is a filter on the KG query.- Layer 4 line selection uses the filtered candidates to match against dialogue lines. The line's
tagsfield (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:
- Trust threshold met: NPC-player trust >= Surface tier (trust >= 0). From
RelationshipGraph. - Mood permits:
DerivedTellState.categoryis notAngryorGuarded. Angry/Guarded NPCs don't volunteer info. - 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. - Player in proximity: Manhattan distance <= conversation range (3 tiles). Reuse
CONVERSATION_PROXIMITYfrom conversation.rs:36. - Not in active conversation: NPC is not currently in an
NpcConversation(checkOption<&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
DialogueCooldownTrackermechanism. 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
Resourcecounter, 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:
DisclosureCandidatescomponent + 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:
- NPC A tells player "X was at location L1 at time T" ->
EntityKnowledgeentry created withsource: ToldBy { source_id: A, tick: T }andlast_known_position: Some(L1). - Player directly observes X at location L2 at time T' where T' is within a time window of T ->
EntityKnowledgeentry updated withsource: DirectObservation { tick: T' }andlast_known_position: Some(L2). - Detection: same
StableId, two entries? No -- there's only oneEntityKnowledgeperStableIdin 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
ToldBypredecessor. Virtually free.
Sub-question 5: Event emission chain.
The full chain, system by system:
- Perception system (
server/src/perception/) detects entity in LOS. - Knowledge event (
KnowledgeEventType::DirectObservation) pushed toKnowledgeEventQueue. - 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 { ... }).
- In
- Anomaly detection (
detect_anomalies, anomaly.rs:44) runs after knowledge processing.- Finds
KnowledgeState::Contradictedon the entity (anomaly.rs:64). - Inserts
AnomalyMarkercomponent.
- Finds
- Monologue system (monologue.rs) detects
AnomalyMarkerorContradictedstate.- 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.
- Relationship system reads
Contradictedstate 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.
- Shifts the ToldBy source (Sera) to
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:
-
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::KnowledgeGrantedpushed to queue.process_knowledge_eventscreatesEntityKnowledgefor Kael in player's KG:source: ToldBy { source_id: sera_sid, tick: T1 },last_known_position: Some(dock_7_pos),confidence: KnowsOf.
- Dialogue line selected via D-028 pipeline. Line has
-
Detective walks to B-7 corridor and observes Kael.
- Perception system fires
DirectObservation { target: kael_entity, position: b7_pos }. process_knowledge_eventscallsobserve_entity(kael_sid, b7_pos, T2).- Contradiction check fires:
entry.sourceisToldBy,entry.last_known_positionisdock_7_pos, incomingpositionisb7_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.
- Perception system fires
-
Anomaly detection fires.
detect_anomalies(anomaly.rs:44) finds Kael's entry hasstate == Contradicted.AnomalyMarkerinserted on Kael's entity. -
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.sourceto name Sera andcontradicted_claim.positionfor the claimed location.) -
Relationship shift. The contradiction involves
ToldBy { source_id: sera_sid }. System identifies Sera as the source of the contradicted claim. Sera'sRelationshipStateshifts toPersonOfInterest. D-033 color pipeline renders Sera in amber (#e8c547). -
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 perobserve_entitycall wheresourceisToldBy. ~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
- Single
KnowledgeGrantedevent type with compound payload (facts + entities). Don't fragment the event enum. - Piggyback gossip on conversation termination. Don't build a separate propagation system.
- Confidence cap at
KnowsOfon transfer. Gossip chains never produce high-confidence knowledge. - Disclosure as filtered KG query, separate from tell state derivation. Different update frequencies, different concerns.
- NPCs check own KG only for disclosure. No cross-entity KG reads. D-010 principle 2 is non-negotiable.
- MVP boundary: tell_state + disclosure only. Don't retrofit pathfinding or routines.
- Contradiction detection at KG write time in
observe_entity. Event-driven, not polling. - Store contradicted claim for downstream monologue/relationship references. One struct, not a full history.
- Close Q-025. Memory is not a concern at current scale.
- 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.