Files
settled-reach/docs/workshops/knowledge-flow-npc-boundaries/tyre-round1.md
T
jpmschweitzerandClaude Sonnet 4.6 f685cb7324 chore(meta): register D-079–D-083, close Q-024/Q-025/Q-026, ticket Sprint 17 knowledge flow work
Workshop outputs from Knowledge Flow & NPC Information Boundaries workshop (2026-02-24):

Decisions registered in decisions/perception.md:
- D-079: Knowledge Grant Architecture (unified KnowledgeGranted event, Fact+Entity enum, ContentEntityRegistry)
- D-080: NPC-to-NPC Knowledge Propagation (transfer_npc_knowledge system, trust-tier gating, ToldBy source)
- D-081: Unprompted Disclosure Design (DisclosureCandidates component, two-stage trait filter, trigger gates)
- D-082: NPC Information Boundaries MVP Scope (tell_state + disclosure; pathfinding KG integration explicitly not planned)
- D-083: Contradiction Detection Pipeline (event-driven at observe_entity, ContradictionClaim struct, downstream chain)

Questions closed in decisions/questions.md:
- Q-024: closed by D-080 (immediate during conversation, not queued)
- Q-025: confirmed closed (gossip propagation ships Sprint 17; ~12MB peak, no cap needed before v0.3)
- Q-026: closed by D-083 (event-driven at KG write time, ContradictionClaim struct)

Tickets created (#545–#551, Sprint 17, team server):
- #545 KnowledgeGrant schema + ContentEntityRegistry (critical, blocks all downstream)
- #546 KnowledgeGranted event + process_knowledge_events handler
- #547 ContradictionClaim struct + detection in observe_entity
- #548 NPC-to-NPC knowledge transfer system
- #549 tell_state.rs KG awareness — MVP information boundary
- #550 Contradiction monologue + event chain completion
- #551 DisclosureCandidates + unprompted disclosure

Existing tickets updated: #141, #142 (sprint 17 + team assigned, descriptions updated); #172, #173 (full mechanical spec from D-081).

Workshop source files committed: all round docs + workshop-outcomes.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 01:59:58 +01:00

536 lines
37 KiB
Markdown

# 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:
```rust
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.**
```yaml
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:
```rust
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:
```rust
// 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:
```rust
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:
```rust
#[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:
```rust
// 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:
```rust
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:
```yaml
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.