From f685cb7324cf969178049c082261416d4a3bed70 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Feb 2026 01:59:58 +0100 Subject: [PATCH] =?UTF-8?q?chore(meta):=20register=20D-079=E2=80=93D-083,?= =?UTF-8?q?=20close=20Q-024/Q-025/Q-026,=20ticket=20Sprint=2017=20knowledg?= =?UTF-8?q?e=20flow=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- decisions/perception.md | 50 +- decisions/questions.md | 28 +- .../dudley-round1.md | 521 +++++++++++++++++ .../dudley-round2.md | 494 ++++++++++++++++ .../gestalt-round1.md | 467 +++++++++++++++ .../gestalt-round2.md | 377 ++++++++++++ .../paula-round1.md | 309 ++++++++++ .../paula-round2.md | 314 ++++++++++ .../round-1-notes.md | 215 +++++++ .../round-2-notes.md | 236 ++++++++ .../tyre-round1.md | 535 ++++++++++++++++++ .../tyre-round2.md | 470 +++++++++++++++ .../workshop-outcomes.md | 345 +++++++++++ 13 files changed, 4345 insertions(+), 16 deletions(-) create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/dudley-round1.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/dudley-round2.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/gestalt-round1.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/gestalt-round2.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/paula-round1.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/round-1-notes.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/round-2-notes.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/tyre-round1.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/tyre-round2.md create mode 100644 docs/workshops/knowledge-flow-npc-boundaries/workshop-outcomes.md diff --git a/decisions/perception.md b/decisions/perception.md index 17f003af0..4d30139ac 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -398,6 +398,54 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - **Raised by:** Lead (passive panel directive, occlusion filter, monologue separation) - **Dissent:** None +### D-079: Knowledge Grant Architecture +- **Date:** 2026-02-24 +- **Decision:** All knowledge input (dialogue testimony, physical evidence, POI discovery, NPC-authored initial knowledge) flows through a single `KnowledgeEventType::KnowledgeGranted` event type. No separate event types for different grant sources. The `KnowledgeGrant` YAML schema is extended to an untagged enum supporting `Fact { fact_id, confidence }` and `Entity { entity_ref, attributes, confidence }` variants. The source field (`KnowledgeSource`) distinguishes provenance: `ToldBy { source_id: StableId, tick }` for NPC testimony, `DirectObservation { tick }` for physical evidence, `Heard { tick, range }` for overheard conversations. Grants fire at line selection time in `process_talk_interaction`, server-side, pushed to `KnowledgeEventQueue` and processed on the same tick. A `ContentEntityRegistry` resource (`BTreeMap`, populated at NPC spawn time) resolves `entity_ref` strings to `StableId`s at grant processing time. Content-load validation enforces: confidence strings parse to valid `KnowledgeConfidence` variants; fact_ids conform to `"category.topic"` format; entity_refs resolve to registered StableIds. Runtime guardrail: if the granting NPC's KG does not contain the fact being granted, the grant is dropped with `tracing::warn!`. +- **Scope note — `Compound` grant variant:** `Compound { grants: Vec }` (multiple grants from a single line) deferred to Sprint 18. Sprint 17 ships `Fact` and `Entity` variants only. Sprint 18 `EntityGrant { attributes: BTreeMap }` full variant is committed scope, not aspirational (Paula condition, 2026-02-24). +- **Rationale:** Unified event type keeps the knowledge system composable. Grant timing at line selection (not client display) ensures D-010 determinism — tick-stamped and server-authoritative. Entity grants are required for contradiction detection: testimony must create `EntityKnowledge` with `ToldBy` source so that a subsequent `DirectObservation` can detect a discrepancy. Physical evidence uses the same mechanism with `DirectObservation` source, which is treated as higher-confidence and cannot be contradicted by the NPC-denial path. +- **Raised by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous on grant timing and unified event type. Entity grants in Sprint 17 per team lead decision (2026-02-24), overriding Paula's Round 2 acceptance of Dudley's FactId workaround. +- **Dissent:** Paula (Round 2) accepted the FactId workaround with 3 binding conditions; team lead overrode in favour of entity grants path championed by Tyre, Gestalt, and Dudley. Paula's conditions honoured where applicable: (1) `ToldBy` source flows through `ContradictionDetected` event payload — satisfied by D-083; (2) Sprint 18 entity grants are committed scope; (3) formal record of this commitment. +- **Implements:** Tickets #545 (schema), #546 (event + handler) +- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline), [D-083](#d-083-contradiction-detection-pipeline) + +### D-080: NPC-to-NPC Knowledge Propagation +- **Date:** 2026-02-24 +- **Decision:** NPC-to-NPC knowledge transfer is implemented via a `transfer_npc_knowledge` Bevy system that runs `after(run_npc_conversations)`. The system uses `kg_query.get_many_mut([entity_a, entity_b])` for dual-mutable KG access. Knowledge transfer occurs once per conversation at conversation start, not per-line. Rate: flat `rng.random_range(1..=3)` facts per conversation, drawn from eligible entries sorted by `last_updated_tick` descending (most recent first). Trust-gated filtering: `RelationshipEdge.trust` value determines eligible entries (None tier < 0: no transfer; Surface 0–3: Active facts at KnowsOf+ only; Real 3–7: facts at any confidence + entity observations; Secret 7–10: all Active entries). Confidence cap: `transferred_confidence = min(source_confidence, KnowledgeConfidence::KnowsOf)`. Facts with `disclosure_blocked: true` are never transferred regardless of trust tier. Source construction: `KnowledgeSource::ToldBy { source_id: speaker_sid, tick: time.tick }`. Player overhearing: when the player is within `VOICE_RANGE_TILES` (8) of a knowledge-transferring NPC pair, the player gains entity-level KG entries at `Suspects` confidence with `KnowledgeSource::Heard { tick, range: Medium }`. Specific overheard fact transfer to player deferred to Sprint 18. +- **Rationale:** Reuses `run_npc_conversations` proximity detection, deterministic StableId-sorted pairing, and conversation lifecycle — avoids building a separate propagation system. Confidence capping at `KnowsOf` prevents gossip chains from amplifying information. `ToldBy` source construction is the prerequisite for contradiction detection. The `disclosure_blocked` flag models secrets whose sharing is existentially dangerous regardless of trust (D-034 NPC design requirement). +- **Resolves:** Q-024 +- **Raised by:** Workshop — unanimous +- **Dissent:** None +- **Implements:** Ticket #548 +- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-083](#d-083-contradiction-detection-pipeline), [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter) + +### D-081: Unprompted Disclosure Design +- **Date:** 2026-02-24 +- **Decision:** Unprompted disclosure is implemented as a filtered KG query producing a `DisclosureCandidates` component, consumed by Layer 4 of the dialogue pipeline. The system runs as `derive_disclosure_candidates` (Active-tier NPCs within player range only; recomputed every 30 ticks via `computed_tick` freshness check). NPCs check their own KG only — no cross-entity KG queries (D-010 principle 2). Candidate selection filters: Active state only; confidence ≥ KnowsOf (Cautious trait raises threshold); not in `per_fact_history` (not already disclosed this cooldown period); `disclosure_blocked != true`. Trait two-stage filter: Stage 1 (what) filters candidate pool by trait-based predicates (Cautious raises confidence floor, Gossipy lowers it, Loyal suppresses facts about protected entities, Talkative overrides witness inhibition). Stage 2 (how) biases line pool scoring in Layer 4. Trigger gates (all must pass): trust tier ≥ Surface toward player; mood not Hostile; contentment ≥ −10; candidates not empty; witness inhibition (no non-trusted NPCs within 5 tiles, OR NPC-player trust = Secret tier, OR Talkative trait override); location privacy (`disclosure_context` tag compatible with current zone type: `private`/`semi_private`/`any`); per-NPC cooldown (300 ticks / 30 game-minutes) not active; global rate limit not exceeded. Global rate limit: 1 disclosure per 10 ticks maximum across all NPCs; when multiple candidates ready in same window, select by ascending StableId (deterministic, D-010). Rate limiting layers: (1) per-fact per-NPC `per_fact_history` — primary; (2) per-NPC 300-tick cooldown — secondary; (3) global 1/10-tick cap — tertiary. +- **Rationale:** NPCs checking only their own KG (Option A) is both diegetically correct and mechanically richer: NPCs can say things the player already knows, which creates dramatic irony. Option B (NPC checks player's KG) would collapse this asymmetry at exactly the moments their speech would be most dramatically charged. Separate `DisclosureCandidates` component prevents DerivedTellState serialization bloat. The location privacy gate creates learnable spatial behavior patterns. The per-fact `per_fact_history` is the primary narrative quality gate. +- **Raised by:** Workshop — unanimous on architecture; Paula and Gestalt on trigger gates; Tyre and Dudley on implementation structure +- **Dissent:** Gestalt (Round 1) opposed global rate limit as creating invisible NPC competition. Resolved in Round 2 via deterministic StableId selection. Included. +- **Implements:** Tickets #551, #172, #173 +- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-082](#d-082-npc-information-boundaries--mvp-scope), [D-034](content.md#d-034-the-friend-npc-archetype) + +### D-082: NPC Information Boundaries — MVP Scope +- **Date:** 2026-02-24 +- **Decision:** Sprint 17 MVP information boundary scope: (1) `tell_state.rs` reads NPC relationship state from KG for OTHER-entity state (replaces direct `relationships.entries` read for Friendly tell derivation); self-axis components (Secret, Contentment, Tolerance, Mood) continue as ground-truth reads. (2) Unprompted disclosure (D-081) uses KG for candidate selection. No other system retrofits in Sprint 17. `derive_tell_state` adds `Option<&KnowledgeGraph>` to query; `None` falls through to current axis-based behavior (existing tests continue passing). All KG-driven behavior scoped to `With` — Background-tier NPCs receive no KG-based boundary changes. Deferred: conversation partner KG-awareness check (Sprint 18 candidate, `kg.knows_entity(&partner_sid)` in pairing loop); routine KG awareness (v0.2+); pathfinding KG integration (not planned — failure mode has no safe recovery). Fallback on missing KG entry: fall through to ground truth with `tracing::debug!` structured logging. +- **Rationale:** The minimum viable boundary produces maximum gameplay-visible difference for minimum risk. Pathfinding from KG has an unresolvable failure mode (NPC forgets path nodes = stuck NPCs = undefined movement behavior) and must not be implemented. Self-knowledge always comes from axis components because the NPC is the continuous observer of its own state. The `Option<&KnowledgeGraph>` addition is backward-compatible. +- **Raised by:** Workshop — unanimous +- **Dissent:** None. Paula adds that `tell_state` should eventually incorporate KG-derived secret-exposure intensity — deferred to a future sprint as enhancement. +- **Implements:** Tickets #549 (step 1), #551 (step 2), ticket #142 +- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-081](#d-081-unprompted-disclosure-design), [D-026](architecture.md#d-026-npc-simulation-tier-system) + +### D-083: Contradiction Detection Pipeline +- **Date:** 2026-02-24 +- **Decision:** Contradiction detection is event-driven at KG write time — fires inside `observe_entity()` (`server/src/knowledge/graph.rs`) before overwriting an existing entry. Detection algorithm: if the existing entry has `source: ToldBy { tick: told_tick }` AND `last_known_position` differs from incoming position AND `|current_tick - told_tick| < CONTRADICTION_WINDOW_TICKS` (default 600; configurable per contradiction type), then: (1) `entry.state = KnowledgeState::Contradicted`; (2) `entry.contradicted_claim = Some(ContradictionClaim { source: entry.source.clone(), claimed_position: entry.last_known_position, detected_at_tick: current_tick })`; (3) proceed to write new observation. A `ContradictionDetected { observer, entity_sid, source_display_name: Option, subject_display_name: Option }` event is pushed; display names are resolved at detection time via `EntityRegistry + NpcName` so the monologue system is a pure string consumer. `EntityKnowledge` gains one new field: `pub contradicted_claim: Option` with `#[serde(skip_serializing_if = "Option::is_none")]` and `#[serde(default)]` — backward-compatible, zero migration required. Downstream event chain: `ContradictionDetected` → monologue system (named contradiction line) → relationship shift (`ToldBy` source entity → `PersonOfInterest`) → D-033 amber via existing pipeline → `AnomalyMarker` via `detect_anomalies` (already tested). Both the `ToldBy` entry and the `DirectObservation` entry receive `Contradicted` state (epistemic neutrality). Location contradiction is automatic (Sprint 17). Attribute contradiction uses content-authored YAML pairs (Sprint 18). Fact contradiction uses content-authored `contradicts` field on KnowledgeGrant entries (Sprint 18). `CONTRADICTION_WINDOW_TICKS = 600` (1 game-hour) as default constant, with content-authored override per claim type. +- **Rationale:** Event-driven detection avoids per-tick KG scan overhead (99% of ticks have no new information). The `ContradictionClaim` struct (typed) was chosen over string encoding in `known_attributes`: the struct is accessed with direct field reads (no string parsing in the detection hot path); the monologue system needs a typed `KnowledgeSource` to resolve the source entity's name; string encoding in `known_attributes` mixes semantic metadata with internal bookkeeping; one optional struct field is additive (no breaking changes, no migration). Steps 2–5 of the downstream chain (anomaly marker, D-033 color, relationship shift, monologue selection) are already implemented and passing tests. Contradiction detection is the missing link that enables THE FRIEND arc. +- **Resolves:** Q-026 +- **Raised by:** Workshop — unanimous on architecture and downstream chain; Tyre and Gestalt on struct approach; Dudley conceded Option B (string encoding). +- **Dissent:** Dudley (Round 1): Option B string encoding as Sprint 17 workaround. Conceded in Round 2. No standing dissent. +- **Implements:** Tickets #547 (struct + detection), #550 (monologue + event chain) +- **Cross-reference:** [D-034](content.md#d-034-the-friend-npc-archetype), [D-033](perception.md#d-033-entity-color--relationship-to-player), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-079](#d-079-knowledge-grant-architecture), [D-080](#d-080-npc-to-npc-knowledge-propagation) + --- -*32 decisions. Last updated: 2026-02-20 (D-061: amended with unified conversation log architecture from Sprint 14 #535)* +*37 decisions. Last updated: 2026-02-24 (D-079–D-083: Knowledge Flow & NPC Information Boundaries Workshop)* diff --git a/decisions/questions.md b/decisions/questions.md index 6d327cd35..d2dbf7d79 100644 --- a/decisions/questions.md +++ b/decisions/questions.md @@ -131,25 +131,23 @@ Tracked questions awaiting discussion or resolution. - **Source:** Architecture Review Audit 2026-02-11 ### Q-024: Gossip propagation timing -- **Status:** Open (preferred direction: queued) -- **Question:** When does NPC-to-NPC knowledge transfer occur? Two options: (1) Immediate during conversation — knowledge transfers instantly when two NPCs talk, more reactive but harder to predict. (2) Queued for routine intersection — knowledge transfers at scheduled meeting points (bar visits, shift changes), more predictable and player can exploit timing windows. -- **Preferred direction:** Queued approach aligns better with core gameplay. If gossip propagates at routine intersections, the player can observe NPCs meeting and predict information flow, time actions between intersection points to exploit windows of ignorance, and strategically attend/skip routine events to control what they overhear. Creates the "I need to be at the bar before shift change to see who talks to whom" mechanic. Immediate propagation makes gossip invisible and unpredictable; queued propagation makes it observable and exploitable, which is the design goal (D-010 principle 2, D-028 Layer 4). -- **Context:** Deferred to Sprint 3. Making this decision now without implementation experience would be premature. Gossip propagation is not in Sprint 2 scope (D-041). -- **Assigned to:** Gestalt, Tyre -- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt Round 1) +- **Status:** Resolved → [D-080](perception.md#d-080-npc-to-npc-knowledge-propagation) +- **Resolution:** Knowledge transfer occurs via a separate `transfer_npc_knowledge` Bevy system running `after(run_npc_conversations)`. Transfer fires once per conversation at conversation start (immediate during conversation, not queued). Rate: 1–3 facts drawn by recency. Trust-tier gated. See D-080 for full specification. +- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous. 2026-02-24. +- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt Round 1); resolved in Knowledge Flow & NPC Information Boundaries Workshop Round 2. ### Q-025: Knowledge graph cap and eviction strategy -- **Status:** Resolved — not needed for v0.1/v0.2. Revisit if gossip propagation (Q-024) causes unbounded growth. +- **Status:** Resolved — no cap or eviction needed for v0.1/v0.2. Re-evaluation trigger: Active NPC count > 200 OR KG memory exceeds 50 MB. - **Question:** At what point does an NPC's knowledge graph need entry eviction? What is the eviction policy? -- **Resolution (2026-02-23):** The v0.1 Gauntlet has ~16 NPCs. At worst case (every NPC knows every other NPC + 50 facts), total KG memory is ~30 KB. The D-041 workshop estimate of ~1.1 MB for 80 Active NPCs still holds. `ToldBy` source (the only mechanism that could cause unbounded growth via gossip chains) is not yet implemented — NPC-to-NPC knowledge transfer does not exist. The existing decay system (`decay_knowledge` in `knowledge/events.rs`) downgrades confidence and marks entries `Stale` but does not remove them, which is correct behavior (preserves "I used to know X" for narrative). No cap or eviction is needed at current or projected v0.1 scale. If gossip propagation ships (Q-024) and causes growth concerns, the simplest eviction policy is: on each decay pass, if `entities.len() > MAX_ENTITIES`, remove `Stale` entries with lowest `last_updated_tick`. The BTreeMap makes this a clean O(N) scan. -- **Source:** Knowledge Graph & Information Boundaries Workshop (Dudley Round 1, section 8.3). Closed by architecture audit 2026-02-23. +- **Resolution (2026-02-24, confirmed by Knowledge Flow workshop):** Current analysis: ~14 KB per Active NPC KG (50 entities + 20 facts, D-041 budget). 80 Active NPCs = ~1.1 MB. 2,000 Background NPCs at 10 entries = ~5 MB. Total ~6 MB. With gossip propagation shipping in Sprint 17 (D-080, 1–3 facts per conversation): estimated ~12 MB peak at current NPC counts. Neither re-evaluation condition expected before v0.3. The existing decay system (`decay_knowledge` in `knowledge/events.rs`) downgrades confidence and marks entries Stale but does not remove them — correct behavior (preserves "I used to know X" for narrative). If eviction becomes necessary, simplest policy: on each decay pass, if entities.len() > MAX_ENTITIES, remove Stale entries with lowest last_updated_tick. BTreeMap makes this O(N). +- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — Tyre, Gestalt, Dudley confirmed; Paula non-objection noted. 2026-02-24. +- **Source:** Knowledge Graph & Information Boundaries Workshop (Dudley Round 1, section 8.3). Architecture audit 2026-02-23. Workshop confirmation 2026-02-24. ### Q-026: Contradiction detection algorithm -- **Status:** Open -- **Question:** How exactly does the system detect that two knowledge entries contradict each other? Options: (1) Content-authored contradiction pairs (explicit markup in content files: "fact A contradicts fact B"), (2) Automatic same-subject different-value detection (algorithmic comparison of EntityKnowledge fields), (3) Hybrid (author-marked contradictions plus automatic location/state conflicts). How granular should contradiction detection be? Entity location only, or also attributes, relationships, facts? -- **Context:** THE FRIEND arc (D-034, D-039 wow moment #3) requires contradiction detection for emotional impact. The canonical example: Sera tells detective "Kael was at the dock during second shift" (ToldBy source), then detective observes Kael in corridor B-7 at that time (DirectObservation source). System must detect location contradiction, set both entries to Contradicted state, emit monologue event, and shift relationship to PersonOfInterest. -- **Assigned to:** Gestalt, Paula, Dudley -- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt/Paula Round 1) +- **Status:** Resolved → [D-083](perception.md#d-083-contradiction-detection-pipeline) +- **Resolution:** Event-driven detection at KG write time in `observe_entity()`, using `ContradictionClaim` struct. Location contradiction is automatic (Sprint 17): position comparison + time window (CONTRADICTION_WINDOW_TICKS = 600). Attribute and fact contradiction are content-authored (Sprint 18). Both ToldBy and DirectObservation entries receive Contradicted state (epistemic neutrality). `ContradictionDetected` event → monologue with resolved display names → relationship shift → AnomalyMarker via existing pipeline. +- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous on architecture. 2026-02-24. +- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt/Paula Round 1); resolved in Knowledge Flow & NPC Information Boundaries Workshop Round 2. ### Q-027: Fast-travel system design - **Status:** Open @@ -172,4 +170,4 @@ Tracked questions awaiting discussion or resolution. --- -*28 questions (3 resolved, 2 partially resolved, 23 open). Last updated: 2026-02-23* +*28 questions (6 resolved, 1 partially resolved, 21 open). Last updated: 2026-02-24* diff --git a/docs/workshops/knowledge-flow-npc-boundaries/dudley-round1.md b/docs/workshops/knowledge-flow-npc-boundaries/dudley-round1.md new file mode 100644 index 000000000..99cbd710f --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/dudley-round1.md @@ -0,0 +1,521 @@ +# Workshop Round 1 — Dudley: Implementation Feasibility Analysis + +**Workshop:** Knowledge Flow & NPC Information Boundaries +**Author:** Dudley (Server Developer) +**Date:** 2026-02-23 +**Focus:** Rust implementation feasibility, integration with existing systems, edge cases + +--- + +## Framing + +After reading all referenced files, I can confirm Tyre's audit is accurate and complete. The simulation guarantees state consistency for everything it already touches — the problem is the empty spaces where state transitions have no code to run. My job here is to determine the precise implementation shape for each gap, flag edge cases, and push back on anything that will create determinism problems. + +One constraint runs through all five topics: **every state mutation must flow through an event or a system, never through direct component mutation in a calling system.** This is D-010 principle 4. Any design that requires "update the KG in the dialogue handler" instead of "push an event for the KG system to process" is inadmissible. I'll note where this creates architectural pressure. + +--- + +## Topic 1: Knowledge Flow — The Grant Mechanism + +### State of the code + +`server/src/content/types.rs` lines 494-498: +```rust +pub struct KnowledgeGrant { + pub fact_id: String, + pub confidence: String, +} +``` + +`server/src/content/line_pool.rs` line 297: +```rust +pub knowledge_grant: Option, +``` + +The field is deserialized and indexed, but nothing reads it at runtime. The grant never fires. + +### Where the grant fires: line selection, not client display + +The grant must fire server-side at line selection — not when the client displays the text. Reasons: + +1. D-010 principle 4: deterministic simulation requires state changes to occur at a specific tick, tied to an input event (the Talk verb). Client display timing is non-deterministic. +2. The `process_talk_interaction` system already has access to the selected `IndexedDialogueLine`. This is the natural injection point. +3. Firing on client display would require a client→server acknowledgment round-trip, adding IPC complexity. + +**Implementation path:** After the weighted line selection in `process_talk_interaction`, if `selected_line.knowledge_grant.is_some()`, push a `KnowledgeEventType::KnowledgeGranted` event into `KnowledgeEventQueue`. The existing `process_knowledge_events` system (events.rs line 85) handles it on the same tick. + +### New event type + +`server/src/knowledge/events.rs` — add to `KnowledgeEventType` enum: + +```rust +KnowledgeGranted { + recipient: Entity, // who receives the knowledge + fact_id: FactId, + confidence: KnowledgeConfidence, + source_id: StableId, // NPC who told them (ToldBy) +} +``` + +The `recipient` is the observer entity (player or NPC). The `source_id` is the NPC's StableId — available via `registry.to_stable(npc_entity)` at the point of line selection. + +### Confidence string parsing + +`KnowledgeGrant.confidence` is a `String` in the YAML schema. We need a parsing step to convert `"KnowsOf"` → `KnowledgeConfidence::KnowsOf`. I recommend this at content index time (when `IndexedDialogueLine` is built), not at runtime grant time. A `TryFrom<&str> for KnowledgeConfidence` implementation on the type is ~6 lines. + +If parsing fails on an unknown confidence string, the content indexer should emit a validation error and reject the YAML file. Do not silently default — silent defaults hide content author errors. + +### Entity grants vs fact grants + +The current `KnowledgeGrant` schema only covers facts (`fact_id`). The brief asks whether entity-knowledge grants are needed. + +**My position: defer entity grants to Sprint 18.** The mechanics required for Sprint 17 (#141, #149 POI discovery) are fact-based. Entity grants introduce a harder problem: the content author must specify the target entity by `fact_id`-style reference (e.g., `"entity.kael"`) which requires a separate entity name registry that doesn't exist yet. + +For THE FRIEND arc (D-034), Sera says "Kael was at the dock during second shift." The grant here is: `FactId("entity.kael.location.second_shift_dock")` with confidence `KnowsOf`. This is a fact grant that encodes the entity + location + time context as a structured fact ID. The contradiction detector can then match it against the player's direct observation. This approach works without entity grants. + +The `EntityKnowledge.known_attributes` field (`BTreeMap` in types.rs line 171) can receive population via fact grants that encode attribute updates — but this is awkward. A proper entity grant schema is a Sprint 18 design task. + +### POI discovery + +Option (a) — `FactId("poi.dock_7_restricted")` — is the correct choice. Reasons: + +1. POIs are locations/things, not entities. `FactKnowledge` (types.rs line 177) models exactly this. +2. `filter_by_access` (`KnowledgeGated` variant, graph.rs line 299) already gates on `FactId` presence. POI visibility gating works immediately with no new access control code. +3. The `"poi.*"` namespace is self-documenting in YAML. +4. No new type is needed. `KnowledgeGraph.facts` already stores arbitrary `FactId`. + +The POI discovery system (#149) pushes a `KnowledgeGranted` event with `fact_id = FactId("poi.dock_7_restricted")` when the player enters the POI's trigger zone. Same event type, same queue, same processing system. No special-casing. + +### Physical evidence discovery + +Physical evidence (terminals, documents, manifests) should use the same `KnowledgeGranted` event, not a separate `EvidenceDiscovered` type. Adding a new event type when the existing mechanism handles it cleanly violates the simulation's guarantee of a minimal, composable event set. + +The *rendering* distinction between "NPC told you" and "you found a document" is a client-side concern. Server-side, both are `KnowledgeGranted` with different `KnowledgeSource` variants: `ToldBy` vs `DirectObservation`. The source is already tracked per-entry (types.rs line 88-102). + +### Content author guardrails + +At content index time (when `LinePoolIndex` is built), validate: +1. `confidence` string parses to a valid `KnowledgeConfidence` variant +2. `fact_id` conforms to the `"category.topic"` format + +Runtime enforcement (NPC can only grant facts it knows) is **Tier 3 difficulty** and should not be Sprint 17 scope. It requires cross-entity KG queries at line selection time, which adds significant query complexity to an already-complex system. + +For Sprint 17: authoring-time validation only. Document the constraint: "NPCs should only grant facts consistent with their character background and role." This is enforced by content review, not runtime checks. + +### Summary: Topic 1 implementation cost + +- New `KnowledgeEventType::KnowledgeGranted` variant: **~30 lines** +- `process_knowledge_events` handler for new variant: **~20 lines** +- `TryFrom<&str> for KnowledgeConfidence` parsing: **~10 lines** +- Hook in `process_talk_interaction` after line selection: **~15 lines** +- Content index validation: **~20 lines** +- Total: **~95 lines of new code**, zero schema changes, zero breaking changes + +**Tyre's 2-day estimate is accurate for the fact-only path.** + +--- + +## Topic 2: NPC-to-NPC Knowledge Propagation (Q-024) + +### The conversation system is the right hook + +`run_npc_conversations` at `server/src/simulation/conversation.rs` line 278+ already provides: +- Proximity pairing with deterministic StableId sort (line 321-324) +- Cooldown management (line 314-319) +- Conversation lifecycle (start tick, end tick, line timing) + +This satisfies "queued at routine intersections." No separate system needed for the pairing logic. + +### The critical ECS constraint + +`run_npc_conversations` currently queries NPCs with: +```rust +npc_query: Query<(Entity, &TilePosition, Option<&NpcName>, ...), (With, With)> +``` + +There is no `&mut KnowledgeGraph` in this query. To perform knowledge transfer, we need mutable access to the KGs of both conversation participants. **Bevy ECS does not allow two mutable borrows of the same component type from a single query in the same system.** This is the primary structural constraint. + +**Solution: separate `transfer_npc_knowledge` system.** This system runs immediately after `run_npc_conversations` completes, reads the `NpcConversation` components (which identify both participants), and performs the transfer. The system ordering guarantee: `transfer_npc_knowledge.after(run_npc_conversations)` ensures transfer happens on the same tick as conversation initiation. + +The transfer system structure: +```rust +pub fn transfer_npc_knowledge( + time: Res, + conversations: Query<(Entity, &NpcConversation, Option<&StableEntityId>), With>, + mut kg_query: Query<&mut KnowledgeGraph>, + relationship_query: Query<&Relationships, With>, + mut event_queue: ResMut, +) { ... } +``` + +Using `kg_query.get_many_mut([entity_a, entity_b])` — bevy_ecs supports this for distinct entities. + +### Trust-gated filtering and confidence downgrade + +Trust tier mapping (D-028 to NPC-NPC transfer): + +| Trust level | What A shares with B | +|-------------|---------------------| +| `surface` (trust_level 0-2) | `KnowledgeState::Active` facts with `confidence >= KnowsOf` only | +| `real` (trust_level 3-6) | Also `Suspects` facts + entity knowledge at `KnowsOf` or above | +| `secret` (trust_level 7-10) | Also entity knowledge at `Suspects` (rumors) | + +Confidence downgrade: +```rust +let transferred_confidence = source_entry.confidence.min(KnowledgeConfidence::KnowsOf); +``` + +This works because `KnowledgeConfidence` derives `Ord` with load-bearing ordering assertions at types.rs lines 50-55. `Direct` downgrades to `KnowsOf`. `KnowsDetails` downgrades to `KnowsOf`. `Suspects` stays `Suspects`. `KnowsOf` stays `KnowsOf`. The cap is clean and prevents knowledge amplification through gossip chains. + +### Rate limiting + +1-3 facts per conversation, drawn via `rng.rng.random_range(1..=3)` at conversation start. The cap goes in `NpcConversation`: +```rust +pub struct NpcConversation { + ... + pub knowledge_transfers_remaining: u8, // set at start, decremented per fact +} +``` + +Transfer happens once per conversation (at conversation start tick), not on each line emission. The conversation is already tracked; we transfer when `NpcConversation` is first attached. + +### ToldBy source construction + +`KnowledgeSource::ToldBy { source_id: StableId, tick: u64 }` (types.rs line 97) is defined but never constructed. Construction point: + +```rust +let source_sid = registry.to_stable(entity_a) + .expect("NPC in conversation must be registered"); +let source = KnowledgeSource::ToldBy { + source_id: source_sid, + tick: time.tick, +}; +``` + +The StableId is available. The tick is in `SimulationTime`. This is a 2-line construction. The `expect()` is justified — any NPC in an active conversation must have been registered at spawn. + +### Overheard knowledge by player + +The overheard grant should fire for the player entity when `distance <= VOICE_RANGE_TILES` AND the conversation had a knowledge transfer. The player's confidence should be `Suspects` unconditionally — the player doesn't know what was said, only that information was exchanged. + +**Implementation constraint:** the player's overheard grant requires knowing which facts NPC A transferred to NPC B during the conversation. This means the transfer system must also push a `KnowledgeGranted` event for the player (or: record the transfer in a `RecentConversationTransfer` resource that the observer system reads). + +For Sprint 17: **overheard knowledge at `Suspects` for the NPC entity (not the specific facts).** The player learns "Kael was being discussed" not "Kael was discussed in relation to dock schedules." The specific fact transfer to player can wait for Sprint 18 when content-authored conversation lines replace placeholder lines. + +### Summary: Topic 2 implementation cost + +- New `transfer_npc_knowledge` system: **~80 lines** +- `NpcConversation` struct addition (`knowledge_transfers_remaining`): **~5 lines** +- Trust-tier filter helper: **~30 lines** +- Overheard entity grant (Suspects confidence): **~20 lines** +- Total: **~135 lines**, no breaking changes + +**Tier 1 for the core transfer. Tier 2 for overheard fact precision.** Q-024 resolves with the core transfer. + +--- + +## Topic 3: Unprompted Disclosure Design (#172) + +### Architecture constraint: where does disclosure live? + +The dialogue pipeline layers in D-028 are: access → situation → trust → topic+mood scoring. Layer 4 is "unprompted disclosure." Currently Layer 4 only fires when the player initiates (Talk verb creates `TalkRequest`). + +For unprompted disclosure, the NPC initiates. This means: +1. A new system `derive_disclosure_candidates` runs each tick on Active-tier NPCs with a KG +2. It writes `DisclosureCandidates` component (new component, per-NPC) +3. A new system `process_unprompted_disclosure` checks whether conditions are met, then pushes a dialogue line to `DialogueResponseBuffer` (or a new `UnpromptedDisclosureBuffer`) + +### DerivedTellState vs separate component + +I recommend a **separate `DisclosureCandidates` component** rather than adding `disclosure_candidates: Vec` to `DerivedTellState`. Reasons: +1. `DerivedTellState` is read by the observer snapshot system — adding a `Vec` to it would serialize unnecessary data over the bridge +2. `DisclosureCandidates` may be empty (and often will be) — keeping it separate avoids allocating a `Vec` on every Active NPC every tick + +```rust +#[derive(Component, Debug, Default)] +pub struct DisclosureCandidates { + pub candidates: Vec, + pub last_derived_tick: u64, +} +``` + +### Candidate selection algorithm + +``` +For each Active NPC with KG: + candidates = npc_kg.known_facts_iter() + .filter(|(_, fact)| fact.state == KnowledgeState::Active) + .filter(|(_, fact)| fact.confidence >= KnowsOf) + .filter(|(fact_id, _)| !matches trust gate against player relationship) + .take(3) + .collect() +``` + +The NPC does NOT check the player's KG (Option A). This is: +1. Correct per D-010 principle 3 — no entity-special-casing +2. Realistic — NPCs don't know what you know +3. Architecturally clean — avoids cross-entity KG queries in a per-NPC system + +Repeated disclosure of known facts is acceptable. The rate limiting (see below) prevents spam. + +### Trait filtering (#173) + +From my implementation perspective: traits should filter WHAT is disclosed, not just HOW. The reason is architectural: if traits only affect delivery, they're a line-pool modifier and we already have that mechanism via `tags`. But if a cautious NPC systematically withholds certain fact categories, that requires filtering the `DisclosureCandidates` set — a KG-level operation. + +Implementation: traits become a `candidate_filter` function applied to the candidates list. A `Cautious` trait drops facts with `source == ToldBy` (NPC won't pass on rumors). A `Gossipy` trait includes `Suspects` confidence facts. This is a trait-to-filter-predicate mapping, ~20 lines per trait. + +### Trigger conditions + +My proposed conditions (as system checks in `process_unprompted_disclosure`): +1. `trust_toward_player >= SURFACE_THRESHOLD` (read from NPC's `Relationships` component) +2. `mood_state != Hostile` (hostile NPCs don't volunteer info) +3. `DisclosureCandidates.candidates.is_not_empty()` +4. Per-NPC disclosure cooldown not active (new `DisclosureCooldown` component) + +The `SURFACE_THRESHOLD` should be configured per-NPC role (not global). Content-authored as a YAML field. + +### Rate limiting + +The existing `LINE_COOLDOWN_TICKS = 600` (dialogue.rs line 39) applies to individual lines. For unprompted disclosure, we also need a per-NPC cooldown to prevent one NPC bombarding the player: + +```rust +#[derive(Component, Debug)] +pub struct DisclosureCooldown { + pub until_tick: u64, + pub last_disclosed_facts: BTreeSet, // prevent fact repetition +} +``` + +The `last_disclosed_facts` set caps per-fact disclosure rate. Once a fact has been disclosed, it's excluded from candidates until the cooldown expires. + +Global rate limit (prevent disclosure spam from multiple NPCs simultaneously): a per-tick counter on `SimulationTime` or a resource `DisclosureThisTick: u8`. Cap at 1 unprompted disclosure per tick globally. Simple, deterministic. + +### Summary: Topic 3 implementation cost + +- `DisclosureCandidates` component: **~15 lines** +- `derive_disclosure_candidates` system: **~50 lines** +- `DisclosureCooldown` component: **~15 lines** +- `process_unprompted_disclosure` system: **~60 lines** +- Trait filter predicates (3 basic traits): **~40 lines** +- Total: **~180 lines**, requires Paula + Gestalt to specify candidate selection criteria before implementation + +**Tier 2. The pipeline exists; the NPC-side candidate derivation is the new work.** Cannot implement until this workshop produces: (a) the disclosure candidate selection algorithm and (b) the trait filter/delivery split decision. + +--- + +## Topic 4: NPC Information Boundaries (#142) + +### Priority order with implementation complexity + +| System | Complexity | Value | +|--------|-----------|-------| +| `tell_state.rs` (add KG awareness) | Tier 1 | High | +| `conversation.rs` (KG-aware partner selection) | Tier 2 | Medium | +| `routine.rs` (KG-aware routine execution) | Tier 3 | Low for v0.1 | +| `path_follow.rs` (KG-aware pathfinding) | Tier 3 | Risky — see below | + +### Minimum viable boundary: tell_state.rs + +The `derive_tell_state` system at tell_state.rs line 129 queries six components. Adding `&KnowledgeGraph` to the query is a one-line addition: + +```rust +pub fn derive_tell_state( + mut npcs: Query< + ( + &Secret, + &ToleranceThreshold, + &Contentment, + &MoodState, + Option<&Relationships>, + Option<&RoutineDeviation>, + Option<&KnowledgeGraph>, // new + &mut DerivedTellState, + ), + (With, With), + >, +) { +``` + +The KG awareness change: suppress `Guarded` and `Nervous` tells if the NPC has `FactKnowledge` for `"meta.secret.safe"` or similar fact indicating their secret hasn't been threatened. This is a KG-gate on tell derivation. Practically: if the NPC's own KG has no entry suggesting the secret is at risk, show fewer tells. + +More immediately useful: an NPC that KNOWS the player suspects them (because it has a `ToldBy` entry from another NPC saying "the detective is asking about you") should have elevated stress contributing to tell derivation. This is a new compute path, not just a filter. + +**For Sprint 17 minimum viable: add `Option<&KnowledgeGraph>` to the query but only use it as a null check — NPCs without a KG retain current axis-based behavior, NPCs with a KG can have their tell overridden by specific KG facts.** This preserves all existing tests while opening the KG pathway. + +### Conversation partner selection + +Adding KG awareness to partner selection requires checking: "does NPC A know NPC B exists (i.e., has an EntityKnowledge entry for B's StableId)?" + +The pairing loop at conversation.rs lines 332-371 currently pairs any two proximate eligible NPCs. With KG: pair only if `npc_a_kg.knows_entity(&npc_b_sid)`. + +**Edge case:** brand-new NPCs have empty KGs. If neither NPC knows the other, they never converse — which breaks the natural "first meeting" scenario. Resolution: NPCs can converse with `Unknown` entities (proximity-driven meeting), but knowledge transfer on first meeting is minimal (only public facts, regardless of trust tier). Trust tier defaults to `surface` when there is no relationship history. + +The query constraint: `run_npc_conversations` at line 284 already has 8 fields in the query tuple. Adding `Option<&KnowledgeGraph>` makes 9. Bevy ECS supports this but query ergonomics degrade. The conversion to KG-aware pairing is Tier 2 because of the query complexity, not because the logic is hard. + +### Pathfinding: DO NOT retrofit for v0.1 + +`path_follow.rs` using KG for walkability creates a failure mode with no safe recovery path in v0.1: NPC forgets a path node is blocked → NPC navigates into a blocked tile → physics/movement system conflicts → undefined behavior. The fallback must be ground truth, and if the fallback is always ground truth, the KG check adds complexity without behavioral change. + +**Recommendation: no pathfinding KG retrofit until v0.2.** Document as deferred decision. + +### D-026 tier interaction + +Background-tier NPCs should receive NO KG-based boundary changes. The `With` filters in both `derive_tell_state` and `run_npc_conversations` already enforce this. The tier boundary is already correct in the existing code. + +### Summary: Topic 4 implementation cost (MVP only) + +- Add `Option<&KnowledgeGraph>` to `derive_tell_state` query + minimal KG gate: **~25 lines** +- KG-aware conversation partner matching (Sprint 17 scope decision needed): **~30 lines** +- Total MVP: **~25-55 lines** depending on scope decision + +**Tier 1 for tell_state MVP. Tier 2 for conversation pairing.** Pathfinding deferred. + +--- + +## Topic 5: Contradiction Detection Pipeline (Q-026) + +### Prerequisite dependency is real and hard + +The detection algorithm requires `ToldBy` entries. Without Topics 1 and 2 being implemented first: +- `KnowledgeSource::ToldBy` is never constructed (confirmed — it exists at types.rs line 97 but no code calls it) +- There is nothing to contradict against `DirectObservation` entries +- The detection system would scan KGs and find zero contradiction candidates on every tick + +**The simulation guarantees: if `ToldBy` sources don't exist, `detect_contradictions` is a no-op. It will not error. But it will also do nothing useful.** Topics 1 and 2 are genuinely blocking. + +### Location contradiction algorithm + +For each entity E in observer KG where `last_known_position.is_some()`: + +``` +// Check all knowledge entries for E +// (currently one entry per entity — extension needed for multi-source tracking) +``` + +**Critical architectural gap:** `EntityKnowledge` (types.rs line 154) is a single struct per target entity. It holds ONE `last_known_position`, ONE `source`, ONE `confidence`. It cannot represent "NPC said X was at Dock" AND "I saw X at Corridor B-7" simultaneously — the second observation overwrites the first. + +**This is the core design problem for contradiction detection.** To detect contradictions, we need to retain multiple conflicting observations. Options: + +A. Add `Vec` to `EntityKnowledge` alongside the primary entry +B. Extend the `known_attributes` map with structured claim keys (e.g., `"claim.tick.1234.position" = "dock_7"`) +C. Create a separate `ContradictionCandidate` component attached to entities that need cross-source comparison + +**My recommendation: Option B for Sprint 17 (lowest structural impact).** Encode position claims as structured attribute strings. The contradiction detector parses them. Ugly but zero schema change. Option A is cleaner and should be the Sprint 18 refactor target. + +Option B implementation: +``` +key: "claim.{tick}.position" +value: "{x},{y},{z},{source_stable_id}" +``` + +Contradiction detection then iterates `known_attributes` keys matching `"claim.*"`, parses the values, and checks for conflicting positions within a configurable tick window. + +### Detection timing + +Event-driven is correct. Run `detect_contradictions` as part of `process_knowledge_events` handling — check for contradictions only when a new `KnowledgeGranted` or `DirectObservation` event is processed. Do not run as a separate per-tick system. + +Implementation: after applying a `KnowledgeGranted` event to the KG, call `check_for_contradictions(&mut kg, target_stable_id, tick)` inline. This is ~40 lines of detection logic added to `process_knowledge_events`. + +### Event emission chain for THE FRIEND arc + +Walk through the full sequence with the proposed design: + +1. **Dialogue selection tick T1:** Sera's line "Kael was at the dock during second shift" is selected. `knowledge_grant` on the line fires → `KnowledgeGranted { fact_id: FactId("entity.kael.position.second_shift"), confidence: KnowsOf, source_id: sera_sid }` pushed to queue. + +2. **`process_knowledge_events` tick T1:** Event processed → `kg.facts.insert(FactId("entity.kael.position.second_shift"), FactKnowledge { confidence: KnowsOf, source: ToldBy { source_id: sera_sid, tick: T1 }, ... })`. Also: add structured attribute claim to Kael's `EntityKnowledge`: `"claim.T1.position" = "dock_7,T1,sera_sid"`. + +3. **`detect_contradictions` check tick T1:** No DirectObservation of Kael at a different position yet. No contradiction. + +4. **DirectObservation tick T2:** Player sees Kael in corridor B-7. `DirectObservation` event → `observe_entity(kael_sid, corridor_B7, T2)`. Also adds structured attribute claim: `"claim.T2.position" = "corridor_b7,T2,direct"`. + +5. **`detect_contradictions` check tick T2:** Parses claims for Kael. `"claim.T1.position" = "dock_7"` vs `"claim.T2.position" = "corridor_b7"`. T2 - T1 < contradiction_window_ticks. Positions differ → **CONTRADICTION DETECTED**. Both KG entries receive `KnowledgeState::Contradicted`. `ContradictionDetected` event pushed (new event type, or reuse `KnowledgeEventQueue` with a new variant). + +6. **`detect_anomalies` tick T3:** Iterates player KG, finds Kael with `state == Contradicted` → attaches `AnomalyMarker` to Kael entity. (anomaly.rs line 64 — this fires on `Contradicted` already.) + +7. **Monologue system tick T3+:** `ContradictionDetected` event triggers selection of contradiction monologue line: "Sera said Kael was at the dock. I just saw him in B-7." (Content team writes this line; the trigger tag fires here.) + +8. **Relationship update:** `set_relationship(&kael_sid, RelationshipState::PersonOfInterest)` → D-033 amber color transition. + +**The simulation guarantees this chain fires correctly given the proposed event additions.** The existing downstream consumers (anomaly.rs, monologue.rs) already handle `Contradicted` state — tested and passing. + +### Attribute contradiction + +`known_attributes: BTreeMap` is untyped (types.rs line 171). Typed attribute keys are a Sprint 18 concern. For Sprint 17: + +- Location contradictions: algorithmic (position claim parsing as described above) +- Attribute contradictions: **content-authored only** — a YAML file defines explicit contradiction pairs. The detector checks a lookup table: `if known_attributes.get("role") == Some("legitimate") && kg.knows_fact(&FactId("entity.kael.smuggler")) → CONTRADICTION`. + +This is a simple O(N) check per KG update, where N is the authored contradiction pair count. + +### New types needed + +```rust +// In events.rs +KnowledgeEventType::ContradictionDetected { + observer: Entity, + entity_a: StableId, + entry_a_source: KnowledgeSource, + entity_b: StableId, // same as entity_a for location contradictions + entry_b_source: KnowledgeSource, + contradiction_type: ContradictionType, +} + +pub enum ContradictionType { + Location, + Attribute { key: String }, + FactConflict { fact_id_a: FactId, fact_id_b: FactId }, +} +``` + +The monologue system subscribes to `ContradictionDetected` events and selects the appropriate content line based on the `ContradictionType`. + +### Summary: Topic 5 implementation cost + +- `EntityKnowledge` structured claim addition (Option B): **~15 lines type + ~20 lines claim recording** +- `detect_contradictions` function (location only): **~60 lines** +- `ContradictionDetected` event variant + type: **~20 lines** +- Monologue subscription to contradiction events: **~30 lines** +- Content-authored attribute contradiction table: **~25 lines + YAML schema** +- Total: **~170 lines**, plus content schema + +**Tyre's 3-day estimate for location detection + event chain is correct.** The critical gap is the `EntityKnowledge` single-entry model — this must be addressed (Option B minimally, Option A properly) before detection can work. + +--- + +## Cross-Topic Dependencies and Implementation Order + +The simulation guarantees a specific ordering constraint: + +``` +Topic 1 (KnowledgeGranted event + dialogue wire) + ↓ enables +Topic 2 (ToldBy source construction + NPC-to-NPC transfer) + ↓ enables +Topic 5 (contradiction detection — needs ToldBy entries to exist) +``` + +Topics 3 and 4 are partially independent: +- Topic 4 MVP (`tell_state.rs` KG awareness) can be implemented in parallel with Topic 1 +- Topic 3 (`DisclosureCandidates` system) requires Topic 2 to have populated NPC KGs with meaningful content +- Topic 3 requires Paula + Gestalt decisions on candidate selection and trait behavior before implementation + +**Recommended implementation order for Sprint 17:** +1. Topic 1 fact grant mechanism (unblocks everything) +2. Topic 4 MVP (tell_state KG awareness — isolated, low risk) +3. Topic 2 NPC-to-NPC transfer (unblocks Topic 5) +4. Topic 5 location contradiction detection (immediately useful for THE FRIEND arc) +5. Topic 3 unprompted disclosure (requires decisions from this workshop + populated NPC KGs) + +--- + +## Open Positions for Round 2 Discussion + +1. **Entity grant schema:** I've proposed deferring to Sprint 18 and encoding entity-knowledge grants as structured FactIds. Tyre's opinion needed on whether the single-entry `EntityKnowledge` model needs an architectural fix before Sprint 17 or can carry the Option B workaround. + +2. **`EntityKnowledge` multi-source model:** The contradiction detection gap (single entry per entity) is the most structurally significant problem in this workshop. My Option B (structured attributes) is a workaround. What's the correct path to Option A (proper multi-claim model)? + +3. **Overheard knowledge precision:** I've proposed `Suspects` confidence for overheard NPC-NPC conversations regardless of occlusion fidelity. Is this the right tradeoff, or does the game need per-fact overheard grants for a mechanically satisfying experience? + +4. **Trait filtering (#173):** My position is traits should filter WHAT is disclosed AND HOW it is delivered. Paula needs to confirm whether the narrative design requires both, or if HOW-only is sufficient for the first NPC characters. + +5. **Q-025 formal closure:** Tyre's 30KB memory analysis is correct at current NPC counts. I support formal closure. At 500+ Active NPCs, the question should be reopened. This can be a one-line decision. diff --git a/docs/workshops/knowledge-flow-npc-boundaries/dudley-round2.md b/docs/workshops/knowledge-flow-npc-boundaries/dudley-round2.md new file mode 100644 index 000000000..905f67f30 --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/dudley-round2.md @@ -0,0 +1,494 @@ +# Workshop Round 2 — Dudley: Cross-Review and Synthesis + +**Workshop:** Knowledge Flow & NPC Information Boundaries +**Author:** Dudley (Server Developer) +**Date:** 2026-02-23 +**Focus:** Tension resolution, implementation ticket specs, implementation order + +--- + +## Reading Summary + +After reviewing all Round 1 outputs: + +- Tyre and Gestalt have converged on architecturally identical solutions for Tension B (`ContradictionClaim` struct). Different field names, same structure. +- Paula makes the blocking case for Tension A (entity grants in Sprint 17) that I cannot dismiss on architectural grounds — only on infrastructure grounds I am now reconsidering. +- Gestalt raises the only substantive dissent (no global disclosure rate limit) that I will address. +- Two open questions are directed at me specifically: the `DisclosureCandidates` compute trigger and the StableId→name lookup in the monologue system. Both have answers. + +--- + +## Tension A: Entity Grants — Position Change + +**I am changing my position. Entity grants are Sprint 17 scope.** + +My Round 1 concern was: entity grants require a "name → StableId" content registry that doesn't exist, and building it is non-trivial. Let me examine this more carefully. + +### The actual infrastructure needed + +Tyre's proposal stores `entity_ref: "kael"` in the YAML and resolves it to `StableId` at content load via a `BTreeMap`. The question I raised: where does this map come from? + +The answer: **resolve at spawn time, not content load time.** When an authored NPC is spawned from its content definition (which includes an authored identifier like `"kael"`), the spawn system inserts `"kael" → StableId(N)` into a new `ContentEntityRegistry` resource. The content definition already has the authored identifier — NPCs need to be referenced in dialogue YAML. The spawn registration is ~5 lines per spawn site. + +At grant processing time, `process_knowledge_events` queries `ContentEntityRegistry` to resolve `entity_id: "kael"` → `StableId(N)`. If the entity is not yet in the registry (not yet spawned), the grant is dropped with `tracing::warn!` and a log entry. This is graceful — no panic, no undefined behavior. + +``` +ContentEntityRegistry: BTreeMap + | + ├── Populated at: NPC spawn from authored content + ├── Read by: process_knowledge_events (entity grant processing) + └── Size: O(authored NPCs) — small, ~20-50 entries for v0.1 +``` + +This is genuinely minimal infrastructure. I was treating it as a large unknown; it is a ~60-line addition. + +### Why my workaround was worse + +My Round 1 alternative — structured FactId strings like `FactId("entity.kael.position.second_shift_dock")` — fails Paula's test: contradiction detection operates on `EntityKnowledge.last_known_position`, not on `FactKnowledge`. A `DirectObservation` updates `EntityKnowledge` (via `observe_entity`). If Sera's testimony only creates a `FactKnowledge` entry, the contradiction detector in `observe_entity` has nothing to compare against. The chain breaks at step 4 of THE FRIEND arc. + +Tyre and Gestalt are correct. Paula's blocking case is sound. + +### Confirmed schema: two-type grant + +For Sprint 17: `FactGrant` and `EntityGrant`. `Compound` (grants that do both simultaneously) can be Sprint 18. + +```yaml +# Fact grant (existing format, backwards-compatible): +knowledge_grant: + fact_id: "poi.dock_7_restricted" + confidence: "knows_of" + +# Entity grant (new format): +knowledge_grant: + entity_ref: "kael" + attributes: + location: "dock-7" + shift: "second" + confidence: "knows_of" + # source is always ToldBy { source_id: speaking_npc_sid, tick } — inferred by system +``` + +The Rust type: + +```rust +// In server/src/content/types.rs — replaces current KnowledgeGrant struct +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum KnowledgeGrant { + Fact { + fact_id: String, + confidence: String, + }, + Entity { + entity_ref: String, + #[serde(default)] + attributes: std::collections::BTreeMap, + confidence: String, + #[serde(default)] + disclosure_blocked: bool, + }, +} +``` + +The `#[serde(untagged)]` attribute allows the existing `fact_id/confidence` YAML format to deserialize as `Fact` variant without any content changes to existing YAML files. Backwards compatible. + +--- + +## Tension B: ContradictionClaim Struct — Position Change + +**I am conceding Option B. The `ContradictionClaim` struct approach is correct.** + +Tyre and Gestalt have proposed structurally identical solutions. My Option B (encoding position claims as structured `known_attributes` strings) has three problems I underweighted: + +1. **String parsing is fragile.** A malformed string `"claim.T1.position" = "dock_7,T1,sera_sid"` silently fails. The struct approach fails loudly at compile time. +2. **It doesn't help the monologue system.** The monologue needs to display "Sera said Kael was at the dock" — which requires `ToldBy { source_id: sera_sid }` as a typed field, not a parsed string. Paula's requirement for source-named contradiction monologues is correct and it requires the struct. +3. **Zero schema changes was a false economy.** One optional field on `EntityKnowledge` is a smaller change than the refactor obligation Option B creates. + +### Agreed struct design + +Aligning on Tyre's naming (`ContradictionClaim`) — the field is specifically "the claim that was contradicted," which is precise: + +```rust +// In server/src/knowledge/types.rs — add to EntityKnowledge +pub struct EntityKnowledge { + // ... all existing fields unchanged ... + + /// When state is Contradicted: the prior claim that conflicts with the + /// current observation. Populated by contradiction detection in observe_entity(). + /// None while state is Active or Stale. + pub contradicted_claim: Option, +} + +pub struct ContradictionClaim { + /// Who made the contradicted claim (typically ToldBy { source_id, tick }). + pub source: KnowledgeSource, + /// Where they claimed the entity was. + pub claimed_position: Option, + /// Tick when the contradiction was detected. + pub detected_at_tick: u64, +} +``` + +Detection in `observe_entity()` at graph.rs line 111, before the overwrite: + +```rust +// BEFORE updating the entry: check for contradiction +if let Some(entry) = self.entities.get_mut(&target) { + if let KnowledgeSource::ToldBy { tick: told_tick, .. } = entry.source { + if let Some(old_pos) = entry.last_known_position { + if old_pos != position + && current_tick.saturating_sub(told_tick) < CONTRADICTION_WINDOW_TICKS + { + entry.state = KnowledgeState::Contradicted; + entry.contradicted_claim = Some(ContradictionClaim { + source: entry.source.clone(), + claimed_position: entry.last_known_position, + detected_at_tick: current_tick, + }); + // Don't return — fall through to update position and source + } + } + } +} +``` + +The `CONTRADICTION_WINDOW_TICKS` constant: **600 ticks (1 game-hour)**. This is Gestalt's proposal. Rationale: within a game-hour, a claim about "where Kael was during second shift" is not stale — second shift hasn't ended. Beyond a game-hour, the decay system should mark entries Stale, not Contradicted. The contradiction is a fresh conflict, not an archaeology project. + +--- + +## Open Questions Directed at Dudley — Answers + +### DisclosureCandidates compute trigger + +Gestalt asks: is there a "player entered dialogue range" event or does this need a range-query every N ticks? + +**Answer: range-query in the existing Active NPC processing loop, every tick, gated on distance.** + +There is no "player entered dialogue range" event — the game doesn't have a proximity-event system. The disclosure system should piggyback on the conversation system's existing proximity check pattern: + +```rust +// In derive_disclosure_candidates system: +// Query Active NPCs within CONVERSATION_PROXIMITY of the player. +// Same O(N_active) scan the conversation system already does. +let player_pos = player_query.single().0; +for (npc_entity, npc_pos, npc_kg, ...) in npc_query.iter() { + let dist = npc_pos.manhattan_distance(player_pos).unwrap_or(u32::MAX); + if dist > DISCLOSURE_RANGE { + continue; // Skip NPCs far from player + } + // Derive candidates for this NPC +} +``` + +`DISCLOSURE_RANGE` can match `CONVERSATION_PROXIMITY` (3 tiles) or be slightly larger. This is O(N_active) per tick — at 30-80 Active NPCs, this is ~60 comparisons per tick. Negligible. + +The `DisclosureCandidates` component acts as a cache. It's only populated for NPCs within range and expires after a configurable number of ticks (suggest 30 ticks = 3 game-minutes). If the NPC moves out of range or the player moves away, the component decays and is not repopulated. + +### Monologue StableId → displayable name lookup + +Gestalt asks: is `EntityRegistry + NpcName` available from the monologue system context? + +**Answer: yes, with a small signature addition.** + +The monologue system at `server/src/simulation/monologue.rs` currently takes: +- `ContentStoreResource` +- `SimRng` +- `SimulationTime` +- Player entity queries + +Adding `EntityRegistry` and a `Query<&NpcName>` to the system signature is ~5 lines. The lookup chain: + +```rust +fn resolve_name( + source_id: &StableId, + registry: &EntityRegistry, + name_query: &Query<&NpcName>, +) -> String { + registry.to_entity(source_id) + .and_then(|entity| name_query.get(entity).ok()) + .map(|name| name.0.clone()) + .unwrap_or_else(|| format!("Unknown({})", source_id.0)) +} +``` + +This is available and works. The monologue system CAN produce "Sera said Kael was at the dock" as Paula requires. + +For THE FRIEND arc specifically: Paula's Option 3 (generic fallback + hand-authored override) is the right authoring model. The monologue pool gets a `trigger: contradiction_detected` variant. The system selects a line, then substitutes `{source_name}`, `{entity_name}`, `{claimed_location}`, `{actual_location}` from the `ContradictionClaim` struct. For FRIEND-pattern NPCs (Sera, Kael), Mellanie authors specific lines that are selected first via the priority system; the generic template serves all other cases. + +Parameterized string substitution is ~20 lines in the monologue emission path. Worth it for the emotional payoff. + +--- + +## Secondary Disputes — Positions + +### Global disclosure rate limit (3-vs-1 split with Gestalt dissenting) + +Gestalt argues: a global limit is invisible to the player and creates unintelligible competition. + +This is correct but incomplete. The global limit is NOT a gameplay-visible competition — it is a degenerate-case safeguard. Consider: the player enters a crowded room with 15 Active NPCs, all at Friendly relationship, all with disclosure candidates. Without a global limit, 15 NPCs may attempt disclosure in the same tick. The per-NPC disclosure cooldown (300 ticks) prevents repeat disclosures from ONE NPC but does nothing about simultaneous first-time disclosures from MANY NPCs. + +**Resolution: include the global limit, but make it predictable rather than random.** NPCs are evaluated in `StableId` order (ascending). The first NPC in StableId order whose gates all pass fires its disclosure. The cap is 1 per tick. This means: +- The competition is deterministic (D-010 principle 4) +- The same-tick scenario resolves gracefully +- No NPC is silently blocked from EVER disclosing — they simply fire on a different tick + +The cap is a tick-granularity concern only. Over game-minutes, every NPC with a valid disclosure eventually fires. Gestalt's concern applies to a cap that permanently suppresses NPCs; this one does not. + +### Trust-weighted transfer count (Paula's 0-1/1-2/up-to-3 vs flat 1-3) + +Paula's weighting adds a trust-tier dependency. I adopt it — it's cleaner narratively and the implementation is trivial: + +```rust +let max_transfers = match trust_tier { + TrustTier::None => return, // no transfer + TrustTier::Surface => rng.random_range(0..=1), + TrustTier::Real => rng.random_range(1..=2), + TrustTier::Secret => rng.random_range(2..=3), +}; +``` + +Surface trust NPC pairs may transfer 0 facts (nothing worth saying). This makes surface trust feel surface-level. + +### Major secret `disclosure_blocked` flag (Paula's addition) + +Paula's proposal: a per-KG-entry flag preventing transfer even at maximum trust tier. + +**Include it.** Implementation: `disclosure_blocked: bool` field on `FactKnowledge` (default `false`). The `transfer_npc_knowledge` system skips entries where `disclosure_blocked == true`. Content authors mark Major-secret facts as `disclosure_blocked: true` in the initial KG YAML. + +This is ~15 lines total and correctly models "some things you never tell anyone regardless of trust." + +### Witness inhibition + location privacy gate (Gestalt + Paula additions) + +Both are additive trigger gates for unprompted disclosure. I include both: + +**Witness inhibition** (Gestalt): count Active NPCs within 5 tiles of the disclosing NPC. If `count > 2`, disclosure is suppressed unless the NPC's trait overrides it (a `Talkative` NPC ignores witnesses). + +**Location privacy** (Paula): disclosure candidates can be tagged `"location_privacy: private|semi_private|any"` in content. The disclosure trigger checks the NPC's current location zone tag. A `private`-tagged fact won't fire at `The Terminal`. This creates the spatial behavior pattern Paula describes: "If you want Sera to confide, find her at Lera's." + +Implementation of both gates: ~30 lines combined. Both use data already available in the system (NPC positions for witness count, location zone tags for privacy). + +### Runtime NPC KG guardrail + +Tyre proposes a 3-line runtime check; I called it Tier 3 in Round 1. + +**I recalibrate: include it.** Tyre's 3-line check is straightforward once the entity grant architecture is in place. The check is: + +```rust +// For fact grants: verify granting NPC knows the fact +if let Some(npc_kg) = npc_kg_query.get(granting_npc_entity).ok() { + if !npc_kg.knows_fact(&fact_id) { + tracing::warn!("NPC {} granted unknown fact {}", npc_sid.0, fact_id.0); + continue; + } +} +// For entity grants: verify granting NPC knows the target entity +// (check entities BTreeMap contains target_sid at appropriate confidence) +``` + +This handles the runtime KG decay case Gestalt identifies: a dialogue line remains eligible after the NPC's KG decays — the runtime check catches it. Include in Sprint 17. + +--- + +## Implementation Ticket Specifications + +### Ticket A: KnowledgeGrant schema + ContentEntityRegistry + +**Depends on:** Nothing (foundational) +**Blocks:** Tickets B, D, E, F + +**Changes:** +- `server/src/content/types.rs`: Replace `KnowledgeGrant` struct with `KnowledgeGrant` enum (`#[serde(untagged)]`); add `disclosure_blocked: bool` field to `Entity` variant +- `server/src/content/line_pool.rs`: Update `IndexedDialogueLine.knowledge_grant` type; update index building to parse both variants +- `server/src/knowledge/types.rs`: Add `disclosure_blocked: bool` to `FactKnowledge` (default `false`) +- New file `server/src/knowledge/content_registry.rs`: `ContentEntityRegistry` resource — `BTreeMap` + `register(content_id, stable_id)` + `resolve(content_id) -> Option` +- NPC spawn sites: add `content_registry.register(npc_content_id, sid)` call + +**Line estimate:** ~120 lines total + +--- + +### Ticket B: KnowledgeGranted event + process_knowledge_events handler + +**Depends on:** Ticket A (ContentEntityRegistry, schema) +**Blocks:** Tickets D, E, F + +**Changes:** +- `server/src/knowledge/events.rs`: Add `KnowledgeEventType::KnowledgeGranted` variant: + ```rust + KnowledgeGranted { + recipient: Entity, + grant: ProcessedKnowledgeGrant, + granting_npc: Option, // None for evidence/POI discovery + } + + pub enum ProcessedKnowledgeGrant { + Fact { fact_id: FactId, confidence: KnowledgeConfidence }, + Entity { target_sid: StableId, attributes: BTreeMap, confidence: KnowledgeConfidence }, + } + ``` +- `server/src/knowledge/events.rs`: Add match arm in `process_knowledge_events` for `KnowledgeGranted`: + - For `Fact`: `observer_kg.facts.insert(fact_id, FactKnowledge { confidence, source: ToldBy/DirectObservation, ... })` + - For `Entity`: `observer_kg.entities.entry(target_sid).or_insert_with(...)` with `source: ToldBy { source_id: granting_npc_sid, tick }` + - Runtime guardrail: if `granting_npc.is_some()`, verify granting NPC's KG contains the granted fact/entity before applying +- `server/src/knowledge/types.rs`: Add `TryFrom<&str> for KnowledgeConfidence` for parsing confidence strings +- `server/src/simulation/dialogue.rs`: In `process_talk_interaction`, after line selection, if `selected_line.knowledge_grant.is_some()`, push `KnowledgeGranted` event + +**Line estimate:** ~150 lines + +--- + +### Ticket C: ContradictionClaim struct + detection in observe_entity + +**Depends on:** Nothing (changes only `types.rs` and `graph.rs`) +**Blocks:** Ticket F (contradiction monologue) + +**Changes:** +- `server/src/knowledge/types.rs`: Add `ContradictionClaim` struct; add `contradicted_claim: Option` to `EntityKnowledge`; add `CONTRADICTION_WINDOW_TICKS: u64 = 600` constant +- `server/src/knowledge/graph.rs`: In `observe_entity()`, add pre-overwrite contradiction check (see algorithm above); push `ContradictionDetected` event when detected +- `server/src/knowledge/events.rs`: Add `KnowledgeEventType::ContradictionDetected { observer: Entity, entity_sid: StableId }` variant; add processing in `process_knowledge_events` that fires relationship shift for the `ToldBy` source entity +- `server/src/knowledge/graph.rs`: Tests — location contradiction, time window boundary, no-false-positive for non-ToldBy sources + +**Line estimate:** ~120 lines + ~40 lines of tests + +--- + +### Ticket D: NPC-to-NPC knowledge transfer system + +**Depends on:** Ticket B (KnowledgeGranted event infrastructure) +**Blocks:** Ticket E (contradiction detection needs ToldBy entries) + +**Changes:** +- New file `server/src/simulation/npc_knowledge_transfer.rs`: System `transfer_npc_knowledge` + - Queries `NpcConversation` components (identifies conversation pairs) + - Reads speaker's `KnowledgeGraph` + `Relationships` for trust-tier lookup + - Calls `KnowledgeEventQueue.push(KnowledgeGranted { ... })` for each transferred fact + - Trust-weighted rate: `Surface → 0..=1`, `Real → 1..=2`, `Secret → 2..=3` (SimRng drawn) + - Confidence downgrade: `min(source_confidence, KnowsOf)` via `KnowledgeConfidence::min()` + - Skips entries with `disclosure_blocked == true` + - `KnowledgeSource::ToldBy { source_id: speaker_sid, tick: current_tick }` + - Overheard grant for player: if player within `VOICE_RANGE_TILES`, push entity-level `KnowledgeGranted` at `Suspects` confidence with `KnowledgeSource::Heard { tick, range: Medium }` +- `server/src/simulation/conversation.rs`: Register `transfer_npc_knowledge.after(run_npc_conversations)` in system ordering +- ECS constraint note: `transfer_npc_knowledge` uses `get_many_mut([entity_a, entity_b])` for dual-mutable KG access — this requires the system to own the query, not inline it in `run_npc_conversations` + +**Line estimate:** ~160 lines + +--- + +### Ticket E: tell_state.rs KG awareness (MVP boundary #142) + +**Depends on:** Ticket B (needed for KG to contain meaningful content) +**Blocks:** Nothing (standalone improvement) + +**Changes:** +- `server/src/npc/tell_state.rs`: Add `Option<&KnowledgeGraph>` to `derive_tell_state` query +- Replace the `Friendly` tell's direct `relationships.entries` read (lines 105-112) with `kg.relationship_with(&entity_sid)` query against observed entities +- Note: self-axes (`Secret`, `Contentment`, `Tolerance`) remain ground-truth reads — KG applies to other-entity state only +- Tests: existing tests continue to pass (KG is optional, None falls back to current behavior) + +**Line estimate:** ~35 lines + +--- + +### Ticket F: Contradiction monologue + event chain completion + +**Depends on:** Tickets B + C (ToldBy entries + ContradictionClaim struct) +**Blocks:** Nothing (downstream consumers already exist) + +**Changes:** +- `server/src/simulation/monologue.rs`: Add `EntityRegistry` + `Query<&NpcName>` to system signature; add `resolve_name(source_id, registry, name_query)` helper (~15 lines); add match arm for `ContradictionDetected` events that selects contradiction monologue line with name substitution +- `server/src/knowledge/events.rs`: In `ContradictionDetected` processing, call `kg.set_relationship(&told_by_source_sid, RelationshipState::PersonOfInterest)` — shifts Sera to amber (D-033 downstream via existing pipeline) +- Monologue pool (content): Add `trigger: contradiction_detected` line category with `{source_name}`, `{entity_name}`, `{claimed_location}`, `{actual_location}` substitution tokens. Generic fallback line authored by Mellanie; FRIEND-specific lines separately authored. +- Test: THE FRIEND arc integration test — Tick 100 grant creates ToldBy, Tick 150 DirectObservation triggers contradiction, monologue fires with correct names, Sera shifts to PersonOfInterest, AnomalyMarker set on Kael + +**Line estimate:** ~90 lines + content (Mellanie) + +--- + +### Ticket G: DisclosureCandidates + Unprompted Disclosure system (#172) + +**Depends on:** Tickets B + D (KG must have meaningful NPC content before this is useful) +**Blocks:** Nothing + +**Changes:** +- New file `server/src/npc/disclosure.rs`: + - `DisclosureCandidates` component with `candidates: Vec`, `entity_candidates: Vec<(StableId, String)>`, `computed_tick: u64` + - `DisclosureCooldown` component with `per_fact_history: BTreeSet`, `npc_cooldown_until: u64` + - `derive_disclosure_candidates` system: range-query Active NPCs near player, filter NPC KG by trust tier + trait filters + `disclosure_blocked`, populate component + - `process_unprompted_disclosure` system: 5-gate check (trust ≥ surface, mood ≠ Hostile, contentment ≥ -10, witness count ≤ 2, location privacy gate), global rate limit (1 per tick, StableId-ordered), push line to dialogue pipeline if gates pass +- `server/src/simulation/dialogue.rs` integration: Layer 4 reads `DisclosureCandidates` for NPC-initiated dialogue selection +- System ordering: `derive_disclosure_candidates.after(process_knowledge_events)`, `process_unprompted_disclosure.after(derive_disclosure_candidates)` + +**Line estimate:** ~220 lines + +--- + +## Implementation Order with Dependencies + +``` +SPRINT 17 CRITICAL PATH: + +Ticket A: KnowledgeGrant schema + ContentEntityRegistry (~120 lines) + │ + ├──→ Ticket B: KnowledgeGranted event + dialogue wire (~150 lines) + │ │ + │ ├──→ Ticket D: NPC-to-NPC transfer system (~160 lines) + │ │ │ + │ │ └──→ [ToldBy entries now exist in KGs] + │ │ + │ └──→ Ticket E: tell_state KG awareness (~35 lines) ← can parallel with D + │ + └──→ Ticket C: ContradictionClaim + detect in observe_entity (~160 lines) + │ + └──→ [Requires ToldBy from B+D to fire. But struct can land independently.] + +After B + C + D complete: + └──→ Ticket F: Contradiction monologue + event chain (~90 lines) + +After B + D complete (KG populated with meaningful NPC content): + └──→ Ticket G: DisclosureCandidates + unprompted disclosure (~220 lines) + + +TOTAL: ~935 lines across 7 tickets +``` + +### Parallelization notes + +- **Ticket A** must complete first. It's the schema foundation for everything else. +- **Tickets B and C** can develop in parallel after A. B wires the grant pipe; C wires the detection pipe. They don't conflict. +- **Ticket D** requires B's `KnowledgeGranted` event type to exist (it pushes events). B's system changes don't affect D's query structure. +- **Ticket E** is fully independent of D-F. It can be done any time after A if someone needs a small task. +- **Ticket F** requires both B (for ToldBy sources to exist) and C (for `ContradictionClaim` struct). It's the last step on the critical path. +- **Ticket G** (unprompted disclosure) has no hard dependency on C or F, but benefits greatly from D having populated NPC KGs. Implement after D. + +--- + +## Unresolved Items — For the D-Record + +The following are forming positions that need to be captured in the decision document: + +| Item | Resolution | +|------|------------| +| Entity grants Sprint 17 | **Yes** — via `ContentEntityRegistry` (spawn-time registration) | +| `ContradictionClaim` struct vs attribute encoding | **`ContradictionClaim` struct** — Tyre/Gestalt approach adopted | +| `ContradictionClaim` field name | `contradicted_claim: Option` on `EntityKnowledge` | +| `CONTRADICTION_WINDOW_TICKS` | **600 ticks (1 game-hour)** | +| Global disclosure rate limit | **1 per tick, StableId-ordered** (deterministic, includes Tyre/Paula's cap, addresses Gestalt's concern) | +| Trust-weighted transfer count | **Paula's proposal adopted** (Surface 0-1, Real 1-2, Secret 2-3) | +| `disclosure_blocked` flag on FactKnowledge | **Include** — for Major secret no-transfer override | +| Witness inhibition gate | **Include** (count Active NPCs ≤5 tiles; threshold = 2) | +| Location privacy gate | **Include** (disclosure candidate tag: private/semi_private/any) | +| Runtime NPC KG guardrail | **Include** — 3-line check in KnowledgeGranted processing | +| Contradiction monologue authoring | **Option 3: generic template + hand-authored override for FRIEND-pattern NPCs** | +| `DisclosureCandidates` compute trigger | **Range-query per tick in Active NPC loop** — no special event needed | +| StableId → name in monologue system | **Available** — add EntityRegistry + Query<&NpcName> to system signature | +| Q-025 formal closure | **Close** — 30KB per Active NPC at current scale is not a constraint | + +--- + +## Final Notes + +The downstream chain for THE FRIEND arc is complete from step 3 onward. The simulation guarantees: once `ContradictionClaim` is stored and `KnowledgeState::Contradicted` is set, the anomaly system, relationship system, and D-033 color pipeline fire correctly without additional work — all tested and passing. + +The two new structural pieces this workshop adds are `ContradictionClaim` on `EntityKnowledge` (Topic 5 prerequisite) and `ContentEntityRegistry` as a new resource (Topic 1 prerequisite). Both are minimal. Everything else is new systems that plug into existing, tested infrastructure. + +State consistency across all 7 tickets: each ticket produces a well-defined state transition. None of them reach across system boundaries in ways that break existing determinism guarantees. The BTreeMap iteration order mandate (D-010 principle 4) is preserved throughout — `ContentEntityRegistry`, `DisclosureCandidates`, and the transfer system all use `BTreeMap` for deterministic ordering. diff --git a/docs/workshops/knowledge-flow-npc-boundaries/gestalt-round1.md b/docs/workshops/knowledge-flow-npc-boundaries/gestalt-round1.md new file mode 100644 index 000000000..5c028299b --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/gestalt-round1.md @@ -0,0 +1,467 @@ +# Gestalt Round 1 — Systems Design Analysis +## Knowledge Flow & NPC Information Boundaries Workshop + +**Author:** Gestalt +**Date:** 2026-02-23 +**Focus:** Mechanical interactions, "is this fun?", knowledge as gameplay lever + +--- + +## Framing: The Master Mechanic + +Before diving topic-by-topic, let me crack my knuckles and establish the frame I'm evaluating everything through. + +**Asymmetric information is the game.** Not a feature, not a pillar — the entire experience of The Settled Reach is "you know different things than the world does." Every system in this workshop is a mechanism for managing that asymmetry. The question I'm asking for each topic is: **does this design produce interesting player decisions about what to do with information?** + +The current state is: the KG data model is a perfect asymmetric information engine (D-041), but it's running in idle. Knowledge enters it via perception. Nothing comes out of it into NPC behavior. This workshop is about turning the engine on. + +Let me break down what that actually means mechanically for each topic. + +--- + +## Topic 1: Knowledge Flow — The Grant Mechanism + +### Mechanical position summary + +This is the INPUT side of the knowledge engine. The key design question is: what is the canonical moment a fact "enters" the player's mental model as a game state change? + +### 1.1 When does grant fire? + +**Position: Fire on line selection, server-side, before snapshot emission.** + +Not on display. Not on client acknowledgment. Line selection is the authoritative moment. + +Why this matters mechanically: if the grant fires at display, we have a timing problem when the player walks away mid-conversation (D-064 walk-away mechanic). The NPC started a sentence — did the player "hear" it? By firing on selection, the server authoritatively records "this information was transmitted at tick T." The walk-away event, if it fires, records an incomplete interaction but does NOT retract the knowledge already granted. This is diegetically correct: if someone starts telling you something, you heard the beginning. + +The pipeline should be: +1. Layer 4 topic+mood scoring selects line +2. Server fires `KnowledgeGranted` event into `KnowledgeEventQueue` +3. Knowledge update system processes it that tick +4. Snapshot includes updated KG state + +This puts knowledge grant in the same event pipeline as `DirectObservation` and `LeftLOS` (events.rs) — correct architectural fit. + +### 1.2 Grant payload schema — the entity/fact split + +**Position: `KnowledgeGrant` must support three payloads.** + +Current `KnowledgeGrant { fact_id: String, confidence: String }` only handles fact-level grants. The "Kael handles cargo at Dock 7" example from the brief is NOT a fact grant — it's an entity grant: creates/updates an `EntityKnowledge` entry for Kael with source `ToldBy { source_id: sera_sid, tick }`. + +Proposed schema (extend `content/types.rs`): + +``` +enum KnowledgeGrantPayload: + FactGrant { fact_id: FactId, confidence: KnowledgeConfidence } + EntityGrant { entity_id: String, attributes: BTreeMap, confidence: KnowledgeConfidence } + Compound { grants: Vec } // "Kael is at Dock 7 AND contraband.ring_exists" +``` + +The `EntityGrant` maps to `known_attributes` on the `EntityKnowledge` entry. The source is always `ToldBy { source_id: speaking_npc_sid, tick }`. This is the missing piece that makes `KnowledgeSource::ToldBy` constructible. + +**Is this fun?** Yes. It means a single dialogue line can tell you TWO things simultaneously — a new fact AND update your understanding of a person. These are the "information dense" moments players remember. + +### 1.3 POI discovery + +**Position: `FactId("poi.*")` namespace is correct. No new type needed.** + +`FactId("poi.dock_7_restricted")` integrates cleanly with: +- `filter_by_access` → `KnowledgeGated("poi.dock_7_restricted")` — the access rule already reads from `knows_fact()` in graph.rs line 301 +- Monologue prerequisites → `fact_at_least()` in graph.rs line 71 already works on any FactId +- Dialogue gating → same + +The alternative (extend `EntityKnowledge` to cover locations) fragments the query model. POIs are facts, not entities. Extending `EntityKnowledge` for locations would mean adding a third BTreeMap to `KnowledgeGraph` and duplicating query methods. Reject. + +### 1.4 Physical evidence + +**Position: Same grant mechanism, different source variant.** + +Evidence discovered via terminal/document/cargo manifest fires a `FactGrant` or `EntityGrant` with `source: DirectObservation { tick }` rather than `ToldBy`. This distinction matters for contradiction detection (Topic 5): a `DirectObservation` source carries higher credibility than `ToldBy`. It also means physical evidence cannot be contradicted by the NPC denial path — you saw it with your own eyes. + +A separate `KnowledgeEventType::EvidenceDiscovered` variant is NOT needed. The payload type distinguishes it from regular perception updates; the source type (`DirectObservation`) distinguishes it from NPC-told knowledge. One new event type (`KnowledgeGranted`) handles all grant paths. + +### 1.5 Author guardrails + +**Position: Runtime enforcement with content-load validation.** + +Rule: an NPC dialogue line can only grant knowledge that is consistent with the NPC's KG or their authored background facts. + +Implementation: content validator (already exists in the build pipeline) checks that for any `EntityGrant { entity_id }`, the entity_id references a known entity in the NPC's profile or the station manifest. Runtime enforcement: the dialogue system, when processing `knowledge_grant`, first checks that the granting NPC has the relevant knowledge at the claimed confidence level. If the NPC's KG doesn't support the grant, the line fires but the grant is silently dropped with a `tracing::warn!`. + +**Why not compile-time only?** NPCs' KGs are dynamic (they change at runtime via gossip, observation). A content-authored grant might be valid at session start and invalid after a KG decay cycle. Runtime enforcement handles this gracefully. + +--- + +## Topic 2: NPC-to-NPC Knowledge Propagation (Q-024) + +### Mechanical position summary + +This is the CIRCULATION side of the knowledge engine. Knowledge isn't useful if it just accumulates in one NPC and decays. The gossip system is what makes knowledge a renewable resource — facts move through the world and reach the player through multiple channels. + +### 2.1 Conversation system as the hook + +**Position: Confirmed. The conversation system IS the routine intersection hook. No separate system needed.** + +`run_npc_conversations` (conversation.rs line 278+) already has: +- Proximity detection within `CONVERSATION_PROXIMITY` tiles +- Cooldown management (`ConversationCooldown`) +- Deterministic pairing via StableId sort (D-010 compliant) +- Duration management (start tick → end tick) + +Adding a knowledge transfer phase between conversation start and conversation end is architecturally clean. It fits between Phase 1 (start conversation) and the existing emission of `SoundEvent`/`ConversationEvent`. The transfer happens at conversation start — both NPCs exchange knowledge at the moment contact is made. + +This satisfies the "queued at routine intersections" intent from Q-024. Routine-driven NPCs will naturally cross paths during their schedules, triggering conversations, triggering transfers. + +### 2.2 Trust-gated filtering + +**Position: Map trust tiers to KnowledgeConfidence caps.** + +Proposed mapping (aligns with D-028 trust tiers): + +| Trust tier | D-028 label | Facts eligible to transfer | Confidence cap on transfer | +|---|---|---|---| +| 0 (None) | — | No transfer | — | +| 1 (Surface) | `public` | Facts with confidence ≥ KnowsOf, state: Active only | KnowsOf | +| 2 (Real) | `real` | Facts with confidence ≥ KnowsOf, any Active state | KnowsOf (downgraded from KnowsDetails) | +| 3 (Secret) | `secret` | All Active facts including Suspects-level | KnowsOf (downgraded from KnowsDetails) | + +The confidence cap is the critical design choice: **transferred confidence = `min(source_confidence, KnowsOf)`**. + +Why `KnowsOf` as the cap, not `KnowsDetails`? Because gossip degrades information. NPC A telling NPC B something produces second-hand knowledge, never first-hand detail. This prevents gossip chains from propagating `KnowsDetails` — if a fact reaches someone via three intermediaries, it's still only `KnowsOf`. This is also why the player gaining `KnowsDetails` from an NPC requires DIRECT dialogue, not gossip relay. + +`Suspects` stays `Suspects` across all tiers — a rumor is a rumor. + +### 2.3 Rate limiting + +**Position: 1-3 facts per conversation, randomly selected from eligible pool.** + +Why random selection rather than "all eligible"? Knowledge explosion prevention. If NPC A has 30 eligible facts and meets NPC B every 10 game-minutes, uncapped transfer means every NPC converges to the same knowledge state quickly. This destroys the asymmetry that makes the game work. + +Random selection also creates variance: each NPC-NPC meeting produces a different outcome. An NPC you talk to might not know the one thing they had a chance to overhear from someone else yesterday — they just weren't selected to share it. This is emergent and creates replay variance (nodding to Nigel). + +Suggested cap: `rand.random_range(1..=3)` facts per conversation. Small enough to prevent explosion; large enough that relationships with high-trust NPCs provide real information value. + +### 2.4 Observable by player — overheard knowledge + +**Position: Overheard knowledge is always `Suspects` regardless of occlusion fidelity.** + +The workshop raises this as a question: should the grant for overheard conversations follow word-level occlusion (D-078)? My answer: no. Here's why. + +**The two systems answer different questions:** +- D-078 word occlusion: *what can the player READ/HEAR* — audio fidelity, narrative text +- Knowledge grant: *what can the player KNOW* — mechanical state + +These should be decoupled. A player who overhears "...Kael...dock...second shift..." (heavily occluded) still walked away with a SUSPICION, not knowledge. The occluded words give narrative flavor; the fixed-confidence grant gives mechanical state. + +Fixed `Suspects` for ALL overheard NPC-NPC conversations means: +- Player always gets SOMETHING from eavesdropping (reward for the behavior) +- Player never gets CERTAINTY from eavesdropping (incentive to follow up with direct dialogue) +- System is simple (no occlusion-weighted confidence calculation) + +**Is this fun?** Yes. Eavesdropping becomes a preliminary investigation tool. You hear something suspicious, you go find the person involved, you talk to them directly, you escalate to `KnowsOf` or `KnowsDetails`. The eavesdrop is a lead, not a solution. + +### 2.5 ToldBy source construction + +The `source_id` for `ToldBy` is available at conversation.rs line 321: `sid.map(|s| s.0.0).unwrap_or(u64::MAX)`. This is the StableId of the source NPC. The `tick` is `time.tick` from the `SimulationTime` resource. ToldBy construction is a one-liner once the architecture confirms Topic 2. + +--- + +## Topic 3: Unprompted Disclosure Design (#172) + +### Mechanical position summary + +This is the most design-dense topic. Unprompted disclosure is not just "NPC volunteers info" — it's the mechanic that makes NPCs feel like they have an independent relationship with information. Done correctly, it creates the "a stranger just told you something important you didn't know you needed" moments that define good immersive sims. + +### 3.1 "Do I know something you don't?" — Option A is correct + +**Position: NPC checks only own KG (Option A). Strongly opposed to cross-entity KG query (Option B).** + +This is not primarily a technical decision — it's a design decision about NPC cognition. NPCs **do not know what the player knows**. This is both diegetically correct AND mechanically interesting. + +Why is Option A MORE interesting, not less interesting? + +Consider what happens with Option B: every time the player already knows something, no NPC will repeat it. The player's information state becomes a filter on the entire NPC disclosure system. This means players who investigate thoroughly get LESS disclosure from NPCs over time. That's punishing success. + +With Option A: NPCs might tell you things you already know. That is information too. "Why is this NPC telling me this? Do they not know that I know? Are they testing me? Are they covering for someone by offering an explanation I already disproved?" This is the paranoid detective headspace the game is going for. + +The only UX downside is redundant disclosure feeling noisy. Solved by per-fact cooldowns (see 3.5) — the player won't hear the same thing twice from the same NPC in rapid succession. + +### 3.2 Connection to NPC KG — architecture + +**Position: New `DisclosureCandidateList` component, NOT extending `DerivedTellState`.** + +`DerivedTellState` is a behavioral signal (how the NPC APPEARS). Disclosure candidates are content selection state (what the NPC might SAY). These are different concerns. + +Proposed: a separate `DisclosureCandidateList` component, populated by a system that runs after the KG update pass: + +``` +struct DisclosureCandidateList { + // Facts from this NPC's KG that pass the disclosure filter + candidates: Vec, + // Entity knowledge entries that pass the filter + entity_candidates: Vec<(StableId, String)>, // (entity, key_attribute) + // Last computed at this tick + computed_tick: u64, +} +``` + +This component is computed lazily (only for Active-tier NPCs in dialogue range) and expires after a few ticks. Layer 4 of the dialogue pipeline reads it when selecting unprompted disclosure lines. + +**Why not make `DerivedTellState` hold candidates?** Because tell derivation runs every tick for ALL Active NPCs (tell_state.rs line 129). Adding KG iteration to that loop for NPCs not in dialogue range wastes compute. `DisclosureCandidateList` only computes for NPCs the player is actually engaging with. + +### 3.3 Trait filtering (#173) — BOTH, sequenced + +**Position: Traits filter WHAT (candidate pool) then modify HOW (line selection). Two-stage.** + +Stage 1 — candidate filtering (which facts are eligible): +- `Cautious` trait: removes facts with confidence > `Suspects` from candidate pool (shares only rumors, never certainties) +- `Loyal` trait: removes facts about entities with `relationship: Friendly/PersonOfInterest` (protects people they care about) +- `Talkative` trait: expands pool to include facts at all confidence levels + +Stage 2 — delivery modification (which line from the pool wins): +- Trait tags bias the line pool selection weights in Layer 4 +- A `Cautious` NPC who does disclose something delivers it obliquely ("I heard something... probably nothing") +- A `Talkative` NPC delivers it directly and with elaboration + +This is the correct architecture because content authors can create trait-appropriate LINE variants without needing to touch the candidate selection logic. The filtering and the delivery are independently authorable. + +**Is this fun?** Yes. Personality shapes WHAT the NPC is willing to reveal AND how it reads when they reveal it. Two NPCs with the same knowledge but different traits create different investigative experiences. + +### 3.4 Trigger conditions + +**Position: Four-gate system, all must pass.** + +| Gate | Condition | Rationale | +|---|---|---| +| Trust | Trust tier ≥ `surface` toward player | You don't volunteer info to strangers | +| Mood | MoodState ≠ Hostile | Angry NPCs don't help | +| Contentment | Contentment ≥ -10 | Miserable NPCs are self-absorbed | +| Witness inhibition | No NPCs in radius OR trust override | NPCs are less forthcoming with an audience | + +The witness inhibition gate is the interesting one. An NPC who is Friendly to the player might still not disclose sensitive information if their colleague is standing nearby. This creates the "can we talk privately?" dynamic — a moment of social positioning that feels diegetically real. + +Trust tier ≥ `surface` maps to D-028 access tier: the NPC will speak to you at all (they haven't gone silent). Real disclosure of substantive facts should probably gate at `real` trust. Layer 4 line pool already handles trust tier filtering — unprompted disclosure can piggyback on this. + +### 3.5 Rate limiting + +**Position: Two-layer rate limiting.** + +Layer 1 — per-fact per-NPC per-player: once an NPC has disclosed a fact to the player, that fact is marked `disclosed_to_player` in the `DisclosureCandidateList` and never selected again. Facts the player already knows (via other channels) are NOT filtered — see 3.1 above. + +Layer 2 — per-NPC disclosure rate: 1 disclosure per `LINE_COOLDOWN_TICKS` (600 ticks = 1 game-hour, dialogue.rs line 39). This prevents disclosure spam from high-contentment NPCs. + +No global rate limit across all NPCs. Global limiting would create invisible competition between NPCs for "disclosure slots" that the player can't see or understand. Keep complexity in the individual NPC state. + +--- + +## Topic 4: NPC Information Boundaries (#142) + +### Mechanical position summary + +Let me be precise about what "NPC using their own KG for decisions" actually means at each level, because different systems have different stakes. + +### 4.1 Priority order with justification + +| Priority | System | What changes | Gameplay impact | Risk | +|---|---|---|---|---| +| 1 | `tell_state.rs` | Tell derivation reads NPC's relationship knowledge from KG | NPC tells reflect what they know about OTHERS, not just their internal state | Low — KG reflects observed state accurately | +| 2 | Unprompted disclosure (#172) | Disclosure candidates come from KG | NPCs only volunteer what they know | Low — this is the whole point of #172 | +| 3 | Conversation partner selection | NPC A checks KG before approaching NPC B | NPCs don't chat with "strangers" (entities not in their KG) | Medium — adds KG query to pairing loop | +| 4 | Routine execution | Routine decisions read KG state | NPCs take different routes based on what they know | High — requires careful fallback | +| 5 | Pathfinding | Path uses known (not ground-truth) walkability | NPCs might get lost after KG decay | Very high — don't touch in v0.1 | + +### 4.2 On tell_state.rs specifically + +There is a subtle point here worth calling out. The workshop brief says `tell_state.rs` uses axes directly and should use KG. I want to be careful: the `Secret`, `Contentment`, `Tolerance` axes ARE the NPC's own internal state — the NPC always knows their own secret severity. Using ground truth for self-knowledge is CORRECT. + +What changes with KG integration is not reading self-knowledge differently, but **incorporating relationship knowledge** into tell derivation. Currently, `TellCategory::Friendly` checks `relationships.entries` directly (tell_state.rs lines 105-112). This should instead check the KG's `RelationshipState` for the entities in range — because an NPC's relationship state can be affected by observed behavior that's recorded in their KG. + +More impactfully: an NPC who has a `Contradicted` entry in their KG (they know something doesn't add up about someone they thought they trusted) should show a modified tell. This is a new tell category or a modifier on existing tells, not yet designed. Worth flagging for a future sprint. + +### 4.3 Minimum viable boundary + +**Position: tell_state.rs relationship reads from KG + unprompted disclosure from KG = MVP.** + +These two changes produce maximum gameplay-visible difference for minimum implementation risk. An NPC that: +1. Shows tells based on their actual observed relationship state (not just the raw relationship axis) +2. Only voluntarily discloses things they actually know + +...is a meaningfully bounded NPC even if its pathfinding runs on ground truth. + +### 4.4 Simulation tier + +**Position: Background-tier NPCs (D-026, 500-2000 range) get NO KG-based behavior.** + +Background NPCs run state machines. They don't have dialogue. They don't initiate conversations. Their KG is either empty or minimal. Adding KG queries to background-tier processing would blow the performance budget. + +Active-tier NPCs (30-80 per D-026) are the only ones who can engage in dialogue and unprompted disclosure. KG-based behavior is gated on `With`, matching the existing `derive_tell_state` scope (tell_state.rs line 140). + +### 4.5 Fallback behavior + +**Position: Ground truth fallback with structured logging. No "ask around" behavior in v0.1.** + +If an Active-tier NPC's KG has no relevant entry for a needed decision: +1. Fall through to ground truth +2. Log at `tracing::debug!` level: `"NPC {sid} falling back to ground truth for {decision_type}"` + +The "ask around" emergent behavior (option C from the brief) is the CORRECT long-term design but it's a v0.2+ feature. It requires an "information-seeking" behavioral state, a system to resolve it, and content to support it. Not v0.1 scope. + +--- + +## Topic 5: Contradiction Detection Pipeline (Q-026) + +### Mechanical position summary + +This is the payoff. Everything above feeds into this: ToldBy sources exist (from Topic 2 grants and Topic 1 dialogue grants), the player has observed entity positions directly, and now the system must detect when told information and observed reality diverge. This is THE FRIEND arc mechanic at its purest. + +### 5.1 Critical structural issue I must raise + +**There is a problem with the current `EntityKnowledge` data model for contradiction detection.** + +`KnowledgeGraph.entities` is `BTreeMap` — **one entry per known entity**. When the player observes Kael at corridor B-7 via `observe_entity()`, the `last_known_position` is overwritten to B-7 and `source` is overwritten to `DirectObservation`. The original `ToldBy` source that said "dock during second shift" is **gone**. + +Contradiction detection cannot fire on writes if the information being contradicted has already been overwritten. + +**Position: Add `contradiction_basis: Option` to `EntityKnowledge`.** + +```rust +pub struct ContradictionBasis { + /// The previous source, preserved when a contradiction is detected. + pub conflicting_source: KnowledgeSource, + /// The previous position at the time of the conflicting observation. + pub conflicting_position: Option, + /// The tick when the contradiction was detected. + pub detected_at_tick: u64, +} +``` + +Contradiction detection fires in the `observe_entity()` write path BEFORE overwriting: +1. Read existing entry +2. If existing source is `ToldBy { source_id, tick: told_tick }` AND new position ≠ existing position AND time window overlaps (|current_tick - told_tick| < CONTRADICTION_WINDOW) +3. Preserve old source/position in `contradiction_basis` +4. Set entry state to `Contradicted` +5. Write new observation +6. Push `ContradictionDetected` event + +This preserves both pieces of information for monologue text ("Sera said X was at the dock — I just saw them in corridor B-7"). + +### 5.2 Detection algorithm + +**Position: Event-driven detection at KG write time. Never per-tick.** + +Two detection categories for v0.1: + +**Location contradiction (automatic):** +``` +IF new_observation.position ≠ existing_entry.last_known_position +AND existing_entry.source == ToldBy { tick: told_tick } +AND |current_tick - told_tick| < CONTRADICTION_WINDOW_TICKS +THEN contradiction detected +``` +`CONTRADICTION_WINDOW_TICKS` = 600 (1 game-hour) is a reasonable default. Facts more than a game-hour old aren't "this moment" contradictions — they're just outdated info, which is handled by decay/stale. + +**Attribute contradiction (semi-automatic):** +For `known_attributes: BTreeMap`, contradiction fires when a new attribute write for an existing key produces a different value AND both have different source types (`ToldBy` vs `DirectObservation` or different `ToldBy` sources). + +The `known_attributes` key convention needs to establish a typing scheme to make this meaningful: `"role:*"` keys are comparable (same role key, different values), `"event:*"` keys are not directly comparable. This can be a content convention rather than a type system change — lightweight for v0.1. + +**Fact contradiction (content-authored):** +Content authors specify contradiction pairs in YAML: `contradicts: ["contraband.smuggling_denied", "contraband.ring_exists"]`. Detection fires when both facts exist in the same KG at `Active` state. + +### 5.3 Event chain + +``` +KG write (observe_entity / grant processing) + → ContradictionCheck (inline, before write completes) + → ContradictionDetected event { observer, entity, type, conflicting_source, new_source } + → MonologueSystem: "Wait, that doesn't add up" line selection + → Prerequisite: both KG entries Contradicted + → RelationshipSystem: target entity → PersonOfInterest + → AnomalySystem: AnomalyMarker set on target + → D-033: color transition → amber #e8c547 +``` + +The monologue system reads from `EntityKnowledge.contradiction_basis.conflicting_source` to construct the text: `"[Name] said [entity] was at [conflicting_position]. I just saw them at [current_position]."` This requires the `ToldBy source_id` to be resolvable back to a name — which requires the `EntityRegistry` and the resolved NPC's `NpcName` component. Dudley should confirm this lookup is available from the monologue system context. + +### 5.4 THE FRIEND arc — full mechanical sequence + +This is the canonical integration test. Walking through every system: + +**Setup:** Sera's dialogue pool includes a line with `EntityGrant { entity_id: "kael", attributes: { "location": "dock-7-second-shift" }, confidence: KnowsOf }` and `knowledge_grant: Some(...)`. + +**Sequence:** + +1. **Tick 100:** Player talks to Sera → dialogue pipeline selects her "Kael handles cargo at Dock 7" line → `KnowledgeGranted` event pushed → knowledge update system processes it → player's KG: `entities[kael_sid] = EntityKnowledge { last_known_position: dock_7_coords, source: ToldBy { source_id: sera_sid, tick: 100 }, confidence: KnowsOf, state: Active }`. + +2. **Tick 150:** Player moves toward Dock 7, Kael is not there. Player continues through station. Passes through corridor B-7. Kael walks into LOS. + +3. **Tick 150:** Perception system fires `KnowledgeEventType::DirectObservation { target: kael_entity, position: b7_coords }` → knowledge update system calls `observe_entity(kael_sid, b7_coords, 150)`. + +4. **Inside `observe_entity()`:** Existing entry has `source: ToldBy { source_id: sera_sid, tick: 100 }`, `last_known_position: dock_7_coords`. New position `b7_coords ≠ dock_7_coords`. Time window: `|150 - 100| = 50 < 600`. **Contradiction detected.** Entry updated: `contradiction_basis: Some(ContradictionBasis { conflicting_source: ToldBy { sera_sid, 100 }, conflicting_position: dock_7_coords, detected_at: 150 })`, `state: Contradicted`, `last_known_position: b7_coords`, `source: DirectObservation { tick: 150 }`. + +5. **Tick 150 (same frame):** `ContradictionDetected` event emitted. Monologue system fires: "Sera said Kael was at the dock. I just saw him in B-7." (line from monologue pool, prerequisite: `contradiction.kael_dock_b7` or whatever the fact tag is — needs content authoring). Relationship state for `Sera` → `PersonOfInterest`. `AnomalyMarker` set on Sera entity. D-033 color transition: Sera → amber. + +6. **Player reaction:** Sera now appears amber on screen. Player is prompted to re-engage with Sera. Next conversation with Sera uses "Contradicted" entry in player's KG as a trust modifier in Layer 1-3 filtering → Sera's trust-tier dialogue unlocks confrontation lines. + +**What breaks this sequence:** If `observe_entity()` does not check for contradiction before overwriting, step 4 fails silently. This is why the structural issue in 5.1 is blocking. Everything downstream (monologue, relationship shift, color change) is already built and tested — the detection algorithm is the missing link. + +--- + +## Cross-Topic Interactions (the emergent machine) + +Let me map the feedback loops, because this is where it gets interesting: + +``` +Topic 2 (NPC gossip) → produces ToldBy entries in NPC KGs +Topic 1 (grant mechanism) → NPC ToldBy entries can be transferred to player via dialogue +Topic 5 (contradiction) → player observation contradicts ToldBy → Contradicted state +Topic 3 (unprompted disclosure) → NPC with Contradicted knowledge discloses their confusion? +``` + +That last arrow is a bonus emergent loop: an NPC who has a `Contradicted` entry in their own KG (because they also observe Kael somewhere unexpected) could volunteer that confusion via unprompted disclosure. "I could have sworn Kael was supposed to be at the dock today, but..." This is not Topic 5, it's Topic 3 using Topic 5 outputs as candidate selection criteria. Worth noting for future sprint. + +--- + +## Positions Summary Table + +| Topic | Position | Rationale | +|---|---|---| +| 1.1 Grant timing | Fire on line selection | Server-authoritative, handles walk-away cleanly | +| 1.2 Grant payload | Three-type enum: Fact/Entity/Compound | Entity grants enable ToldBy construction | +| 1.3 POI | `FactId("poi.*")` namespace | No new type; integrates with existing KnowledgeGated | +| 1.4 Evidence | Same grant mechanism, DirectObservation source | Source type distinguishes from NPC-told | +| 1.5 Guardrails | Runtime enforcement + content-load validation | KGs are dynamic; compile-time-only is insufficient | +| 2.1 Gossip hook | Conversation system confirmed | Already has pairing, lifecycle, determinism | +| 2.2 Trust-gate | `min(source_confidence, KnowsOf)` cap | Prevents KnowsDetails propagation via gossip | +| 2.3 Rate limit | 1-3 random facts per conversation | Prevents knowledge convergence | +| 2.4 Overheard | Fixed `Suspects` regardless of occlusion | Eavesdrop = lead, not certainty | +| 3.1 "Do I know?" | Option A: NPC checks own KG only | Diegetically correct AND mechanically richer | +| 3.2 Architecture | New `DisclosureCandidateList` component | Separation from tell derivation | +| 3.3 Traits | Both: filter candidates AND modify delivery | Independently authorable | +| 3.4 Trigger | Four-gate: trust + mood + contentment + witnesses | Witness gate creates social positioning | +| 3.5 Rate limit | Per-fact per-NPC + per-NPC rate limit | No global limit (unintelligible to player) | +| 4.1 Priority | tell_state (KG relationships) → disclosure → conversation pairing | Most impact, lowest risk first | +| 4.2 tell_state | Read relationship state from KG, not raw axis | Self-knowledge stays on axes (correct) | +| 4.3 MVP | tell_state relationship reads + disclosure from KG | Visible difference, low risk | +| 4.4 Tier | Active-tier only (`With`) | Consistent with D-026 | +| 4.5 Fallback | Ground truth + logging, no "ask around" in v0.1 | "Ask around" is v0.2+ | +| 5.1 Structural | Add `contradiction_basis: Option` to `EntityKnowledge` | Overwrite problem — blocking | +| 5.2 Algorithm | Event-driven at KG write time | Off per-tick hot path | +| 5.2a Location | Automatic: position + time window | Tier 1 implementation | +| 5.2b Attribute | Semi-automatic: same key, different value, different source | Works with String BTreeMap + key conventions | +| 5.2c Fact | Content-authored contradiction pairs | Not all fact contradictions are algorithmic | +| 5.3 Event chain | KG write → ContradictionDetected → monologue → relationship → anomaly → color | All downstream consumers confirmed built | +| 5.4 FRIEND arc | Full sequence walks clean with proposed design | Pending structural fix from 5.1 | + +--- + +## Open Questions I'm Flagging for Round 2 + +1. **Contradiction window (`CONTRADICTION_WINDOW_TICKS`).** I proposed 600 ticks (1 game-hour). Paula should weigh in: what timeframe makes narrative sense for "that's a contradiction, not just stale info"? + +2. **Entity grant `entity_id` resolution.** `EntityGrant { entity_id: String }` needs to resolve to a `StableId` at grant processing time. Who owns the "name → StableId" registry? `EntityRegistry` does `StableId ↔ Entity` (Bevy Entity), but is there a "display name → StableId" path? Tyre should confirm. + +3. **Monologue text generation from `contradiction_basis`.** The monologue pool currently has authored lines. For THE FRIEND arc, the line needs to reference specific entities by name ("Sera said Kael was at the dock"). Does this require parameterized monologue lines (dynamic text insertion)? Or do we just require dedicated authored lines per contradiction scenario? Paula should decide. + +4. **`DisclosureCandidateList` compute trigger.** I said it computes lazily for NPCs in dialogue range. Tyre should confirm: is there a "player entered dialogue range" event or does this need a range-query every N ticks? + +5. **Q-025 formal closure.** I agree with Tyre: ~30KB total at current NPC count, eviction not needed for v0.1/v0.2. Qatux, please record formal closure of Q-025 when this workshop produces its D-record. diff --git a/docs/workshops/knowledge-flow-npc-boundaries/gestalt-round2.md b/docs/workshops/knowledge-flow-npc-boundaries/gestalt-round2.md new file mode 100644 index 000000000..3ee64374b --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/gestalt-round2.md @@ -0,0 +1,377 @@ +# Gestalt Round 2 — Cross-Review, Synthesis, and D-Record Contributions +## Knowledge Flow & NPC Information Boundaries Workshop + +**Author:** Gestalt +**Date:** 2026-02-23 +**Sources reviewed:** tyre-round1.md, dudley-round1.md, paula-round1.md, round-1-notes.md + +--- + +## Opening: What Round 1 Actually Settled + +The consensus table in Qatux's notes is largely correct and the fundamentals are solid. Before the tensions: I want to note that the four-way agreement on Option A (NPC checks own KG only) and on event-driven contradiction detection is meaningful — these were the design questions with the most surface area for disagreement, and we landed in the same place independently. That's a signal worth acknowledging before we argue about the edges. + +Now: the two tensions. These are the critical path. Everything else in this document is secondary. + +--- + +## Tension A: Entity Grants — Sprint 17 vs Sprint 18 + +### My position: Sprint 17, but scoped narrowly. + +Let me map the debate precisely, because Dudley's objection is valid but not blocking. + +**Dudley's concern:** "entity_ref → StableId" name registry doesn't exist, Sprint 17 shouldn't take on that infrastructure. + +**Tyre's counter:** It's a `BTreeMap` populated at content load. 0.5 days. + +Both are right, but they're talking past each other. Dudley is imagining a general entity-name-to-StableId registry that works for ANY authored entity reference in ANY dialogue line. That is Sprint 18+ scope. Tyre is imagining a narrow content-load-time map for the specific entities involved in narrative-critical dialogue. That is Sprint 17 scope. + +**The actual minimum for Sprint 17:** + +The contradiction detection chain for THE FRIEND arc requires exactly one thing that entity grants provide: `EntityKnowledge` for Kael in the player's KG with `source: ToldBy { source_id: sera_sid }`. This is not a general capability — it's a specific authoring requirement for one dialogue line in one NPC's pool. + +Dudley's workaround (structured FactId `"entity.kael.position.second_shift_dock"`) does NOT cleanly substitute for this. Paula identified the exact failure mode: + +1. Sera's dialogue grants `FactId("entity.kael.position.second_shift_dock")` — creates `FactKnowledge` entry with `source: ToldBy { sera_sid }` +2. Player observes Kael in B-7 — calls `observe_entity(kael_sid, B7_pos, tick)` — updates `EntityKnowledge` +3. Contradiction check in `observe_entity`: existing entry has `source: DirectObservation` (the player's PREVIOUS direct observation of Kael, not ToldBy!) — no contradiction fires + +The workaround breaks because the player's `EntityKnowledge` for Kael is populated by direct observation BEFORE any testimony. The first time the player sees Kael, `observe_entity` creates a `DirectObservation` entry. When Sera then tells them "Kael was at the dock," if this is only a FactGrant, the `EntityKnowledge` entry still shows `DirectObservation`. The contradiction between Sera's testimony and the player's later observation has no anchor in `EntityKnowledge`. + +Dudley's Option B (structured attribute strings `"claim.T1.position" = "dock_7,T1,sera_sid"`) does address this — it adds the ToldBy position claim INTO the existing EntityKnowledge entry's `known_attributes`. But it introduces a new problem: brittle string parsing in a hot code path, plus the contradiction detector needs a string parser instead of typed reads. + +**My verdict on Tension A: Sprint 17, narrow entity grant scope.** + +Deliverable for Sprint 17: +- `KnowledgeGranted` event supports TWO payload types: `FactGrant` and `EntityPositionGrant` +- `EntityPositionGrant { entity_ref: String, position_claim: String, confidence: KnowledgeConfidence }` — resolved to StableId at content load via a static `BTreeMap` seeded from `entity-attributes.yaml` (which already defines all authored entities) +- This creates/updates `EntityKnowledge` for the referenced entity with `source: ToldBy { source_id: speaker_sid, tick }` and stores the position claim in `contradiction_basis` (see Tension B resolution below) +- Scope restriction: `entity_ref` must reference an entity in `entity-attributes.yaml` — no dynamic resolution + +The full `EntityGrant { attributes: BTreeMap }` capability (for arbitrary attribute grants) defers to Sprint 18 as Dudley prefers. + +This gets THE FRIEND arc working. It does NOT require a general entity name registry — just a static lookup against the existing entity manifest. + +--- + +## Tension B: `EntityKnowledge` Structural Fix + +### My position: Tyre's approach. Unify on `ContradictionClaim`. Reject Dudley's Option B. + +Tyre and I independently proposed the same architecture with different field names: +- Tyre: `contradicted_claim: Option` +- Gestalt: `contradiction_basis: Option` + +These are structurally identical. I defer to Tyre's naming: **`contradicted_claim: Option`**. The struct fields: + +```rust +pub struct ContradictionClaim { + pub source: KnowledgeSource, // Who made the conflicting claim + pub position: Option, // Where they claimed the entity was + pub tick: u64, // When the claim was made +} +``` + +The detection fires inside `observe_entity` before overwriting, sets `entry.state = Contradicted`, populates `contradicted_claim` with the prior source data, then overwrites normally. + +**Why not Dudley's Option B?** + +Dudley's structured attribute approach (`"claim.T1.position" = "dock_7,T1,sera_sid"`) has three concrete problems: + +1. **String parsing in a hot path.** `detect_contradictions` would iterate `known_attributes` keys matching `"claim.*"` and parse comma-delimited values on every KG write. This is not expensive in absolute terms (Tyre's budget analysis puts it well within budget), but it is brittle. A malformed string crashes the detector. A content author misspelling a claim key silently skips detection. + +2. **`known_attributes` is the wrong data structure for this.** It's typed `BTreeMap` for entity metadata — role, faction, relationships. Using it for contradiction tracking mixes concerns. When Dudley suggests a Sprint 18 refactor, they're acknowledging this is wrong — but the refactor creates breaking changes to any contradiction content authored in Sprint 17. Technical debt with a deadline is not "pragmatic workaround," it's committed future work. + +3. **Downstream consumers read typed fields.** The monologue system needs `contradicted_claim.source` to identify Sera by name. With Option B, it needs to parse `"dock_7,T1,sera_sid_as_string"` and reassemble a `ToldBy` source. This creates a second parsing site and doubles the failure modes. + +**The counterargument:** "zero schema change" is attractive. My response: adding one optional field to `EntityKnowledge` is not a meaningful schema change. The struct has 8 fields already. Adding one optional field with `Default: None` is backward-compatible and requires no migration of existing state. + +**Resolution:** Tension B resolves to Tyre's approach. One optional struct field. Zero breaking changes. Typed reads throughout. + +--- + +## Secondary Disagreements + +### Global disclosure rate limit — concession with modification + +Round 1 had a 3-vs-1 split: Tyre/Dudley/Paula for global limit, Gestalt against. + +Let me be precise about what I objected to and where I was wrong. + +**What I said:** "Global limiting creates invisible competition between NPCs that the player can't see or understand." + +**Where I was wrong:** In the specific scenario where a global limit fires — multiple NPCs simultaneously ready to disclose in the same player proximity — the player is looking at ONE NPC who is speaking. They are not watching the other NPCs not-speak. The "invisible competition" framing assumed the player was tracking all NPCs in parallel, which is not how occlusion-based single-character perspective works. + +**Where I was still right:** Dudley proposed "1 disclosure per tick globally." At 10 ticks/game-minute, this is essentially no limit in normal play (per-NPC cooldowns of 300 ticks prevent individual NPCs from firing every tick). The global limit only matters when several NPCs have their cooldowns expire simultaneously AND are all within player range AND pass all their trigger conditions. That's a degenerate case, and limiting it to 1-per-tick is correct. + +**My concession:** I accept the global rate limit as a degenerate-case safeguard, with one condition: the NPC that "wins" the slot must be selected by deterministic StableId ordering (D-010 principle 4), not random. A per-tick random selection would make the global limit a source of non-determinism. Lowest StableId in the ready-to-disclose set wins the tick. + +**Full rate limiting model (synthesis):** +1. Per-fact, per-NPC: once disclosed, fact is in `last_disclosed_facts` set; never repeated to same player until intentionally cleared +2. Per-NPC cooldown: 300 ticks minimum between any disclosures from one NPC +3. Global: 1 disclosure per tick maximum; when multiple candidates, select by lowest StableId (deterministic) + +### Paula's `disclosure_threshold_override` on KG entries + +This is not a tension — it's a new design element that doesn't conflict with anything. I support it. + +A per-entry flag (`never_disclose: bool` on `FactKnowledge` or per-entry override in `DisclosureCandidates` filtering) that prevents specific facts from entering the candidate pool regardless of trust tier. This handles the "Major secret that would never be shared even with closest confidant" scenario (Kael's ring membership, Voss's blackmail knowledge). + +**Implementation:** Add an optional field `disclosure_blocked: bool` to `FactKnowledge`. Content authors set this on the authored initial KG for NPCs who hold facts they would NEVER share. This is a content-level attribute, not a systemic change. + +### Paula's location privacy gate + +I proposed witness inhibition; Paula proposed location privacy. These are additive gates, not competing ones. Both should be in the trigger conditions for `process_unprompted_disclosure`. + +The combined trigger condition set (final proposal, see Disclosure Algorithm section below): + +| Gate | Condition | +|------|-----------| +| Trust | NPC-player relationship >= Surface tier | +| Mood | `DerivedTellState.category` not Angry; mood not Hostile | +| Contentment | `Contentment.level > -10` | +| Candidates | `DisclosureCandidates.candidates.is_not_empty()` | +| NPC cooldown | `DisclosureCooldown` not active for this NPC | +| Global limit | At most 1 disclosure this tick (StableId ordering) | +| Location privacy | Candidate's `location_privacy` tag compatible with current location type | +| Witness inhibition | No non-trusted NPCs in radius (≤3 tiles), OR NPC-player trust >= Real | + +The witness inhibition gate: I defined it as "no NPCs nearby OR trust high enough to override." Paula's location privacy gate: "current location must match candidate's privacy requirement." These are independent checks that both apply. + +**Implementation note for Dudley:** The location privacy check applies at LINE SELECTION (Layer 4), not at candidate derivation. The candidate pool surfaces eligible facts. When Layer 4 selects a line to deliver a candidate fact, the line may have a `location_privacy` tag that gates against current location. This keeps candidate derivation clean (KG query only) and puts environmental context check at the correct pipeline layer. + +### Contradiction monologue authoring — Paula's three options + +Paula's Option 3 (generic fallback + authored override) is correct and I endorse it with one addition. + +For v0.1, the only FRIEND-pattern NPCs are Kael and Sera. Their contradiction monologue lines should be hand-authored with explicit entity names: "Sera said Kael was at the dock. I'm looking at him in B-7." This is possible because the monologue system has access to `contradicted_claim.source` (a `ToldBy { source_id: sera_sid }`) and can resolve `sera_sid → NpcName` via `EntityRegistry`. + +**The resolution lookup must be confirmed by Dudley:** Is `EntityRegistry + NpcName` available in the monologue system context? This is the technical prerequisite for named contradiction monologue lines. If yes: hand-author the FRIEND arc lines now, design the template system for Sprint 18+. If no: the template system becomes a Sprint 17 blocker. + +For auto-generated NPC contradictions (future scope): generic fallback "Something doesn't add up about [entity_name]'s whereabouts" is sufficient. + +--- + +## The Disclosure Candidate Selection Algorithm + +This is the co-production Dudley requested. Paula and I need to specify this before implementation can proceed. Here is my full design; Paula should confirm the narrative assumptions inline or amend in her Round 2 document. + +### Algorithm: `derive_disclosure_candidates` + +**System name:** `derive_disclosure_candidates` +**Runs:** Once per game-minute (every 10 ticks) for Active-tier NPCs within player dialogue range +**Input components:** `KnowledgeGraph`, `DerivedTellState`, `Relationships`, `DisclosureCooldown` +**Output component:** `DisclosureCandidates { candidates: Vec, computed_tick: u64 }` + +**The algorithm (pseudocode):** + +``` +fn derive_disclosure_candidates( + npc_kg: &KnowledgeGraph, + npc_traits: &TraitProfile, // from NPC's authored profile + player_trust_tier: TrustTier, // derived from NPC Relationships + disclosed_facts: &BTreeSet, // from DisclosureCooldown +) -> Vec: + + // Step 1: Determine minimum confidence threshold based on trait + min_confidence = match npc_traits.primary_trait: + Cautious => KnowsDetails // only shares things they're certain of + Gossipy => Suspects // shares everything including rumors + _ => KnowsOf // default: established knowledge only + + // Step 2: Scan KG facts + candidates = [] + for (fact_id, fact) in npc_kg.known_facts_iter(): + + // Exclude: non-Active state (Stale = unreliable, Contradicted = NPC is confused) + if fact.state != Active: continue + + // Exclude: below trait-modulated confidence threshold + if fact.confidence < min_confidence: continue + + // Exclude: already disclosed to this player + if fact_id in disclosed_facts: continue + + // Exclude: entry has disclosure_blocked = true (Major secret / never-share) + if fact.disclosure_blocked: continue + + // Exclude: trust gate — some fact categories require higher trust + // IMPORTANT: This is a ROUGH gate only. Layer 4 trust filtering (D-028 Layers 1-3) + // does the precise trust check. This prevents obviously sensitive facts from + // reaching Layer 4 at all for low-trust players. + if player_trust_tier < Surface: continue + if fact_id.category == "secret.*" && player_trust_tier < Real: continue + + // Trait: Loyal — don't disclose facts about entities the NPC trusts/protects + // (entity-linked facts use "entity.{sid}.*" namespace convention) + if npc_traits.has(Loyal): + if fact is linked to a protected entity: continue + + candidates.push(FactId, fact.confidence, fact.last_updated_tick) + + // Step 3: Order candidates + // Primary: higher confidence first (NPC leads with what they're most sure of) + // Secondary: more recently updated first (current-game-state relevance) + candidates.sort_by(|a, b| + b.confidence.cmp(a.confidence) + .then(b.last_updated_tick.cmp(a.last_updated_tick)) + ) + + // Step 4: Cap the candidate list + // Layer 4 line selection narrows further based on location, witnesses, etc. + // We pre-filter to avoid unnecessary iteration downstream. + candidates.truncate(10) + + return candidates.map(|(id, _, _)| id) +``` + +### Notes on the algorithm + +**Why no entity candidates in v0.1?** Entity-specific disclosure ("Kael does X") flows through the LINE CONTENT and the `knowledge_grant` on the line, not through the candidate derivation. The candidate is the FactId that TRIGGERS line selection (e.g., `FactId("kael.cargo_intake_role")` is in Sera's KG, triggers selection of her "Kael runs a tight intake process" line, which then grants both the FactKnowledge and the EntityKnowledge via compound grant). Entity candidate derivation as a separate category is Sprint 18. + +**The "Loyal" trait implementation note for Dudley:** Requires a convention: FactIds about specific entities use the namespace `"entity.{stable_id_as_hex}.*"`. The Loyal check then queries the NPC's Relationships component to see if the linked entity has `trust_level > threshold`. This is a convention, not a type change. + +**Why Contradicted facts are excluded:** An NPC who knows something is contradicted (they received conflicting information themselves) shouldn't volunteer the contradicted entry as if it's fact. However: this creates an interesting future case where an NPC might disclose their OWN confusion ("I heard Kael was at the dock but I could have sworn I saw him elsewhere"). This is emergent from system collision and is exactly the kind of thing to design into Sprint 18+. For v0.1: exclude Contradicted entries from candidates. + +**The `fact_id.category` trust gate:** The check `if fact_id.category == "secret.*" && trust < Real` is a rough filter using the existing FactId namespace convention (`"category.topic"`). Facts in the `secret.*` namespace require Real trust to even appear as candidates. Facts in `poi.*`, `event.*`, `cargo.*`, etc. require only Surface. This is content-convention enforcement at the algorithm level, not a schema change. + +### What Paula needs to confirm + +1. **Is "Contradicted facts excluded from candidates" correct?** Or should there be a separate `DisclosureMode::Confusion` that surfaces contradicted facts as uncertain disclosure ("I'm not sure about this, but...")? + +2. **For THE FRIEND arc specifically:** Sera's Phase 2 disclosures about Kael — are these `FactId("kael.*")` entries in her initial KG, or are they `EntityKnowledge` attributes? My algorithm treats them as facts (the trigger mechanism). The compound grant on the line creates the EntityKnowledge as a side effect. Does this match your narrative design for Phase 2? + +3. **Witness inhibition threshold:** I said "no non-trusted NPCs within 3 tiles OR trust >= Real overrides." Does "trusted" mean trust_level >= Real toward the NPC, or toward the player? (It should be: no NPCs with low trust toward the disclosing NPC, not toward the player — Sera won't confide to the detective in front of a coworker she doesn't trust, regardless of how much she trusts the detective.) + +--- + +## D-Record Draft Sections (Mechanical Interactions and Fun Factor) + +The following are my contributions to the workshop's D-record. The full D-record synthesis will be Qatux's job; I'm writing my sections for inclusion. + +### Section: Mechanical Interactions + +**The knowledge engine: how the five topics form a closed loop** + +Topics 1-5 are not independent features — they form a directed graph of mechanical interactions. Understanding this graph is essential for implementation prioritization and for assessing whether the design is "interesting" (vs merely functional). + +``` +[NPC Gossip (Topic 2)] ──ToldBy sources──► [EntityKnowledge populated with ToldBy] + │ │ + ▼ ▼ +[Player Dialogue (Topic 1)] ──grants──► [Player KG: ToldBy + DirectObservation] + │ + Position/attribute mismatch? + │ + ▼ + [Contradiction Detection (Topic 5)] + │ + ┌───────────────────────┼────────────────────┐ + ▼ ▼ ▼ + [Monologue fires] [Relationship → POI] [D-033 amber] + +[NPC KG-aware Behavior (Topic 4)] ─────────────────────────────────────────► + tell_state reads relationships, not axes; disclosure uses own KG + +[Unprompted Disclosure (Topic 3)] ◄── DisclosureCandidates ── NPC's own KG + │ + └── Knowledge grants to player (via line knowledge_grant) + └── feeds back to player KG → possible future contradictions +``` + +The critical path through this graph for THE FRIEND arc: +- Topic 1 (entity grant) → ToldBy EntityKnowledge exists → Topic 5 can fire → monologue lands + +The enrichment path (makes the world feel inhabited): +- Topic 2 (gossip) → NPC KGs populate → Topic 3 (disclosure) → player learns via NPCs → Topic 1 (grant mechanism) converts disclosure to KG state + +**Every system interaction should produce asymmetric information.** If a mechanical interaction produces only symmetric effects (player and world learn the same thing at the same time), it's not serving the core design. Rate-check for each topic: + +| System | Asymmetry produced | +|--------|-------------------| +| Topic 1 grant | Player's KG gets ToldBy source; NPC doesn't know what player now knows | +| Topic 2 gossip | NPC A and B share knowledge; player may or may not observe the conversation | +| Topic 3 disclosure | NPC volunteers info from own KG; doesn't know what player already has | +| Topic 4 boundaries | NPC's behavior reflects what it knows, not ground truth; player sees the difference | +| Topic 5 detection | Player knows there's a contradiction; NPC doesn't know player noticed | + +Every topic in this workshop produces or amplifies asymmetric information. This is the confirmation that the design is correct at its foundations. + +### Section: Fun Factor — "Is This System Interesting?" + +The core question for each mechanic: **does it require the player to make a decision about information?** + +**Topic 1 (Grant mechanism) — Fun factor: HIGH** + +The grant mechanism creates the moment where "talking to people produces game state changes, not just text." The player realizes: "I should have talked to Sera before going to the dock — she knew where Kael was." Or the reverse: "I went to the dock first, now Sera's testimony is suspicious." The order you gather information changes what contradictions you can detect. This produces player choice about investigation sequence. + +**Topic 2 (NPC gossip) — Fun factor: HIGH (with variance as the key)** + +The random 1-3 fact selection per conversation means gossip is probabilistic. The player can KNOW that two NPCs regularly interact (observable from conversation system, D-078 eavesdropping) and try to exploit that channel — but what they overhear is never complete or reliable. This creates the "I need to check this against a direct source" loop. The trust-cap (`min(source_confidence, KnowsOf)`) means gossip gives leads, never certainties. Correct design. + +**Topic 3 (Unprompted disclosure) — Fun factor: VERY HIGH — the emotional engine** + +This is the system that makes NPCs feel like they have an independent relationship with information. The player notices: "Sera keeps telling me about cargo processes. She seems to trust me." This builds the expectation of Sera as a friendly source. When the contradiction lands, the betrayal has weight because the prior gift-giving happened. Without disclosure, Sera is just a dialogue tree. WITH disclosure, she's a character who chose to share things with you. + +The decision the player makes with disclosure information is whether to trust it. "Sera just told me Kael's been great — but I found those discrepancies. Is she lying? Does she not know? Is she covering for him?" This is the investigative headspace the game is built around. + +**Topic 4 (NPC boundaries) — Fun factor: MEDIUM (enablement layer)** + +NPC information boundaries don't create direct player decisions — they prevent the system from cheating. An NPC who tells you about cargo discrepancies because the DATA says there are discrepancies (not because their KG says they know about discrepancies) is an NPC player can intuit as hollow. Boundaries make NPCs feel real; real NPCs make player decisions feel meaningful. Topic 4 is infrastructure for fun, not fun itself. + +Exception: the `tell_state` KG integration (NPC shows more stressed tells when they know they're being watched) creates a detection mechanic. Player observes elevated tell. Player infers: "This NPC knows I'm investigating." Player decision: confront now or gather more evidence first. That's a direct fun output from the boundary change. + +**Topic 5 (Contradiction detection) — Fun factor: CRITICAL MOMENT** + +Contradiction detection produces the defining moment of the game's design: the moment asymmetric information systems collide. The player has been building a mental model. The system detects the model is wrong. The monologue fires. The world shifts. + +The key design success criterion: this should feel like a DISCOVERY, not a notification. The player shouldn't feel like the system told them "contradiction detected." They should feel like THEY noticed something wrong. The monologue text makes this work: "Sera said Kael was at the dock. I just saw him in B-7." This is the player-character's thought, not a system alert. + +This is why Paula's requirement (named-source contradiction monologue) is non-negotiable for fun. Generic "that doesn't add up" is a notification. "Sera said..." is a thought. + +--- + +## Implementation Scope Recommendations for Sprint 17 + +Based on the full Round 2 analysis, here is my recommendation for what Sprint 17 should and should not attempt: + +**Sprint 17 MUST include:** +1. `KnowledgeEventType::KnowledgeGranted` (FactGrant + EntityPositionGrant payloads) +2. `ContradictionClaim` struct field on `EntityKnowledge` +3. Location contradiction detection in `observe_entity` (Tyre's Approach B) +4. `ContradictionDetected` event + downstream chain (already built, just needs the trigger) +5. Wire `knowledge_grant` field in `process_talk_interaction` +6. NPC-to-NPC transfer via `transfer_npc_knowledge` system +7. `DisclosureCandidates` component + `derive_disclosure_candidates` system +8. `process_unprompted_disclosure` stub (trigger gates + candidate selection; content-authored lines can be sparse for Sprint 17) +9. `tell_state.rs` add `Option<&KnowledgeGraph>` to query (MVP boundary) +10. Formal Q-024, Q-025, Q-026 closure + +**Sprint 17 should NOT include:** +- Full `EntityGrant { attributes: BTreeMap }` variant (Sprint 18) +- Conversation partner KG-awareness check (Sprint 18) +- Attribute contradiction via YAML-authored pairs (Sprint 18 — requires typed attribute keys) +- Template monologue system for named contradictions (Sprint 18; Sprint 17 hand-authors the FRIEND NPCs) +- "Ask around" emergent information-seeking behavior (v0.2) +- Pathfinding KG integration (v0.2 or never) + +--- + +## Formal Q-Closure Positions + +| Question | My position | Rationale | +|----------|------------|-----------| +| Q-024 (Gossip timing) | CLOSE — resolves to conversation system hook | Workshop consensus; `transfer_npc_knowledge` system is the implementation | +| Q-025 (KG memory pressure) | CLOSE — not needed at current scale | Tyre's 6MB total confirmed; re-evaluate at 500+ Active NPCs | +| Q-026 (Contradiction detection) | CLOSE — resolves to event-driven write-time detection with `ContradictionClaim` | Workshop consensus on architecture; Entity grant scope (Tension A) resolves to Sprint 17 narrow scope | + +--- + +## Dissent Register + +Items where I'm NOT conceding despite consensus pressure: + +**1. Dudley's Option B workaround for `EntityKnowledge`:** I am not withdrawing my objection. String parsing in contradiction detection is wrong. Tyre's struct approach is correct. The only reason to choose Option B is "Sprint 17 time pressure" — and I dispute that adding one optional struct field is meaningfully more time-consuming than implementing a string encoding scheme plus parser. The workaround costs more total time when Sprint 18 refactor is included. + +**2. The `EntityPositionGrant` must be Sprint 17:** Paula is correct that without this, THE FRIEND arc cannot fire its canonical contradiction sequence. Dudley's structured FactId workaround does not faithfully substitute. This is the FRIEND arc's critical path and it must ship in Sprint 17. If the team decides to defer, the decision should be recorded as "THE FRIEND arc contradiction sequence cannot fire in v0.1" — not "we found a workaround." diff --git a/docs/workshops/knowledge-flow-npc-boundaries/paula-round1.md b/docs/workshops/knowledge-flow-npc-boundaries/paula-round1.md new file mode 100644 index 000000000..cfbd445c8 --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/paula-round1.md @@ -0,0 +1,309 @@ +# Paula — Round 1: Narrative Analysis +## Knowledge Flow & NPC Information Boundaries Workshop + +**Date:** 2026-02-23 +**Role:** Narrative and Political Depth + +--- + +## Framing + +The technical gap this workshop addresses — NPCs running on ground truth, gossip not transferring knowledge, contradiction detection not firing — is not just a systems problem. It is a narrative problem. Every one of these missing pieces corresponds to a moment in THE FRIEND arc that cannot yet land. + +Sera Venn volunteers information that the detective doesn't yet know. That's unprompted disclosure (#172). The detective later observes something that contradicts Sera's account. That's contradiction detection (Q-026). The monologue that fires ("Sera said Kael was at the dock. I just saw him in B-7.") is the emotional centerpiece of v0.1 — and it currently cannot happen because the `ToldBy` source that would tie Sera's testimony to that contradiction was never constructed. + +This workshop is asking what it means for an NPC to *know* something. My job is to make sure the answers we reach don't just pass technical review — they have to feel true to how people actually hold information, share it, and get caught in contradictions. + +--- + +## Topic 1: Knowledge Flow — The Grant Mechanism + +### The authoring philosophy question underneath the schema question + +Before discussing whether `KnowledgeGrant` needs an `entity_grant` variant, there is a prior question: *What does it mean for an NPC to "tell" the player something?* + +In the current system, dialogue lines are content artifacts. They sit in line pools, get scored and selected, get displayed. They don't *do* anything to the world's information state. The player reads "Kael runs the cargo intake" and nothing changes in the KG. The dialogue was informative in the sense that a book is informative — it delivered text — but it didn't create a knowledge relationship between the observer and the subject. + +Testimony is not just text delivery. When Sera says "Kael was at the dock during second shift," she is creating a *source-attributed claim about a specific person at a specific time*. That claim has provenance (Sera, tick N), it has a confidence level (KnowsOf — she's not guessing, she observed), and it can later be contradicted. The grant mechanism is what makes the claim real in the system. + +### Position: `KnowledgeGrant` needs two variants + +The current schema (`KnowledgeGrant { fact_id: String, confidence: String }`) only covers fact knowledge — entries in `facts: BTreeMap`. But Sera's claim about Kael creates **EntityKnowledge**, not just a fact. It should update `entities: BTreeMap` with a new entry for Kael, sourced `ToldBy { source_id: sera_sid, tick }`. + +I propose `KnowledgeGrant` be extended to a sum type: + +``` +enum KnowledgeGrantTarget { + Fact { fact_id: FactId }, + Entity { entity_sid: StableId, attribute_key: String, attribute_value: String }, + EntityPosition { entity_sid: StableId, location_description: String }, +} +``` + +This is not just a schema preference. The downstream consequences are: +- **Contradiction detection requires EntityKnowledge**, not just facts. The canonical contradiction (Sera says "dock", player observes "B-7") operates on `EntityKnowledge.last_known_position` and `known_attributes`. If Sera's testimony only creates a `FactKnowledge` entry, the contradiction detector has nothing to compare against the `DirectObservation` that updates `EntityKnowledge`. +- **Source attribution requires the grant to create a `ToldBy` entry.** If the detective's KG entry for Kael is created `source: ToldBy { source_id: sera_sid, tick }`, the contradiction later marks BOTH the DirectObservation entry AND the ToldBy entry as `Contradicted`. The system can then surface: "Sera told you X. You observed Y. These conflict." Without the entity grant, you lose the source chain. + +### Content authoring guardrail: what NPCs can grant = what they know + +The workshop brief raises the question of content validation. My position: **NPCs should only be able to grant knowledge they hold in their own KnowledgeGraph.** + +This is not merely an authoring convenience. It is a narrative truth constraint. If Sera's own KG doesn't contain an EntityKnowledge entry for Kael-at-the-dock, she cannot have told the detective about it — because she doesn't know. An author who writes a dialogue line with `knowledge_grant: { entity: kael_sid, position: "dock 7" }` on Sera's line has asserted that Sera knows this. The validator should check: does Sera's authored initial KG state include this entry? If not, flag it. + +Runtime enforcement is harder (NPC KGs evolve during play), but the authoring-time check catches the obvious cases and forces authors to make NPC knowledge explicit. + +### POI discovery as knowledge flow + +For POI knowledge, I prefer option (a): a `FactId` category "poi.*" (e.g., `poi.dock_7_restricted`). The reasons are narrative: + +- POI knowledge is *about a location*, not about a person. It fits the `FactKnowledge` model cleanly. +- The player "discovering" a POI is precisely a `DirectObservation` event, just targeted at a location rather than an entity. +- Dialogue-granted POI knowledge (an NPC saying "there's a restricted cargo bay off corridor B-7") should create a `ToldBy` FactKnowledge entry at `Suspects` or `KnowsOf` confidence — lower than direct discovery. +- This integrates cleanly with `filter_by_access`: a player needs `poi.dock_7_restricted` in their KG at KnowsOf+ to get dialogue options about that location. + +### Physical evidence vs dialogue grants + +I see these as the *same grant mechanism* but with different triggering events: +- Dialogue-triggered: post-line-selection, the dialogue system constructs a `KnowledgeEvent` and pushes it to the queue +- Evidence-triggered: player "reads" a document or examines evidence, a similar event fires + +The difference is the `source` field: dialogue creates `ToldBy`, evidence examination creates `DirectObservation` (you're directly observing the document's contents). A new `KnowledgeEventType::KnowledgeGranted { source: KnowledgeSource, target: KnowledgeGrantTarget }` covers both cases cleanly without a separate variant for evidence. + +--- + +## Topic 2: NPC-to-NPC Knowledge Propagation (Q-024) + +### The routine intersection hook is narratively correct + +The existing `run_npc_conversations` system pairing NPCs by proximity is *already* modeling something true about information flow in a community: people share information when they're in physical proximity, during routine overlap, not via some abstract information broadcast. This is the right hook. No separate system needed. + +What I want to advocate for is the *content* of what transfers, not just the mechanism. + +### Trust-gated filtering: not just tiers, but relationship character + +The proposed mapping (surface trust = public facts, real trust = observations/rumors, secret trust = sensitive knowledge) is correct in structure, but misses something. The *character* of what NPC A shares with NPC B depends not just on trust level but on *subject matter*: + +- Voss will share public knowledge about cargo schedules with anyone. He'll share his suspicions about manifest discrepancies only with people he trusts. He'll never share that he knows about the ring — regardless of trust tier — because he's terrified. +- Kael will share warmth freely. His secret (trying to exit the ring) is not something he'd share even at the highest trust tier, because sharing it is dangerous. + +This suggests trust-gated filtering should be necessary-but-not-sufficient. Alongside trust tier, NPCs should have a `disclosure_threshold_override` on individual KG entries — a flag that says "even at maximum trust, don't share this." This is how Major secrets should behave: they are withheld at the KG entry level, not filtered by trust tier. + +### Confidence downgrade on transfer: the proposed rule works + +The proposed rule (`ToldBy` confidence = `min(source_confidence, KnowsOf)`) is right. When NPC A tells NPC B something, B doesn't have A's direct observation — B has A's report. That's `KnowsOf` at best. `Direct` observation never transfers through gossip; you can't tell someone else what it's like to *see* something and have them receive that experience. + +The narrative effect is important: gossip chains dilute information. By the time the fifth NPC in a chain hears something, it's `Suspects`-level, not `KnowsDetails`. This is accurate to how communities work. + +### Rate limiting: 1-3 facts per conversation, but weighted by relationship + +A fixed cap of 1-3 facts per conversation works mechanically. But I'd weight by trust level: +- Surface trust: 0-1 facts (pleasantries and public knowledge only) +- Real trust: 1-2 facts +- Secret trust: up to 3 facts, but only if NPC has relevant high-trust entries + +The goal is to prevent knowledge-explosion (correctly identified in the brief) while also preventing "people with deep trust never share anything." The weighting makes NPCs feel like they're calibrated to the relationship. + +### Overheard NPC conversations: always Suspects, no exceptions + +If the player overhears an NPC-NPC conversation (D-078), the player's KG entry should always be at `Suspects` confidence, regardless of how much of the conversation survived occlusion. Here's why: + +1. **The player lacks context.** They heard words, not an explanation. "He said Kael was at the dock" means nothing without knowing who "he" is, what "the dock" refers to, whether this is past or present. +2. **Suspicion is the correct epistemic state.** You overheard something. You don't know if it's true. `Suspects` is honest. +3. **It creates investigative motivation.** You need to confirm. You seek out sources. You build the case. `KnowsOf` for overheard information would shortcut this. + +The source variant should be `KnowledgeSource::Heard { tick, range: Close }` — not `ToldBy`, because the NPC didn't tell *you* anything; you were eavesdropping. This distinction matters for contradiction detection: an overheard claim is harder to attribute for contradiction purposes than a direct testimony. + +--- + +## Topic 3: Unprompted Disclosure Design (#172) + +This is where I have the strongest opinions, because this is where THE FRIEND arc's emotional setup lives. + +### The setup that has to work + +Sera Venn's relationship with the detective follows this arc (from D-034): +- Phase 1 (trust): Commission tech, bar regular, warm and informative, socially comfortable +- Phase 2 (data): Begins volunteering information about cargo processes, casually — this is unprompted disclosure +- Phase 3 (recognition): Player observes a behavioral tell — Sera avoids Torek Lintar +- Phase 4 (question): Player realizes Sera is sitting on unreported evidence +- Phase 5 (contamination): Trust is re-evaluated + +The whole arc *requires* that Phase 2 actually happen mechanically. Sera must volunteer information to the player before the contradiction. If she only speaks when spoken to (dialogue tree), the player has no expectation of Sera as a source — and when the contradiction lands, it doesn't mean anything. The sense of betrayal requires prior gift-giving. + +This is the narrative case for getting unprompted disclosure right: without it, there is no FRIEND arc. There is only an NPC who sometimes lies. + +### "Do I know something you don't?" — Option A, firmly + +NPCs should NOT check the player's KG before deciding to disclose. Option A (NPC checks own KG only) is the right choice, for narrative reasons that I think are non-negotiable: + +1. **Dramatic irony requires asymmetric knowledge.** When Sera tells the detective "Kael runs a tight intake process — never a discrepancy on his watch" in Phase 2 — and the player has already found manifest discrepancies — the dramatic irony is crushing. Sera *doesn't know* the player already knows. She's praising someone whose cover is already partially blown. If Sera checked the player's KG and saw KnowsOf for manifest discrepancies, she might not say this. You've lost the moment. + +2. **NPCs not knowing what you know is a feature, not a bug.** It's the source of almost all the interesting social texture. Characters talk at cross-purposes. People are still defending someone the player has already made a case against. The community is behind the detective. + +3. **Option B creates a surveillance panopticon.** If NPCs know what the player knows, they're behaving as if they have access to the player's cognitive state. That's not a knowledge boundary — that's a boundary violation. + +The result is that NPCs may repeat information the player already has. This is fine. The player has heard this before — but hearing it from THIS person, at THIS trust level, after THIS much has been discovered, has different weight. Authors should lean into it: "She's still defending him. Doesn't she know?" + +### Trait filtering: both, with specific meaning for each + +The brief asks: do traits affect WHAT is disclosed (filtering) or HOW (delivery)? The answer must be both, but they operate at different layers: + +- **What (filtering):** Cautious NPCs have a higher disclosure threshold — they need more trust and more of their specific mood conditions before any fact makes it to the disclosure candidates list. This is a KG-level filter. A cautious Sera might have 40 facts in her KG but only 2 that would clear her disclosure threshold at any given trust level. +- **How (delivery):** Once a fact clears the threshold, Sera's specific personality shapes the line. Her warmth, her slight formality, her tendency to frame things in terms of professional competence. This is the line pool scoring modifier — same facts, different pool weighting. + +The two-layer model means: trait changes "how many things Sera would ever say unprompted" (filtering) AND "how she says the things she does say" (delivery). This is richer than either alone and avoids the edge case where a cautious NPC with a Major secret that must be disclosed has no way to shape the delivery. + +### Trigger conditions + +Proposed trigger set for unprompted disclosure: +1. **Trust threshold met** (RelationshipState: Friendly minimum) +2. **Contentment above neutral** (> 0 on the Contentment axis) +3. **Mood compatible** (not Hostile, Anxious above threshold, or Frustrated) — use the 8-mood vocabulary from D-035/D-035 Amendment Sprint 14 +4. **Location appropriate** — this condition is missing from the brief and I want to argue for it explicitly (see below) +5. **Rate limit not exceeded** (the existing `LINE_COOLDOWN_TICKS: u64 = 600` from dialogue.rs line 39 should apply) + +**Location appropriateness as a disclosure gate:** + +Sera won't confide sensitive information at the Terminal (too many witnesses, professional context). She will at The Last Shift in a quiet corner. This maps to D-025's public/private spatial distinction — "functional cluster defines spatial identity including public/private." + +Content authors should be able to tag disclosure candidates with a `location_privacy: [private, semi-private, any]` field. The disclosure trigger checks the NPC's current location against this. A `private`-tagged disclosure won't fire at The Terminal. This is not just realism — it creates spatial behavior patterns the player can learn: "If I want Sera to talk, I need to catch her at Lera's, not at work." + +### Rate limiting structure + +Three-layer rate limiting: +1. **Per-fact cooldown** (highest priority): once a fact has been disclosed, mark it in the NPC's disclosure state. Don't repeat it. The player has heard it. Repeating it is noise. +2. **Per-NPC cooldown** (second): prevents disclosure spam in a single interaction. The `LINE_COOLDOWN_TICKS` (600 ticks) seems right as a floor. +3. **Global rate limit across all NPCs** (third, lightest): exists mainly to prevent a degenerate case where the player visits 8 NPCs in rapid succession and gets flooded with disclosures in one game-hour. + +The per-fact cooldown is the most important narratively: an NPC who says the same thing twice is a quest marker, not a person. + +--- + +## Topic 4: NPC Information Boundaries (#142) + +### The narrative argument for minimum viable first + +The workshop brief correctly identifies minimum viable boundary as `tell_state.rs` + unprompted disclosure. I want to reinforce *why* this is the right order from a narrative standpoint — not just a feasibility standpoint. + +`tell_state.rs` currently derives tell category from raw axes: Secret severity, stress vs. threshold, contentment, mood, relationships. This produces behaviors like "NPC is Guarded because they have a Major secret." That's correct. But what if the NPC's *knowledge* of their secret's exposure changes their behavior? Sera's `Guarded` tell should intensify if she *knows* the detective is investigating Kael — not just because her Secret severity is Major, but because her KG contains entries showing investigation proximity. + +The minimum viable boundary retrofit for `tell_state.rs` is: **allow KG-derived knowledge to influence the Secret's effective stress level.** If the NPC knows someone is getting close to their secret, the threat multiplier on `current_stress` increases. This doesn't require a full KG query — just a check: "does my KG contain entries related to the entities/facts that my Secret references, at PersonOfInterest or higher?" + +This is a very small addition that produces a very meaningful behavioral change: NPCs who *know they're being watched* act more stressed. NPCs who are unaware remain calm. + +### Priority order for system retrofits + +From a narrative impact standpoint, I'd prioritize: + +| Priority | System | Narrative Payoff | +|----------|--------|-----------------| +| 1 | `tell_state.rs` | Direct: NPC observable behavior reflects what they know. Every investigation interaction benefits. | +| 2 | Unprompted disclosure (#172) | Direct: Enables Phase 2 of THE FRIEND arc. Without this, Sera is mute pre-contradiction. | +| 3 | `conversation.rs` | Medium: NPC-to-NPC conversations become information events, not just ambient noise. | +| 4 | `routine.rs` | Low for v0.1: routine deviation is already a tell signal (D-027). Whether the routine query uses KG is less impactful initially. | +| 5 | `path_follow.rs` | Very low / deferred: KG-based pathfinding creates stuck-NPC risk (noted in brief). For v0.1, ground truth pathfinding with KG-based decisions is the right split. | + +### Fallback behavior: ground truth with logging, not silent + +For all retrofitted systems, when the NPC's KG has no relevant information, **fall through to ground truth with structured logging** (not silent). The logging serves two purposes: +1. QA visibility: we can see which systems are still operating on ground truth and why +2. Design signal: if we see many log entries for a specific NPC/system, it indicates an authoring gap (NPC's initial KG state is missing entries that should be there) + +Option (c) — "ask around" behavior — is compelling for future sprints. An NPC who doesn't know something seeking out information creates exactly the emergent scenes D-029 needs for the mundane 80%. But it's correctly deferred for v0.1. + +--- + +## Topic 5: Contradiction Detection Pipeline (Q-026) + +This is where the mechanical and narrative come into direct contact, and I have specific things to say about the emotional sequence. + +### The THE FRIEND arc — full mechanical sequence + +The brief asks for a complete walkthrough confirming every system fires correctly. Here is the canonical version from my narrative perspective: + +**Precondition:** Sera's KG contains `EntityKnowledge` for Kael: `last_known_position: Some(DockTile)`, `source: DirectObservation { tick: T0 }`, confidence: `KnowsDetails`. She observed Kael at the dock during second shift. + +**Step 1: The testimony** +- Detective initiates Talk with Sera. Dialogue pipeline selects a line with `knowledge_grant: { entity: kael_sid, position: "dock intake, second shift", confidence: KnowsOf }`. +- `KnowledgeEvent::KnowledgeGranted` fires. Player's KG gains `EntityKnowledge` for Kael: `source: ToldBy { source_id: sera_sid, tick: T1 }`, confidence: `KnowsOf`, position claim: dock, time window: second shift. + +**Step 2: The observation** +- At tick T2 (same game-shift or overlapping time window), player's LOS includes Kael in corridor B-7. +- `KnowledgeEvent::DirectObservation` fires. Player's KG entry for Kael: `source: DirectObservation { tick: T2 }`, confidence: `Direct`, position: B-7. + +**Step 3: Contradiction detection fires** +- Event-driven: on KG write (T2), contradiction detector runs against the new `DirectObservation` entry. +- Check: same entity (Kael_sid), overlapping time window (T1 and T2 within second-shift window), different positions (dock vs B-7). +- Both entries receive `KnowledgeState::Contradicted`. +- `ContradictionDetected` event emits: observer = detective, entries = [ToldBy(Sera, dock), DirectObservation(B-7)], type = location. + +**Step 4: Downstream cascade** +- Monologue system picks up `ContradictionDetected`. Fires contradiction monologue line — **must be authored to name Sera specifically**. Generic "wait, that doesn't add up" is not sufficient here. The line must say: *"Sera said Kael was at the dock during second shift. I'm looking at him in B-7 right now. One of them is wrong."* +- Anomaly system marks Kael AND Sera as `PersonOfInterest` (both entities are implicated — Kael by being in the wrong place, Sera by the testimony that is now contradicted). +- D-033: both entities shift to amber. +- Available dialogue with Sera unlocks Confrontation option. + +**Step 5: The ambiguity** +- The monologue fires `Contradicted` — not "Sera lied." The player doesn't know if Sera is lying, mistaken, or manipulated. The engine is correct to be epistemically neutral. The content must be too. + +### On the "both entries Contradicted" design + +I want to explicitly endorse the design choice that *both* entries receive `Contradicted` state. This is narratively correct. When you have contradictory sources, you can't know which is wrong. The testimony might be wrong (Sera lied/was confused). The observation might be wrong (Kael has a double? Player misidentified?). The engine marks both as uncertain, which is the honest epistemic state. + +Authors writing Sera's post-contradiction dialogue must NOT have Sera behave as if she knows she's been caught. She doesn't know the player has contradicted her testimony. Her behavior changes only if the player CONFRONTS her. This is the "no player-special-casing" principle (D-010) expressed in character psychology. + +### Contradiction content authoring requirements + +This is a gap in the current framework that I want to surface for Round 2 discussion: + +**Contradiction monologue lines must be authored with source attribution.** A generic `trigger: contradiction_detected` monologue line ("that doesn't add up") is insufficient. The player needs to understand WHO provided the contradicted claim. This requires the monologue system to receive the source entity's `StableId` and map it to a displayable name. + +Options: +1. **Templated monologue lines** with entity name substitution: `"{source_name} said {entity_name} was at {claimed_location}. I just saw {entity_name} at {actual_location}."` — clean but requires template string support in the monologue system +2. **Pre-authored lines per relationship phase** — for THE FRIEND NPCs (Kael, Sera), write specific lines for each phase of the relationship that fire on contradiction. This is hand-authored, but THE FRIEND NPCs are already "no generation expansion, all hand-authored" per D-034. +3. **Generic fallback + authored override** — generic template for auto-generated NPCs, hand-authored lines for FRIEND-pattern NPCs + +My preference is option 3. For v0.1 with only two FRIEND NPCs, the hand-authored lines (option 2 flavored content in the generic system) are achievable. The template system is the right long-term architecture. + +### Attribute contradiction — typed keys vs authored pairs + +The brief notes that `known_attributes: BTreeMap` is untyped. For contradiction detection on attributes, I'd prefer **content-authored pairs with structured key conventions over a full typing system**. + +Reasoning: attribute contradiction is rare and narratively significant. You don't want the engine silently marking two minor attribute discrepancies as contradicted. You want the author to say "this specific attribute combination is a contradiction that the player should notice." + +Proposed approach: YAML schema extension with an optional `contradicts` field on `KnowledgeGrant`: +```yaml +knowledge_grant: + entity: kael_sid + attribute_key: "occupation_status" + attribute_value: "legitimate_cargo_handler" + # authored contradiction pair: + contradicts_attribute_value: "ring_member" +``` + +This surfaces the contradictions that *matter narratively* and keeps the detection system from firing on attribute mismatches that are informationally trivial. + +--- + +## Summary: Narrative Requirements for Round 2 Decisions + +The decisions this workshop produces need to satisfy the following narrative requirements to make THE FRIEND arc work: + +| Requirement | Topic | System | Priority | +|-------------|-------|--------|----------| +| Testimony creates EntityKnowledge with ToldBy source | 1 | KnowledgeGrant schema | Critical — blocks contradiction detection | +| NPCs cannot grant knowledge they don't hold | 1 | Content validation | High — authoring integrity | +| POI knowledge via `poi.*` FactId | 1 | KnowledgeGrant schema | Medium | +| Trust-gated NPC-to-NPC sharing with Major secret override | 2 | Propagation model | High — prevents secret leakage | +| Overheard conversations always grant Suspects confidence | 2 | Event model | High — preserves investigation motivation | +| Unprompted disclosure uses Option A (NPC checks own KG only) | 3 | Disclosure algorithm | Critical — enables dramatic irony | +| Location privacy gates unprompted disclosure | 3 | Trigger conditions | High — spatial behavior patterns | +| Per-fact cooldown prevents disclosure repetition | 3 | Rate limiting | High — NPC authenticity | +| tell_state KG awareness (secret exposure intensity) | 4 | tell_state.rs retrofit | High — behavioral authenticity | +| Contradiction monologue names the source entity | 5 | Content system | Critical — emotional payoff | +| Both contradiction entries marked (epistemic neutrality) | 5 | Detection algorithm | Critical — no premature accusation | +| Authored attribute contradiction pairs | 5 | Schema | Medium | + +The three "Critical" items are preconditions for D-027 success criterion #3 ("player names an NPC they felt conflicted about"). If testimony doesn't create ToldBy EntityKnowledge, contradiction detection cannot fire on THE FRIEND arc. If Option B is chosen for unprompted disclosure, the dramatic irony that makes Sera's contradiction land is destroyed. If the contradiction monologue is generic and nameless, the emotional moment dissipates into noise. + +Everything else can be iterated. These three cannot. diff --git a/docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md b/docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md new file mode 100644 index 000000000..14fe3a3e5 --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md @@ -0,0 +1,314 @@ +# Paula — Round 2: Cross-Review and Synthesis +## Knowledge Flow & NPC Information Boundaries Workshop + +**Date:** 2026-02-24 +**Role:** Narrative and Political Depth + +--- + +## Reading the Room + +The consensus is strong. On Option A, on FactId POI, on event-driven detection, on both-entries-Contradicted — every participant reached the same conclusions independently. The disagreements are real but narrow, and Qatux has mapped them accurately. + +My Round 2 work is: + +1. **Tension A** — Respond to Dudley's registry concern with a concrete narrative-minimum position +2. **Tension B** — Take a position from narrative integrity grounds (I deferred in Round 1; I shouldn't have) +3. **Secondary disagreements** — Resolve or concede on several minor items +4. **Contradiction monologue** — Specify exactly what Mellanie needs to author +5. **D-record draft** — Narrative consequences, NPC disclosure behavior, THE FRIEND arc mechanical sequence + +--- + +## Tension A: Entity Grants — My Revised Position + +My Round 1 position was "Sprint 17, critical prerequisite, entity grants required." Dudley responded with a structured FactId workaround. I've now read his full walkthrough carefully, and I'm revising. + +**The honest truth is: Dudley's workaround preserves the three narrative requirements I called non-negotiable.** + +Let me check each against his proposal: + +**Requirement 1: ToldBy source attribution** — Dudley's `KnowledgeGranted` event includes `source_id: StableId` (the NPC who granted the knowledge). When the fact `FactId("entity.kael-davan.location.second-shift-dock")` is inserted into the player's `facts` map, the `FactKnowledge.source = ToldBy { source_id: sera_sid, tick: T1 }` is preserved. Source attribution survives in the FactKnowledge entry. ✓ + +**Requirement 2: Contradiction detection fires on THE FRIEND arc** — Dudley's walkthrough shows this working: the fact grant encodes the claimed location, the DirectObservation writes the actual location as a structured attribute, the contradiction detector cross-references them. Complicated, but it works. ✓ (with caveats — see below) + +**Requirement 3: Monologue can name Sera** — If the `FactKnowledge.source = ToldBy { source_id: sera_sid }` is accessible to the monologue system at contradiction time, Sera can be named. The key open question (Gestalt flagged for Dudley) is whether `StableId → NpcName` resolution is available in monologue system context. If yes, Requirement 3 survives. ✓ (contingent on Dudley confirming the lookup) + +So: **I accept Dudley's Sprint 17 workaround, with three specific conditions.** + +### Conditions on accepting the FactId workaround + +**Condition 1: FactId naming convention must encode entity identity in a resolver-compatible way.** + +`FactId("entity.kael-davan.location.second-shift-dock")` requires the contradiction detector to map `"kael-davan"` → `kael_sid` at runtime. The lookup should work via `EntityRegistry` + `NpcName` component (query: "find the entity whose canonical slug is 'kael-davan'"). This requires NpcName to carry a canonical slug field alongside the display name, or a separate slug-to-StableId lookup. If NpcName only carries `"Kael Davan"` (display), the detector needs a slug derivation function. + +This is a small but load-bearing detail. **Dudley must confirm this resolution path before Sprint 17 implementation begins.** The FactId convention is useless if the contradiction detector can't cross-reference it against EntityKnowledge entries. + +**Condition 2: The FactKnowledge `ToldBy` source must flow through the ContradictionDetected event.** + +When contradiction detection fires on the Kael location fact, the `ContradictionDetected` event must include `entry_a_source: KnowledgeSource::ToldBy { source_id: sera_sid, tick: T1 }`. This is what lets the monologue system name Sera. If the event only carries `StableId`s and not the full `KnowledgeSource` (Dudley's event sketch in his Round 1 does include this — see his `entry_a_source: KnowledgeSource` field), this is already satisfied. + +**Condition 3: Sprint 18 entity grants are a formal commitment, not an aspiration.** + +The FactId workaround is architecturally inelegant. It creates a cross-reference dependency between `facts` and `entities` maps that doesn't exist today. It requires string-parsing entity slugs at contradiction detection time. It means the player's KG doesn't gain an `EntityKnowledge` entry for Kael just because Sera mentioned him — the player only "knows Kael exists" after directly observing him. + +That last point is narratively acceptable for v0.1 (the detective doesn't put Kael in their mental entity roster from hearsay alone; they have to encounter him). But it limits what other systems can do with entity-level testimony before the player has LOS on the target. + +**The D-record for this workshop should explicitly record Sprint 18 entity grants as committed scope, not "future enhancement."** + +--- + +## Tension B: EntityKnowledge Structural Fix — Taking a Position + +I deferred in Round 1. I shouldn't have. Here's my position: + +**The Tyre/Gestalt typed struct approach (`contradicted_claim: Option`) is narratively required, and I advocate for it over Dudley's Option B string-encoding even for Sprint 17.** + +The reason is specific and non-trivial: Dudley's Option B encodes the prior claim's source as a string within `known_attributes`: `"claim.{tick}.position" = "{x},{y},{source_stable_id}"`. The `source_stable_id` is an integer serialized as a string. Parsing it back to a `StableId` for monologue source lookup adds another string→typed-value step on top of the already-required slug→StableId step in Condition 1 above. + +The Tyre/Gestalt approach stores `ContradictionClaim { source: KnowledgeSource, position: TilePosition, tick: u64 }` — typed. When the monologue system reads the `ContradictionDetected` event, it gets `ToldBy { source_id: StableId }` directly. No parsing. + +**This is a 15-line struct addition to `EntityKnowledge` versus an encoding/parsing convention that creates two points of fragility.** From a narrative integrity standpoint, the source attribution must be clean at every step in the chain. The monologue system is the consumer — it must be able to name the source reliably. + +I endorse Tyre and Gestalt's approach. I'll note that Gestalt's naming (`contradiction_basis`) and Tyre's naming (`contradicted_claim`) are identical architecture — they should align on one name in Round 2 synthesis. My preference: `prior_claim` — it's the most semantically precise ("the claim made before this observation contradicted it"). + +--- + +## Secondary Disagreements — Resolved Positions + +### Global rate limit (3-vs-1 with Gestalt dissenting) + +I'm softening. Gestalt's concern — that a per-tick global cap creates invisible NPC competition — is valid. If Voss and Sera both have disclosure candidates and are both in range, and the cap silently selects Voss, the player never knows Sera had something to say. That's an invisible cost. + +**Revised position:** Support the trigger conditions being strict enough that simultaneous disclosure is rare by design rather than enforced by a hard cap. If the requirements (Friendly relationship + positive Contentment + compatible mood + location privacy + witness inhibition) are all gates, the probability of two NPCs meeting them simultaneously is low. No global cap needed for v0.1. If it turns out disclosure spam occurs in playtesting, add the cap then. + +**I withdraw the global rate limit requirement.** Gestalt is right. + +### Location privacy gate (my proposal, no opposition but no support) + +I'm maintaining this as a v0.1 trigger condition. It's the only position from my Round 1 that none of the engineers addressed — not because they disagreed, but because it requires a content-side decision before implementation. + +Concretely: disclosure candidates should be taggable with `disclosure_context: [private, semi-private, any]` in the YAML schema. The `process_unprompted_disclosure` system checks the NPC's current location type (itself a fact in the environment data) against this tag before firing. + +For THE FRIEND arc, Sera's Phase 2 disclosures about Kael's professional behavior should be `semi-private` — she'd say them at The Last Shift over a drink, not at The Terminal while colleagues are nearby. This creates a spatial behavior pattern the player can learn and exploit. + +**This is a Mellanie authoring question as much as a system design question**: authors must choose `disclosure_context` when writing each disclosure candidate. I'll make sure the authoring guide covers it. + +### Trust-weighted rate limiting vs flat rand 1-3 + +I concede. The flat `rand.random_range(1..=3)` is simpler and Tyre, Gestalt, and Dudley all converge on it. My trust-weighted variant (Surface: 0-1, Real: 1-2, Secret: up to 3) adds complexity without strong mechanical payoff — the trust tier already gates which facts are eligible, so the count cap is just a ceiling on an already-filtered pool. Flat random is fine. + +**I withdraw the trust-weighted rate limit proposal.** + +### Major secret `disclosure_threshold_override` + +I'm maintaining this, but reframing it more precisely for engineering clarity. + +The core requirement: **Kael should never disclose his ring membership even at maximum trust, because doing so is dangerous to him regardless of relationship closeness.** + +This doesn't need a new per-entry override field. It can be implemented as a **disclosure eligibility tag on KG entries**. When Kael's KG is authored, his ring-membership fact is tagged `disclosure_eligible: false`. The `derive_disclosure_candidates` system filters out any entry with this tag regardless of trust tier. + +This is the same as a pre-filter, but it's expressed as content authoring (an attribute on the KG entry's initial state) rather than a runtime flag. It reads cleanly: "this fact exists in the NPC's knowledge, but they will never volunteer it." Authors set it for secrets that have survival stakes. + +**Proposed addition**: `disclosure_eligible: bool` field on authored KG entries in the NPC profile YAML. Defaults to `true`. Set to `false` for Major secrets that must never be disclosed unprompted (only revealed via confrontation dialogue, not voluntary disclosure). + +--- + +## Contradiction Monologue — Specification for Mellanie + +This is the open question from Qatux's notes that must be resolved before Mellanie can author contradiction lines. I'm resolving it here. + +### What the monologue system needs from the ContradictionDetected event + +The monologue system must receive: +- `source_id: StableId` — who told the player the contradicted claim (Sera's StableId) +- `subject_id: StableId` — who/what the contradicted claim is about (Kael's StableId) +- `contradiction_type: ContradictionType::Location` (or Attribute) +- `claimed_state: String` — the prior claim in human-readable form (resolved from the FactKnowledge entry or ContradictionClaim struct) +- `observed_state: String` — what the player actually observed + +The source_id → displayable name resolution (Gestalt's open question) works via: `EntityRegistry.to_entity(source_id) → NpcName.display_name`. **Dudley must confirm this is accessible in monologue system context.** If it is, everything else follows. + +### Lines Mellanie needs to author: Sera/Kael contradiction + +**Trigger specification:** +- Character: `detective` +- Trigger: `contradiction_detected` +- Prerequisite: `{ entity: sera_sid, state: Contradicted }` AND `{ entity: kael_sid, state: Contradicted }` +- Trust phase at detection time: should vary by relationship phase (Phase 2 vs Phase 3) + +**Required lines per phase:** + +**Phase 2 contradiction (trust established but not deep — the blindsiding):** +Primary: *"Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."* (flat factual statement, no emotional interpretation — the character is still processing) + +Secondary beat (immediate follow-up, 3-5 seconds later): *"One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."* (cognitive dissonance without accusation — the detective is genuinely uncertain) + +**Phase 3 contradiction (later in the arc, if detective has more context on Sera's avoidance patterns):** +If additional context has accumulated (Sera's avoidance of Torek is already observed), a different line fires: *"Sera told me Kael doesn't make mistakes. He's not where she said he'd be. And she's been avoiding Lintar for three weeks."* (connecting the dots, still not accusatory but seeing a pattern) + +**Generic fallback (for auto-generated NPCs with location contradictions):** +*"{source_name} placed {subject_name} at {claimed_location}. I just saw them at {actual_location}."* — template substitution, no authored emotional tone. This is the system default; FRIEND-pattern NPCs always use hand-authored lines. + +### The authoring principle + +The contradiction monologue must express cognitive dissonance, not accusation. The detective doesn't know who or what is wrong. The emotional weight comes from the uncertainty, not from naming a villain. Mellanie should write as if the detective is genuinely confused first, suspicious second, and making accusations never (that's the player's job after further investigation). + +Both Sera and Kael shift to amber (PersonOfInterest) from this moment. The Confrontation option appears for both. The detective's available topics with Sera narrow — she can no longer be engaged on subjects related to Kael without the option to confront. This is mechanical consequence; the monologue just supplies the character's internal experience of the moment. + +--- + +## D-Record Draft Sections (My Domain) + +These are draft contributions to the workshop's D-record. Tyre/Gestalt/Dudley hold the architecture sections; these are mine. + +--- + +### D-0XX §N: Narrative Consequences of Knowledge Flow Design + +**What it means for an NPC to tell you something:** + +When a dialogue line fires a `KnowledgeGrant`, the grant creates a source-attributed knowledge entry — not just text delivery. The player's KG gains a `FactKnowledge` entry with `source: ToldBy { source_id: npc_sid, tick }`. This models testimony correctly: the claim is attributed to a specific person at a specific time. It can be confirmed, contradicted, or re-evaluated as the player learns more. + +This design enforces that NPCs are *sources*, not just speakers. The same information from two different sources has different provenance — and different vulnerability to contradiction. What Sera told you on Tuesday is not the same epistemic object as what you saw at the dock on Wednesday. + +**Epistemic neutrality as design principle:** + +When contradicting claims are detected, both entries receive `KnowledgeState::Contradicted`. The engine does not determine which claim is wrong. This is not an evasion — it is the correct epistemic stance for a detective story. The player may have misidentified someone. The source may have been deceived. The contradiction may have an explanation that exonerates everyone. The engine marks uncertainty; the player investigates to resolve it. + +**Dramatic irony requires Option A:** + +NPCs checking only their own KG (not the player's) before deciding what to disclose preserves the most valuable structural feature of the narrative: NPCs can say things that are loaded with meaning the player already knows. When Sera praises Kael's operational integrity after the player has found manifest discrepancies, the irony is crushing — and it only works if Sera doesn't know what the player knows. Giving NPCs access to the player's KG would collapse this and produce NPCs who stay silent at exactly the moments their speech would be most dramatically charged. + +--- + +### D-0XX §N+1: NPC Disclosure Behavior (Unprompted Disclosure Design) + +**Disclosure candidate selection:** + +NPC A compiles a `DisclosureCandidates` list by filtering its own KG: +1. Active entries only (not Stale, not Contradicted) +2. Confidence ≥ KnowsOf (Suspects-level facts are not voluntarily disclosed) +3. Not tagged `disclosure_eligible: false` (Major secrets, ring membership, dangerous knowledge) +4. Not recently disclosed to this player (per-fact per-NPC cooldown) + +The NPC does NOT check the player's KG. Facts the player already knows may be disclosed again — this is correct behavior. Repeating information has different weight when the relationship or investigation context has changed. + +**Trigger gate (all conditions must be met):** + +| Condition | Implementation | +|-----------|---------------| +| RelationshipState ≥ Friendly toward player | From player's KG entity entry for this NPC | +| Contentment > 0 | From Contentment axis component | +| Mood not Hostile or Anxious-above-threshold | From MoodState component | +| Disclosure candidates not empty | From DisclosureCandidates component | +| No other NPCs present within N tiles (witness inhibition) | Proximity query (Gestalt's condition) | +| NPC's current location matches candidate's disclosure_context | Environment + YAML tag comparison | +| Per-NPC disclosure cooldown not active | From DisclosureCooldown component | + +**Trait effects — two-stage:** + +Stage 1 (what): Cautious trait removes candidates sourced from `ToldBy` (won't pass on rumors). Gossipy trait includes `Suspects` confidence candidates. Loyal trait suppresses candidates that implicate faction members. Trait → candidate filter predicate applied before trigger check. + +Stage 2 (how): Surviving candidates select from role-specific line pool weighted by mood and topic. Trait modifiers in the line pool (D-028 trait transformation guide) shape delivery. Same fact, different voice. + +**Per-fact cooldown as primary rate limit:** + +Once a fact has been disclosed to the player, it is excluded from candidates until `LINE_COOLDOWN_TICKS` expires (currently 600 ticks = 1 game-hour, from dialogue.rs line 39). This prevents the same NPC from repeating the same information, which is the most authenticity-destroying behavior. NPCs are not quest markers. + +--- + +### D-0XX §N+2: THE FRIEND Arc Mechanical Sequence (Canonical) + +**This sequence is the primary validation test for the knowledge flow and contradiction detection systems. All design decisions in this workshop must be consistent with this sequence firing correctly.** + +The canonical example uses the Detective's FRIEND arc: Sera Venn and Kael Davan (D-034). + +**Preconditions:** +- Sera's authored KG contains: EntityKnowledge for Kael at `KnowsOf`, position claim "dock intake, second shift," `source: DirectObservation` (she has seen this herself) +- Sera has a dialogue line: `"Kael runs intake. Always at dock during second shift — never a discrepancy on his watch."` with `knowledge_grant: { fact_id: "entity.kael-davan.location.second-shift-dock", confidence: "knows_of" }` +- Player's KG has no entry for Kael (has not yet encountered him) + +**Step 1 — Testimony grant (tick T1):** +Detective initiates Talk with Sera. Dialogue pipeline layers 1-3 pass. Layer 4 selects Sera's line. Server fires `KnowledgeGranted { fact_id: FactId("entity.kael-davan.location.second-shift-dock"), confidence: KnowsOf, source_id: sera_sid }` into `KnowledgeEventQueue`. + +`process_knowledge_events` tick T1: inserts `FactKnowledge { confidence: KnowsOf, source: ToldBy { source_id: sera_sid, tick: T1 }, state: Active }` into player's `facts` map. + +`detect_contradictions` check tick T1: no prior claim for this entity. No contradiction. ✓ + +**Step 2 — Direct observation (tick T2, same game-shift as T1 or overlapping window):** +Player LOS includes Kael in corridor B-7. Perception system fires `DirectObservation`. `observe_entity(kael_sid, B7_position, T2)` runs. + +Before overwriting (Tyre/Gestalt's `prior_claim` approach): check whether `EntityKnowledge` for kael_sid exists. It does not yet (player hasn't seen him) — no prior claim to preserve. New `EntityKnowledge { last_known_position: Some(B7), source: DirectObservation { tick: T2 }, confidence: Direct }` created. + +`detect_contradictions` check tick T2: queries player's `facts` map for entries matching "entity.kael-davan.*". Finds `FactId("entity.kael-davan.location.second-shift-dock")` from T1. Resolves "kael-davan" slug → kael_sid via EntityRegistry + NpcName. Compares: fact claims dock at T1, observation finds B7 at T2. T2 - T1 < `CONTRADICTION_WINDOW_TICKS`. Positions differ. **CONTRADICTION DETECTED.** ✓ + +**Step 3 — Both entries marked Contradicted (tick T2):** +`FactKnowledge("entity.kael-davan.location.second-shift-dock").state = Contradicted`. +`EntityKnowledge(kael_sid).state = Contradicted`. + +`ContradictionDetected` event pushed: `{ observer: detective_entity, source_entry: ToldBy { source_id: sera_sid, tick: T1 }, subject_id: kael_sid, contradiction_type: Location }`. ✓ + +**Step 4 — Downstream cascade (tick T2, T3):** +- Anomaly system: `AnomalyMarker` attached to Kael entity (anomaly.rs already handles `Contradicted` state — tested) +- Relationship update: `EntityKnowledge(kael_sid).relationship = PersonOfInterest`. D-033 amber on Kael. +- Contradiction also implicates Sera as source: relationship system sets `EntityKnowledge(sera_sid).relationship = PersonOfInterest`. D-033 amber on Sera. +- Monologue system: receives `ContradictionDetected` event. Resolves `source_id: sera_sid` → `NpcName.display_name: "Sera"`. Selects hand-authored contradiction line for Phase 2 detective-Sera relationship. Fires: *"Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."* + +**Step 5 — New dialogue options unlock (next Talk interaction):** +Confrontation option appears for Sera (new topic, previously invisible per D-062). +Confrontation option appears for Kael. +Available topics with Sera narrow: Kael-related topics now flagged as confrontation-eligible. +Sera's dialogue pool shifts to post-contradiction phase lines (different access tier scoring, trust tier holds but topic weights shift). + +**Step 6 — What Sera knows (the epistemic neutrality moment):** +Sera does NOT know the detective has seen Kael in B-7. Her KG has no entry suggesting the detective observed Kael anywhere. She has no reason to change her behavior. She may greet the detective warmly the next time they meet. This is correct — and this is what makes the confrontation dramatically charged when it comes. + +Sera's dialogue does not shift on the detective's side until the detective CONFRONTS her. Before that, the detective holds the contradiction alone. The internal monologue is the only witness. + +**Validation result:** All systems fire in the correct sequence. The downstream consumers (anomaly, monologue, relationship, color) are tested and operational. The critical path dependencies (ToldBy source constructible, prior claim preserved, source_id resolvable to name) are specified above and must be confirmed before implementation closes Sprint 17 work. + +--- + +## Open Questions I'm Closing + +**`CONTRADICTION_WINDOW_TICKS` value — what is "same time window"?** + +Gestalt proposes 600 ticks (1 game-hour). I asked what timeframe makes contradiction "not stale." + +My answer: **600 ticks is right for location contradictions.** The FRIEND arc example — Sera says Kael is at the dock during second shift, player sees Kael elsewhere — needs to fire within a game-shift (8 game-hours = 4800 ticks) but should probably not fire if the observation is a whole game-day apart. 600 ticks (1 game-hour) is a reasonable window for "were you really in both places at once?" + +But the window should be configurable per contradiction type, not global. A schedule-based claim ("Kael is always at dock, second shift") should have a longer window than a "I just saw X at location Y" claim. For v0.1, 600-tick default with content-authored override is the right approach. + +**Monologue `StableId` → displayable name — is it available?** + +This is Gestalt → Dudley's open question. **I'm flagging it as a Sprint 17 blocker for contradiction monologue.** If the monologue system context cannot read `NpcName` components (because it runs on a different schedule or doesn't have entity query access), the source-named lines cannot fire correctly. The workaround (pre-resolve names into the `ContradictionDetected` event payload) is simple: when the detector fires the event, resolve both `source_id` and `subject_id` to display names immediately via EntityRegistry + NpcName, and include the resolved strings in the event struct. The monologue system then reads `String` fields, not StableIds. No ECS query required at monologue system run time. + +**Recommended addition to `ContradictionDetected` event:** +``` +ContradictionDetected { + ..., + source_display_name: Option, // resolved at detection time + subject_display_name: Option, // resolved at detection time +} +``` + +This moves the resolution burden to the detection system (which already has EntityRegistry access) and makes the monologue system a pure consumer of pre-resolved strings. + +--- + +## Summary: My Positions for Round 2 Synthesis + +| Topic | My Position | Status | +|-------|-------------|--------| +| Tension A: Entity grants | Accept Dudley's Sprint 17 workaround (FactId with slug naming convention + sprint 18 entity grant commitment) | **Resolved, with 3 conditions above** | +| Tension B: EntityKnowledge structural fix | Tyre/Gestalt typed `prior_claim` struct required (not Dudley's Option B) because monologue source attribution must be clean | **Position taken** | +| Contradiction monologue naming | `ContradictionDetected` event pre-resolves display names; Mellanie gets authored spec (see above) | **Resolved** | +| Global rate limit | Withdrawn; Gestalt is right | **Conceded** | +| Location privacy gate | Maintained as v0.1 trigger condition; content-authored `disclosure_context` field on YAML | **Maintained** | +| Trust-weighted rate limit | Withdrawn; flat rand 1-3 is sufficient | **Conceded** | +| Major secret override | Maintained as `disclosure_eligible: false` YAML field on authored KG entries | **Maintained, reframed** | +| `CONTRADICTION_WINDOW_TICKS` | 600 ticks default, configurable per contradiction type | **Answered** | +| Monologue StableId resolution | Pre-resolve into event payload at detection time; monologue reads strings | **Answered** | + +The design is coherent. The FRIEND arc fires. The downstream consumers are ready. The only open question blocking Mellanie is whether Dudley can confirm `StableId → NpcName` resolution path for the ContradictionDetected event — if yes, she can begin authoring the contradiction discovery lines immediately. diff --git a/docs/workshops/knowledge-flow-npc-boundaries/round-1-notes.md b/docs/workshops/knowledge-flow-npc-boundaries/round-1-notes.md new file mode 100644 index 000000000..7c1511801 --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/round-1-notes.md @@ -0,0 +1,215 @@ +# Round 1 Notes — Knowledge Flow & NPC Information Boundaries Workshop + +**Documented by:** Qatux +**Date:** 2026-02-23 +**Sources:** tyre-round1.md, gestalt-round1.md, dudley-round1.md, paula-round1.md + +--- + +## Summary + +Four participants submitted Round 1 analyses across five topics: the grant mechanism (Topic 1), NPC-to-NPC gossip (Topic 2), unprompted disclosure (Topic 3), information boundaries (Topic 4), and contradiction detection (Topic 5). Consensus is strong on fundamentals. Two structural tensions require resolution in Round 2 before implementation can proceed. + +--- + +## Consensus Positions (all four participants aligned) + +### Topic 1: Grant Mechanism + +| Position | Agreed by | +|----------|-----------| +| Grant fires at **line selection time**, server-side — not at client display | Tyre, Gestalt, Dudley, Paula | +| Single `KnowledgeGranted` event type; no separate `EvidenceDiscovered` variant | Tyre, Gestalt, Dudley, Paula | +| POI discovery uses `FactId("poi.*")` namespace; no new type | Tyre, Gestalt, Dudley, Paula | +| Physical evidence grants use `DirectObservation` source; dialogue grants use `ToldBy` — same event, different source | Tyre, Gestalt, Dudley, Paula | + +### Topic 2: NPC-to-NPC Propagation + +| Position | Agreed by | +|----------|-----------| +| Existing `run_npc_conversations` system is the correct hook; no separate propagation system | Tyre, Gestalt, Dudley, Paula | +| Confidence cap: `transferred_confidence = min(source_confidence, KnowsOf)` — `Direct`/`KnowsDetails` downgrade to `KnowsOf`, `Suspects` stays `Suspects` | Tyre, Gestalt, Dudley, Paula | +| Overheard NPC-NPC conversations grant player `Suspects` confidence regardless of word-level occlusion (D-078) | Tyre, Gestalt, Dudley, Paula | +| Rate limit: 1–3 facts per conversation (prevents knowledge explosion, creates asymmetry) | Tyre, Gestalt, Dudley, Paula | + +### Topic 3: Unprompted Disclosure + +| Position | Agreed by | +|----------|-----------| +| **Option A**: NPC checks own KG only when deciding what to disclose; no cross-entity KG query | Tyre, Gestalt, Dudley, Paula | +| Disclosure candidates stored in a **separate component** (`DisclosureCandidates` / `DisclosureCandidateList`), not added to `DerivedTellState` | Tyre, Gestalt, Dudley | +| Traits affect **both** what is disclosed (candidate filtering) AND how it is delivered (line-pool scoring) | Tyre, Gestalt, Dudley, Paula | +| Per-fact per-NPC cooldown prevents the same NPC disclosing the same fact twice | All four | + +### Topic 4: NPC Information Boundaries + +| Position | Agreed by | +|----------|-----------| +| MVP boundary = `tell_state.rs` + unprompted disclosure (#172); no other retrofits in v0.1 | Tyre, Gestalt, Dudley, Paula | +| Background-tier NPCs get no KG-driven behavior; scope to `With` | Tyre, Gestalt, Dudley, Paula | +| Pathfinding (`path_follow.rs`) must NOT be retrofitted in v0.1 — failure mode (stuck NPCs) has no safe recovery | Tyre, Gestalt, Dudley, Paula | +| Fallback on missing KG entry: ground truth with structured logging, not silent | Tyre, Gestalt, Dudley, Paula | +| tell_state reads self-knowledge from axis components directly; KG applies to OTHER-state only | Tyre, Gestalt (same conclusion, different framing) | + +### Topic 5: Contradiction Detection + +| Position | Agreed by | +|----------|-----------| +| Detection is **event-driven at KG write time** — fires in `observe_entity()` before overwrite, not per-tick scan | Tyre, Gestalt, Dudley, Paula | +| **Both** the `ToldBy` entry and the `DirectObservation` entry receive `KnowledgeState::Contradicted` (epistemic neutrality — the engine does not determine who is wrong) | Tyre, Gestalt, Paula | +| Event chain: KG write → `ContradictionDetected` → monologue → `AnomalyMarker` → relationship shift → D-033 amber. Downstream consumers already exist and are tested. | All four | +| Attribute contradiction requires **content-authored contradiction pairs** (YAML), not automatic string comparison | Tyre, Gestalt, Dudley, Paula | +| Location contradiction is **automatic**: position + time window (`CONTRADICTION_WINDOW_TICKS`) | Tyre, Gestalt, Dudley | + +### Cross-cutting + +| Position | Agreed by | +|----------|-----------| +| **Close Q-025** (KG memory pressure). Current budget ~14 KB per Active NPC, ~6 MB total at current scale. Not a constraint until 500+ Active NPCs. | Tyre, Gestalt, Dudley | + +--- + +## Key Tensions Requiring Round 2 Resolution + +### Tension A: Entity Grants — Sprint 17 vs Sprint 18 + +**The question:** Should `KnowledgeGrant` be extended to support entity-level grants (creating/updating `EntityKnowledge` entries via dialogue) in Sprint 17, or deferred to Sprint 18? + +**Positions:** + +| Participant | Position | Rationale | +|-------------|----------|-----------| +| **Tyre** | Sprint 17 — extend YAML schema to compound grant (facts + entities) | Architecture is clean; compound grant is the right shape; entity_ref resolves to StableId at content load via BTreeMap | +| **Gestalt** | Sprint 17 — three-type enum: `FactGrant`, `EntityGrant`, `Compound` | Entity grants are required to make `KnowledgeSource::ToldBy` constructible; this is the missing piece | +| **Paula** | Sprint 17 — critical prerequisite | Without entity grants, `ToldBy` EntityKnowledge is never created; contradiction detection in THE FRIEND arc **cannot fire** | +| **Dudley** | **Defer to Sprint 18** | Entity grants require a "name → StableId" content registry that doesn't exist; Sprint 17 can use structured FactIds instead (e.g., `FactId("entity.kael.location.second_shift_dock")`) | + +**Dudley's Sprint 17 workaround:** Encode entity+attribute+location claims as structured `FactId` strings. Contradiction detection then matches against the `FactKnowledge` entry. Avoids the entity name registry problem. + +**Paula's counter:** This workaround loses the `EntityKnowledge.last_known_position` and `ToldBy` source. Contradiction detection on THE FRIEND arc requires *both* entries in `EntityKnowledge` — the `ToldBy` entry from Sera's testimony, and the `DirectObservation` when the player sees Kael in B-7. A FactId workaround bypasses `EntityKnowledge` entirely and breaks the contradiction chain. + +**Note for the record:** Tyre's Sprint 17 estimate is ~1.5 days (schema + content-load entity_ref registry). Dudley's concern is the missing registry infrastructure. This is the crux. + +--- + +### Tension B: `EntityKnowledge` Structural Fix — `ContradictionClaim` vs `contradiction_basis` vs Option B attribute encoding + +**The structural problem (agreed by all):** `KnowledgeGraph.entities` is `BTreeMap` — one entry per known entity. When `observe_entity()` writes a new `DirectObservation`, it overwrites the existing `ToldBy` source. The prior claim is lost. Contradiction detection has nothing to compare against. + +**Three proposed solutions:** + +| Participant | Proposal | Structural impact | +|-------------|----------|-------------------| +| **Tyre** | Add `contradicted_claim: Option` to `EntityKnowledge`. Detect at write time (Approach B): before overwriting, compare incoming vs current; if contradiction, set `Contradicted` + store prior claim in new field | Adds one optional struct field to `EntityKnowledge`. Zero breaking changes. | +| **Gestalt** | Add `contradiction_basis: Option` to `EntityKnowledge`. Same detection timing. Struct fields: `conflicting_source`, `conflicting_position`, `detected_at_tick` | Structurally identical to Tyre's proposal, different naming | +| **Dudley** | **Option B for Sprint 17**: encode position claims as structured attribute strings in `known_attributes` (`"claim.{tick}.position" = "{x},{y},{source_sid}"`). Contradiction detector parses these strings. Sprint 18: refactor to proper struct (Tyre/Gestalt's approach). | Zero schema change for Sprint 17. Acknowledged as "ugly but minimal impact." | + +**Assessment:** Tyre and Gestalt are proposing the same architecture with different field names — they should align on naming in Round 2. Dudley's Option B is a pragmatic workaround that avoids Sprint 17 schema change at the cost of string-parsing brittleness and a Sprint 18 refactor obligation. + +Paula does not take a position on implementation approach; defers to the engineers. + +--- + +## Secondary Disagreements (lower priority, but noted) + +### Topic 2: Major secret override on trust-gated sharing + +**Paula** proposes a `disclosure_threshold_override` flag on individual KG entries — a per-entry "never share regardless of trust tier" mechanism for Major secrets (e.g., Kael will not disclose his ring membership even to his closest contact). No other participant mentions this. It is a design addition, not a conflict with existing positions. + +**For the record:** This is a new design element not currently in the trust-tier model. It does not contradict Tyre, Gestalt, or Dudley's trust-mapping tables — it adds a pre-filter before the tier lookup. + +### Topic 2: Trust-weighted rate limiting + +**Paula** proposes trust-weighted fact counts (Surface: 0–1, Real: 1–2, Secret: up to 3). **Tyre, Gestalt, Dudley** propose a flat `rand.random_range(1..=3)`. No strong opposition in any direction. Paula's proposal adds a trust-tier dependency; the others prefer simplicity. + +### Topic 3: Disclosure trigger — witness inhibition + +**Gestalt** adds a witness inhibition gate: NPCs are less forthcoming with an audience nearby. **Paula** adds a **location privacy gate**: disclosure candidates tagged `private` will not fire at public locations (e.g., Sera won't confide at The Terminal). These are additive, not conflicting. + +**Tyre and Dudley** do not address either condition. The team will need to specify the trigger gate list before Dudley can implement `process_unprompted_disclosure`. + +### Topic 3: Global rate limit + +**Tyre** and **Dudley** both support a global rate limit (1 disclosure per tick globally) to prevent simultaneous disclosure from multiple NPCs. **Gestalt** opposes it — argues the limit is invisible to the player and creates unintelligible competition between NPCs. **Paula** supports a light global limit as a degenerate-case safeguard. This is a 3-vs-1 split with Gestalt dissenting. + +### Topic 5: Contradiction monologue content + +**Paula** argues strongly that contradiction monologue lines must name the source entity explicitly ("Sera said Kael was at the dock — I'm looking at him in B-7"). A generic "that doesn't add up" line is insufficient for THE FRIEND arc. Proposes three options: +1. Templated monologue lines with entity name substitution +2. Hand-authored lines per NPC relationship phase (FRIEND pattern NPCs) +3. Generic fallback + authored override (Paula's preference) + +This requires the monologue system to resolve `ToldBy.source_id` (StableId) to a displayable name via `EntityRegistry` + `NpcName` component. Gestalt flags this as an open question for Dudley to confirm. No other participant addresses it. **This is a content system requirement that must be resolved before Mellanie can author the contradiction monologue lines.** + +### Topic 1: Runtime NPC KG guardrail + +**Tyre**: 3-line runtime check in `process_knowledge_events` — NPC cannot grant facts not in its own KG; grants for unknown facts are dropped with `tracing::warn!`. + +**Gestalt**: Runtime enforcement + content-load validation (build pipeline check). + +**Dudley**: Runtime check is Tier 3 difficulty, **not Sprint 17 scope**; authoring-time validation only for Sprint 17. + +**Paula**: Authoring-time check is mandatory for narrative integrity; agrees runtime is harder given dynamic KGs. + +This is a scope decision: Dudley's position is the most conservative. Tyre's 3-line check is low-cost; Gestalt's build-pipeline check is moderate work. Team lead to decide Sprint 17 scope. + +--- + +## Implementation Order (Dudley's recommendation, uncontested) + +``` +Topic 1 (KnowledgeGranted event + dialogue wire) + ↓ enables +Topic 4 MVP (tell_state KG awareness — can parallel with Topic 1) +Topic 2 (NPC-to-NPC transfer — unblocks Topic 5) + ↓ enables +Topic 5 (contradiction detection — requires ToldBy entries to exist) +Topic 3 (unprompted disclosure — requires workshop decisions + populated NPC KGs) +``` + +Tyre's total estimate: ~11 days across Topics 1–5, parallelizable after grant schema is agreed. + +--- + +## Open Questions Raised This Round + +| ID | Question | Raised by | +|----|----------|-----------| +| — | Entity grants Sprint 17 vs Sprint 18 (see Tension A) | Dudley (dissent) | +| — | `EntityKnowledge` structural fix approach (see Tension B) | Tyre, Gestalt, Dudley | +| — | `CONTRADICTION_WINDOW_TICKS` value — Gestalt proposes 600 (1 game-hour); Paula asks what narrative timeframe makes "contradiction not stale info" | Gestalt, Paula | +| — | `DisclosureCandidates` compute trigger — is there a "player entered dialogue range" event or does this need a range-query every N ticks? | Gestalt → Tyre to confirm | +| — | Monologue `StableId` → displayable name lookup — is `EntityRegistry + NpcName` available in monologue system context? | Gestalt → Dudley to confirm | +| — | Witness inhibition trigger (Gestalt) + location privacy trigger (Paula) — which conditions are in v0.1 trigger gate? | Requires team decision | +| — | Global disclosure rate limit — include or exclude? (3-vs-1 split: Tyre/Dudley/Paula for, Gestalt against) | Gestalt (dissent) | +| — | Contradiction monologue authoring — templated / hand-authored / hybrid? (Paula requires source-named lines) | Paula → Mellanie/team decision | +| — | Major secret `disclosure_threshold_override` on KG entries — include in trust-gate model? | Paula (new proposal) | + +--- + +## Decisions This Round Is Producing (pending formal D-record after workshop closes) + +The following are **forming consensus** — not yet D-records, but positions Round 2 should confirm or amend: + +1. `KnowledgeGranted` event type is the single mechanism for all knowledge input (dialogue grants, evidence, POI discovery) +2. Grant fires at line selection (server-authoritative, tick-deterministic) +3. `FactId("poi.*")` for POI discovery — no new type +4. NPC-to-NPC gossip piggybacked on conversation system; separate `transfer_npc_knowledge` system (Bevy ECS dual-mutable constraint — Dudley's finding) +5. Confidence cap `min(source_confidence, KnowsOf)` on all gossip transfers +6. Option A for unprompted disclosure (NPC checks own KG only) +7. Traits act as two-stage filter: candidate pool (WHAT) + line delivery (HOW) +8. MVP boundary: tell_state + disclosure; no pathfinding retrofit +9. Contradiction detection event-driven at KG write; both entries marked `Contradicted` +10. Q-025 closure (formal record pending D-record production) + +**Not yet forming consensus (blocked on Tensions A and B):** +- Entity grant schema and Sprint 17 scope +- `EntityKnowledge` structural fix approach + +--- + +## Note to Participants + +For the record: the downstream cascade from contradiction detection (anomaly marker, D-033 color shift, relationship shift to PersonOfInterest) is **already implemented and tested**. The entire narrative payoff of THE FRIEND arc is gated on two things: (1) `ToldBy` EntityKnowledge being constructible from dialogue grants, and (2) the `EntityKnowledge` structural fix that preserves the prior claim before overwrite. Everything else in Topics 3–4 is valuable but not blocking THE FRIEND arc for v0.1. + +Paula's framing is correct: Tensions A and B are the session's critical path. diff --git a/docs/workshops/knowledge-flow-npc-boundaries/round-2-notes.md b/docs/workshops/knowledge-flow-npc-boundaries/round-2-notes.md new file mode 100644 index 000000000..cca938c15 --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/round-2-notes.md @@ -0,0 +1,236 @@ +# Round 2 Notes — Knowledge Flow & NPC Information Boundaries Workshop + +**Documented by:** Qatux +**Date:** 2026-02-24 +**Sources:** tyre-round2.md, gestalt-round2.md, dudley-round2.md, paula-round2.md + +--- + +## Summary + +Both principal tensions are resolved. Tension B (EntityKnowledge structural fix) resolved unanimously to the typed struct. Tension A (entity grants Sprint 17 vs 18) resolved **unexpectedly** — three engineers converged on entity grants in Sprint 17 via `ContentEntityRegistry`, while Paula (who originally called entity grants "Critical") accepted Dudley's FactId workaround with three binding conditions. All secondary disputes are resolved or conceded. The workshop is ready for formal D-record production. + +--- + +## Tension A Resolution: Entity Grants — Split Outcome + +**Result: 3-1 split (not a simple resolution).** + +| Participant | Round 2 Position | Changed from Round 1? | +|-------------|-----------------|----------------------| +| **Tyre** | Entity grants Sprint 17 via `ContentNameRegistry` (BTreeMap, ~30 lines). FactId workaround breaks contradiction chain — the two data maps never intersect. | No change | +| **Gestalt** | Entity grants Sprint 17, narrow scope: `EntityPositionGrant` only (full `EntityGrant { attributes }` defers to Sprint 18). Static lookup against existing `entity-attributes.yaml`. | Refined (scoped narrower) | +| **Dudley** | **Changed to entity grants Sprint 17.** `ContentEntityRegistry` resource, spawn-time registration (~60 lines total). Confirmed FactId workaround breaks because `observe_entity` contradiction check is in the `entities` map — FactKnowledge (in `facts` map) is never compared against it. | **Major change** | +| **Paula** | **Accepted FactId workaround with 3 conditions.** The conditions are binding; if any fail, entity grants are required. | Reversed from Round 1 | + +**The unexpected inversion:** Paula (Round 1: entity grants Critical, non-negotiable) accepted the workaround. The engineers (Round 1: split on feasibility) converged on entity grants as the cleaner path. + +### Paula's 3 conditions on the FactId workaround + +These conditions are binding. If any are not met, the workaround fails and entity grants are required for Sprint 17: + +1. **Resolver-compatible FactId naming.** `FactId("entity.kael-davan.location.second-shift-dock")` requires the contradiction detector to map the slug `"kael-davan"` → `kael_sid` at runtime. NpcName must carry a canonical slug field, or a separate slug-to-StableId lookup must exist. This path must be confirmed before implementation begins. + +2. **ToldBy source flows through `ContradictionDetected` event.** The event must include the full `KnowledgeSource::ToldBy { source_id: sera_sid, tick }` so the monologue system can name Sera. Dudley's Round 1 event sketch already includes `entry_a_source: KnowledgeSource` — if this is preserved, Condition 2 is satisfied. + +3. **Sprint 18 entity grants are a formal commitment, not an aspiration.** The D-record for this workshop must record Sprint 18 entity grants as committed scope. The FactId workaround leaves the player without an `EntityKnowledge` entry for Kael from hearsay alone (they only gain one on direct observation), which limits future systems. + +**For the record:** Tyre argues that even if Paula's 3 conditions are met, the cross-structure lookup (FactKnowledge checked against EntityKnowledge entries) is more complex than simply writing the entity grant correctly. The workaround requires the contradiction detector to scan the `facts` map for `"entity.*"` prefixed entries and cross-reference against `entities` — a dependency the typed entity grant avoids entirely. Gestalt registers a standing dissent: "If the team decides to defer entity grants, the decision should record that THE FRIEND arc contradiction sequence using `EntityKnowledge.ToldBy` cannot fire in v0.1 — not that we found a workaround." + +**Workshop leaves this split for the team lead to resolve.** The implementation path is either: +- (A) Entity grants + ContentEntityRegistry (Tyre/Gestalt/Dudley, ~60 lines extra Sprint 17 infra) +- (B) FactId workaround (Paula accepts, contingent on 3 conditions) + entity grants committed to Sprint 18 + +--- + +## Tension B Resolution: `ContradictionClaim` Struct — Unanimous + +**Result: Full consensus. Dudley conceded Option B.** + +| Participant | Position | +|-------------|---------| +| **Tyre** | `contradicted_claim: Option` on `EntityKnowledge`. Naming: `ContradictionClaim`. `claimed_position` field. | +| **Gestalt** | Same architecture. Defers to Tyre's naming: `ContradictionClaim`. | +| **Dudley** | **Concedes Option B.** String parsing is fragile, pollutes `known_attributes`, and requires Sprint 18 refactor. Adopts `ContradictionClaim` struct. | +| **Paula** | Supports typed struct; her preference for field name `prior_claim` noted but not held firm. | + +**Agreed struct (Tyre/Dudley final spec):** + +```rust +pub struct ContradictionClaim { + pub source: KnowledgeSource, // Who made the contradicted claim (ToldBy source) + pub claimed_position: Option, + pub detected_at_tick: u64, +} + +// Added to EntityKnowledge: +pub contradicted_claim: Option, // None until contradiction fires +``` + +**Why Dudley conceded Option B:** +- String parsing in contradiction detection hot path is fragile (malformed string = silent failure) +- Monologue system needs typed `ToldBy { source_id }` for source attribution — a parsed string adds a second fragility point +- `known_attributes` is semantic NPC metadata; filling it with internal bookkeeping strings mixes concerns +- One optional field with serde default is not a meaningful schema change (all existing state deserializes with `None`) + +--- + +## Secondary Dispute Resolutions + +### Global disclosure rate limit + +**Round 1:** 3-for (Tyre, Dudley, Paula), 1-against (Gestalt). + +**Round 2 movement:** +- Paula **withdraws** her support: "The trigger conditions being strict enough makes simultaneous disclosure rare by design rather than requiring a hard cap." Agrees with Gestalt. +- Gestalt **partially concedes**: "I accept the global rate limit as a degenerate-case safeguard, with one condition: the winning NPC must be selected by deterministic StableId ordering (D-010 principle 4), not random." +- Dudley **maintains**: 1 per tick, StableId-ordered. Addresses Gestalt's invisible-competition concern (player watches one NPC; doesn't observe others not-speaking). +- Tyre **maintains**: per-game-minute (1 per 10 ticks), not per-tick. + +**Resolution:** Include global rate limit. Deterministic selection by ascending StableId. Per-game-minute granularity (1 per 10 ticks, per Tyre; more conservative than per-tick). This means in practice the limit only fires when 2+ NPCs have simultaneous first-time disclosures in the same game-minute — a degenerate edge case. Normal play is governed by per-NPC cooldowns (300 ticks). + +### Contradiction window (`CONTRADICTION_WINDOW_TICKS`) + +| Participant | Proposal | +|-------------|---------| +| Gestalt | 600 ticks (1 game-hour) | +| Tyre | 1800 ticks (3 game-hours) | +| Dudley | 600 ticks (1 game-hour) | +| Paula | 600 default, configurable per contradiction type | + +**Resolution:** 600-tick default. Configurable per contradiction type via content authoring. A schedule-based claim can carry a longer window than a "just saw them" claim. `const CONTRADICTION_WINDOW_TICKS: u64 = 600` as the default constant. Tyre's 1800-tick argument (shift coverage + travel time) noted; the playtest will determine whether 600 is too tight. + +### Trust-weighted transfer count (gossip) + +**Paula's proposal** (Surface: 0-1, Real: 1-2, Secret: 2-3) accepted by Dudley; others not opposed. + +**Paula withdraws** in Round 2: flat `rand.random_range(1..=3)` is sufficient. Trust tier already gates which facts are eligible; the count cap is a ceiling on an already-filtered pool. + +**Resolution:** Flat `rng.random_range(1..=3)` per conversation, confirmed by all. + +### `disclosure_blocked` / `disclosure_eligible` flag + +All four participants agree a per-entry flag is needed for Major secrets (Kael's ring membership). Naming differs: +- Dudley/Tyre: `disclosure_blocked: bool` on `FactKnowledge` (field on struct) +- Paula: `disclosure_eligible: false` in authored YAML + +**Resolution:** `disclosure_blocked: bool` on `FactKnowledge` (struct field, default `false`). Content authors set `disclosure_blocked: true` on authored KG entries for Major secrets in NPC profile YAML. The field name inverted to `_blocked` for cleaner boolean logic in the filter (`if fact.disclosure_blocked { continue }`). + +### Witness inhibition + location privacy gates + +Both confirmed for v0.1 trigger gate. Additive, not competing. + +- **Witness inhibition** (Gestalt): count Active NPCs within 5 tiles; if > 2, suppress `real`/`secret`-tier disclosures unless NPC-player trust is `secret` tier (or NPC has `Talkative` trait override). NPC's trust toward nearby NPCs (not toward player) determines "witness" — Sera won't confide in front of coworkers she doesn't trust, regardless of her trust for the detective. +- **Location privacy** (Paula): `disclosure_context: [private, semi_private, any]` tag on authored disclosure candidates. Check fires at Layer 4 line selection, not at candidate derivation. Private-tagged candidates do not fire at public zones (e.g., The Terminal). + +### Runtime NPC KG guardrail + +- Tyre Round 1: runtime check Sprint 17 (3 lines) +- Dudley Round 1: Tier 3, defer +- Dudley Round 2: **recalibrates to include** — "once entity grant architecture is in place, the 3-line check is straightforward" +- Gestalt: runtime + content-load both Sprint 17 +- Paula: authoring-time mandatory + +**Resolution:** Both content-load validation (parse confidence strings, validate entity_refs) AND runtime guardrail (3-line check: if granting NPC's KG doesn't contain the fact, skip + warn) in Sprint 17. + +### Monologue StableId → NpcName resolution + +**Dudley confirms:** `EntityRegistry + Query<&NpcName>` is accessible from the monologue system with a ~5-line signature addition. The `resolve_name(source_id, registry, name_query)` helper is ~10 lines. + +**Paula's additional proposal:** Pre-resolve display names into the `ContradictionDetected` event payload at detection time. This makes the monologue system a pure string consumer with no ECS query needed: + +``` +ContradictionDetected { + ..., + source_display_name: Option, // resolved at detection time + subject_display_name: Option, // resolved at detection time +} +``` + +**Resolution:** Pre-resolve names into event payload (Paula's approach). Simpler monologue system, single resolution point. Fallback: `"Unknown({})"` if entity not in registry. + +### DisclosureCandidates compute trigger + +**Dudley confirms:** No "player entered dialogue range" event exists. Range-query per tick in Active NPC processing loop — O(N_active) position comparisons (~60 at max). Negligible. `DisclosureCandidates` component as cache, freshness checked by `computed_tick` field (expires after 30 ticks / 3 game-minutes). + +### Contradiction monologue authoring + +**Paula's specification (final):** + +**Option 3 adopted:** Generic fallback template + hand-authored override for FRIEND-pattern NPCs. + +- FRIEND NPCs (Sera, Kael): hand-authored lines per relationship phase. Phase 2 primary: *"Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."* Secondary beat: *"One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."* +- Auto-generated NPC generic fallback: *"{source_name} placed {subject_name} at {claimed_location}. I just saw them at {actual_location}."* +- Authoring principle: cognitive dissonance, not accusation. The engine marks uncertainty; the detective reflects uncertainty. The player makes accusations. + +This is a specification for Mellanie. It is unblocked once Dudley confirms the name resolution path (now confirmed above). + +### Disclosure candidate selection algorithm + +**Gestalt provides complete specification** (pseudocode in gestalt-round2.md §"Disclosure Candidate Selection Algorithm"). Paula asked to confirm narrative assumptions: + +1. **Contradicted facts excluded from candidates** — Gestalt's proposal. Paula's Round 2 is silent on this specific item (confirming by non-objection). Excluded in v0.1; future sprint may add `DisclosureMode::Confusion` for NPCs surfacing their own contradicted knowledge. + +2. **Sera's Phase 2 disclosures are FactId entries** — the compound grant mechanism creates EntityKnowledge as a side effect. Paula's canonical sequence uses FactId grants, consistent with Gestalt's algorithm. + +3. **Witness inhibition "trusted" means NPC's trust toward nearby NPC** — not player's trust toward NPC. Gestalt and Paula agree on this framing. + +--- + +## Positions That Did NOT Change This Round + +For the record: the following Round 1 consensus positions were confirmed without challenge in Round 2: + +- Option A (NPC checks own KG only for disclosure) — reaffirmed by all +- Event-driven contradiction detection at KG write time — reaffirmed +- Both entries marked `Contradicted` (epistemic neutrality) — reaffirmed +- POI discovery via `FactId("poi.*")` — reaffirmed +- Confidence cap `min(source_confidence, KnowsOf)` on gossip — reaffirmed +- Background-tier NPCs get no KG-driven behavior — reaffirmed +- No pathfinding KG retrofit — reaffirmed +- Q-025 close (memory not a constraint) — reaffirmed + +--- + +## Standing Dissent Register + +Items where a participant registered formal dissent in Round 2 that was not resolved: + +1. **Gestalt (Tension A):** "If the team decides to defer entity grants, the decision should be recorded as 'THE FRIEND arc contradiction sequence cannot fire using EntityKnowledge.ToldBy in v0.1' — not that we found a workaround." This dissent stands regardless of which path is chosen. If (B) is selected, the D-record must accurately describe the scope limitation. + +2. **Tyre (Tension A):** FactId workaround requires more code and is more fragile than entity grants + ContentNameRegistry. The workaround produces a cross-structure dependency that does not exist today. Registry cost is genuinely ~0.5 days. + +--- + +## Open Items Still Requiring Decision + +| Item | Status | Who decides | +|------|--------|-------------| +| Entity grants Sprint 17 vs FactId workaround | **Split: 3 engineers vs Paula's conditions** | Team lead | +| Paula's Condition 1 (slug → StableId resolution path) | Requires technical confirmation if workaround chosen | Dudley to confirm before implementation | +| `CONTRADICTION_WINDOW_TICKS` final value (600 vs 1800) | 3-to-1 for 600; playtest will calibrate | Can ship with 600, adjust | +| `Compound` grant variant (facts + entities in one line) | Deferred to Sprint 18 by all except Tyre who included it in schema | Minor — Sprint 18 either way | + +--- + +## Summary Table: All Round 2 Decisions + +| Topic | Decision | Agreed by | +|-------|----------|-----------| +| Tension B: struct | `contradicted_claim: Option` on `EntityKnowledge` | All 4 | +| `ContradictionClaim` naming | `ContradictionClaim` (Tyre's name) | Tyre, Gestalt, Dudley; Paula prefers `prior_claim` but concedes | +| `claimed_position` vs `position` field | `claimed_position` | Tyre, Dudley | +| `detected_at_tick` field | Include | Tyre, Dudley | +| Tension A: entity grants vs workaround | Split — see above | N/A | +| Gossip rate per conversation | Flat `rng.random_range(1..=3)` | All 4 | +| Contradiction window default | 600 ticks (1 game-hour), configurable | Gestalt, Dudley, Paula (Tyre prefers 1800) | +| Global disclosure rate limit | Include, deterministic StableId-ordered, 1/10 ticks | Tyre, Dudley, Gestalt (conditional); Paula withdrew | +| Witness inhibition gate | Include in v0.1 trigger conditions | All 4 | +| Location privacy gate | Include in v0.1 trigger conditions | All 4 | +| `disclosure_blocked: bool` on `FactKnowledge` | Include | All 4 | +| Runtime NPC KG guardrail | Include Sprint 17 | Dudley recalibrated; Gestalt; Paula | +| Monologue name resolution | Pre-resolve into `ContradictionDetected` event payload | Paula (proposed); Dudley (confirmed feasible) | +| Contradiction monologue authoring | Option 3: generic template + hand-authored FRIEND override | All 4 | +| Q-024 formal closure | Close | All 4 | +| Q-025 formal closure | Close | All 4 | +| Q-026 formal closure | Close (contingent on Tension A path) | All 4 | diff --git a/docs/workshops/knowledge-flow-npc-boundaries/tyre-round1.md b/docs/workshops/knowledge-flow-npc-boundaries/tyre-round1.md new file mode 100644 index 000000000..eb681003b --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/tyre-round1.md @@ -0,0 +1,535 @@ +# 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` 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, With`) + +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` 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` (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` (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`). + +**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, 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, +} + +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` (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` 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` 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. diff --git a/docs/workshops/knowledge-flow-npc-boundaries/tyre-round2.md b/docs/workshops/knowledge-flow-npc-boundaries/tyre-round2.md new file mode 100644 index 000000000..397ca12bc --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/tyre-round2.md @@ -0,0 +1,470 @@ +# Round 2: Tyre -- Cross-Review and Synthesis + +**Workshop:** Knowledge Flow & NPC Information Boundaries +**Domain:** Architecture resolution, ECS patterns, performance, D-record draft +**Date:** 2026-02-23 + +--- + +## Tension A Resolution: Entity Grants are Sprint 17 + +*Let me be honest about what this means technically.* + +Dudley's concern is real: entity grants require resolving a content-authored name like `"kael"` to a runtime `StableId`. No such registry exists today. `EntityRegistry` (`server/src/knowledge/registry.rs:20`) maps `StableId <-> Entity` (bevy ECS), but there's no `String -> StableId` path for content references. + +Dudley proposes deferring entity grants to Sprint 18 and using structured FactIds (`FactId("entity.kael.location.second_shift_dock")`) as a workaround. + +Paula argues this workaround breaks THE FRIEND arc: contradiction detection requires comparing `EntityKnowledge.last_known_position` between a `ToldBy` entry and a `DirectObservation`. A FactId encoding bypasses `EntityKnowledge` entirely -- the two data paths never intersect. + +**Paula is right. The FactId workaround does not work for contradiction detection.** Here's the specific failure: + +1. Sera's dialogue grants `FactId("entity.kael.location.second_shift_dock")` -- this creates a `FactKnowledge` entry in `KnowledgeGraph.facts`. +2. Player observes Kael in B-7 -- `observe_entity(kael_sid, b7_pos, tick)` updates `KnowledgeGraph.entities[kael_sid]`. +3. Contradiction detection fires on `entities[kael_sid]` write. It checks the existing `EntityKnowledge.source` for a `ToldBy` variant. There is none -- the ToldBy information is in `facts`, not `entities`. **The detection algorithm has nothing to compare.** The two data structures don't cross-reference. + +You could build a cross-structure lookup that checks both `facts` and `entities` -- but that's more code, more complexity, and more fragile than just writing the entity grant correctly in the first place. + +**But Dudley's concern about the registry is also valid.** So let me size the actual work. + +### The content name registry: ~30 lines of new infrastructure + +What we need: + +```rust +/// Resource: maps content-authored entity names to runtime StableIds. +/// Populated at NPC spawn time. Read at dialogue content indexing time. +#[derive(Resource, Debug, Default)] +pub struct ContentNameRegistry { + names: BTreeMap, +} + +impl ContentNameRegistry { + pub fn register(&mut self, name: &str, sid: StableId) { + self.names.insert(name.to_string(), sid); + } + + pub fn resolve(&self, name: &str) -> Option { + self.names.get(name).copied() + } +} +``` + +This is a `BTreeMap` (BTreeMap for D-010 determinism). Populated during NPC spawn: when the content system spawns "kael" from YAML, it calls `content_name_registry.register("kael", kael_sid)`. When the dialogue content indexer processes `entity_ref: "kael"`, it calls `content_name_registry.resolve("kael")` to get the StableId. + +The loading order is already correct: NPC entities must be spawned before dialogue lines reference them. This is the same constraint that applies to `DialogueProfile.role` referencing valid NPC roles -- it's not a new ordering problem. + +**Cost: ~30 lines for the registry + ~10 lines at NPC spawn to populate it + ~15 lines in the content indexer to resolve entity_refs. Total: ~55 lines. Half a day.** + +### YAML schema extension + +The extended `KnowledgeGrant` in YAML: + +```yaml +# Fact-only grant (most common, existing pattern) +knowledge_grant: + fact_id: "poi.dock_7_restricted" + confidence: "knows_of" + +# Entity grant (for testimony about people) +knowledge_grant: + entity_ref: "kael" + position_hint: "dock-7" # optional, maps to a TilePosition lookup + attributes: + role: "dock-worker" + confidence: "knows_of" + +# Compound grant (rare, for dense narrative moments) +knowledge_grant: + grants: + - fact_id: "contraband.schedule_discrepancy" + confidence: "suspects" + - entity_ref: "kael" + attributes: + role: "dock-worker" + confidence: "knows_of" +``` + +The Rust type becomes: + +```rust +// In content/types.rs, replacing the existing KnowledgeGrant +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum KnowledgeGrant { + Fact { + fact_id: String, + confidence: String, + }, + Entity { + entity_ref: String, + #[serde(default)] + position_hint: Option, + #[serde(default)] + attributes: BTreeMap, + confidence: String, + }, + Compound { + grants: Vec, + }, +} +``` + +Serde `untagged` enum handles the YAML dispatch based on which fields are present. `fact_id` key -> Fact variant. `entity_ref` key -> Entity variant. `grants` key -> Compound variant. Zero ambiguity, clean deserialization. + +### Verdict on Tension A + +**Sprint 17. Entity grants ship.** The infrastructure cost is ~0.5 days, not the multi-day effort Dudley estimated. The `ContentNameRegistry` is 30 lines. The YAML schema extension uses serde's existing untagged enum support. Paula's narrative requirement is correct: without entity grants, THE FRIEND arc's contradiction chain breaks at the first link. + +Dudley: I'm not dismissing your caution. The registry is real new infrastructure. But it's small infrastructure, and the alternative (structured FactIds) creates a data-path split that makes contradiction detection fundamentally harder. The clean path is cheaper than the workaround. + +--- + +## Tension B Resolution: Typed struct, not string encoding + +Three proposals were on the table: + +1. **Tyre (Round 1):** `contradicted_claim: Option` on `EntityKnowledge` +2. **Gestalt (Round 1):** `contradiction_basis: Option` -- structurally identical, different name +3. **Dudley (Round 1):** Option B -- structured attribute strings in `known_attributes` (`"claim.{tick}.position" = "{x},{y},{z},{source_sid}"`) + +### Why the typed struct wins over string encoding + +Dudley's Option B has "zero schema change" as its selling point. But this is misleading. You ARE changing the schema -- you're encoding structured data into strings within an existing BTreeMap. The "change" just moves from the type system to the runtime parser. + +Concrete problems with Option B: + +1. **String parsing in the hot path.** Contradiction detection runs at KG write time (event-driven, consensus from Round 1). Parsing `"claim.{tick}.position" = "{x},{y},{z},{source_sid}"` on every `observe_entity` call means regex or split-based parsing in a performance-sensitive path. The typed struct is a direct field access -- zero parsing. + +2. **No type safety.** A typo in the key format (`"claim.1234.positon"`) silently breaks detection. The typed struct is checked at compile time. + +3. **Pollutes `known_attributes`.** This BTreeMap is meant for semantically meaningful NPC attributes ("role", "faction", "name"). Filling it with internal bookkeeping strings (`"claim.1234.position"`) makes it harder to iterate for actual attribute queries (telling someone about Kael's role requires filtering out the claim keys). + +4. **Creates a Sprint 18 refactor obligation.** Dudley acknowledges this. The refactor touches every system that reads/writes `known_attributes`, every test that constructs `EntityKnowledge`, and the serialization format. The clean struct approach has zero refactor debt. + +The typed struct approach: + +1. **One new optional field.** `Option` is `None` for 99%+ of entries. Serde with `#[serde(skip_serializing_if = "Option::is_none")]` means zero wire overhead for non-contradicted entries. + +2. **Direct field access.** `entry.contradicted_claim.as_ref().map(|c| c.position)` -- no parsing, no string splitting. + +3. **Self-documenting.** The struct fields tell you exactly what a contradiction contains. + +4. **No refactor debt.** This is the correct shape for the long term. + +### Naming: `ContradictionClaim` + +Gestalt and I proposed the same struct with different names. I prefer `ContradictionClaim` over `ContradictionBasis`: + +- `Claim` describes what the struct holds: the prior claim that was contradicted. "Sera claimed Kael was at the dock." +- `Basis` is ambiguous: it could mean "the basis for concluding there's a contradiction" (both sources) or "the basis of the original claim" (just the ToldBy). + +The struct: + +```rust +/// The prior claim that conflicts with the current observation. +/// Preserved when contradiction detection fires, enabling downstream +/// systems (monologue, relationship) to reference the conflicting source. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContradictionClaim { + /// Who made the contradicted claim (ToldBy source entity). + pub source: KnowledgeSource, + /// Position claimed by the source (if location contradiction). + pub claimed_position: Option, + /// Tick when the contradiction was detected. + pub detected_tick: u64, +} +``` + +Added to `EntityKnowledge`: + +```rust +pub struct EntityKnowledge { + // ... existing fields ... + /// When state == Contradicted: the prior claim that conflicts with current observation. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub contradicted_claim: Option, +} +``` + +### Verdict on Tension B + +**Typed `ContradictionClaim` struct, Sprint 17.** Not the string-encoding workaround. The struct is ~15 lines, adds one Optional field, has zero breaking changes (all existing entries default to `None`), and eliminates the Sprint 18 refactor debt. + +Dudley: I understand the instinct to minimize schema changes. But `Option` with serde defaults is not a breaking change -- it's additive. Existing serialized data deserializes with `None`. New data with a contradiction stores the struct. No migration needed. + +--- + +## Secondary Items Resolution + +### Global disclosure rate limit + +Round 1 split: Tyre/Dudley/Paula for, Gestalt against. + +Gestalt's argument: the limit is invisible to the player and creates unintelligible competition between NPCs. Valid concern. A global limit that silently suppresses NPC A's disclosure because NPC B disclosed 3 ticks ago is bad UX. + +**Revised position: Per-game-minute global cap (1 disclosure per 10 ticks), not per-tick.** At 10 ticks per game-minute, this means the player can receive at most 1 unprompted disclosure per game-minute from any NPC. The per-NPC cooldown (300 ticks / 30 game-minutes) is the primary rate limiter. The global cap is a degenerate-case safeguard that only fires when 3+ NPCs all want to disclose in the same game-minute -- unlikely in normal gameplay, but possible when entering a crowded area after a long absence. + +The cap is a `Resource`: + +```rust +#[derive(Resource, Debug, Default)] +pub struct GlobalDisclosureLimiter { + pub last_disclosure_tick: u64, +} +``` + +Check: `if time.tick - limiter.last_disclosure_tick < 10 { return; }`. One comparison per disclosure attempt. Zero overhead when no disclosure fires. + +**Gestalt:** I hear your concern. The per-game-minute granularity means the player would have to encounter 2 NPCs both wanting to disclose within 6 real-time seconds (at 10 tps) for the limit to bite. In practice this safeguard almost never fires. But when it does fire (player enters bar with 8 NPCs after a long investigation), it prevents a disclosure avalanche. Can you live with this? + +### Contradiction time window (`CONTRADICTION_WINDOW_TICKS`) + +Gestalt proposed 600 ticks (1 game-hour). Paula asked what makes narrative sense. + +The window defines "how recent must a claim be for an observation to contradict it, rather than just updating stale info?" The answer depends on the temporal granularity of claims. + +Sera says "Kael was at the dock during second shift." This is a claim about a specific time period. If the player sees Kael in B-7 during second shift, it's a contradiction. If they see Kael in B-7 two game-days later, it's not -- people move around. + +**Position: 1800 ticks (3 game-hours) as the default.** This covers a full shift with buffer. Rationale: + +- D-031: 10 ticks = 1 game-minute. 600 ticks = 1 game-hour. 1800 ticks = 3 game-hours. +- A claim like "Kael was at the dock during second shift" implies a multi-hour window, not a single moment. +- 3 game-hours gives the player time to hear the claim, travel across the station, and observe the contradiction. At normal movement speed, crossing the v0.1 station map takes ~5-10 game-minutes. 3 hours is generous. +- The decay system marks entries as stale at 3600 ticks (6 game-hours). The contradiction window (1800) is well within the "still fresh" range. + +This is a `const` or a `Resource`, easily tunable during playtest: + +```rust +/// Ticks within which a ToldBy claim can be contradicted by a new observation. +/// Default: 1800 ticks = 3 game-hours (at 10 tps per D-031). +pub const CONTRADICTION_WINDOW_TICKS: u64 = 1800; +``` + +### `DisclosureCandidates` compute trigger + +Gestalt asks: is there a "player entered dialogue range" event? + +No, and we don't need one. **Lazy evaluation in the disclosure system itself.** + +The `process_unprompted_disclosure` system runs every tick for Active-tier NPCs. Step 1: check proximity to player (Manhattan distance <= 3, same as `CONVERSATION_PROXIMITY`). If not proximate, skip. Step 2: check if `DisclosureCandidates` component exists and is fresh (within 60 ticks / 6 game-minutes). If stale or missing, recompute from KG. Step 3: apply trigger gates and attempt disclosure. + +The proximity check is O(1) per NPC (position comparison). The candidate recomputation is O(N) per NPC KG where N = fact count (~20). With 30-80 Active NPCs, most of which are NOT proximate to the player, the per-tick cost is dominated by the proximity check -- sub-microsecond for distant NPCs. + +No event infrastructure needed. The system self-manages freshness via the `last_derived_tick` field on `DisclosureCandidates`. + +### Witness inhibition + location privacy gates + +Gestalt proposes witness inhibition (fewer disclosures when other NPCs are nearby). Paula proposes location privacy (disclosure candidates tagged `private`/`semi-private`/`any`). + +**Include both in the v0.1 trigger gate list.** They're cheap and complementary: + +- **Witness inhibition:** Count `ActiveSim` NPCs within 3 tiles of the disclosing NPC (reuse the same proximity scan from the conversation system). If count > 0 and disclosure is trust-tier `real` or above, suppress unless NPC-player trust is at `secret` tier (override). Cost: one pass over the NPC position query, which the disclosure system already needs for proximity. + +- **Location privacy:** Tag on disclosure candidates. The NPC's current tile has a `ZonePrivacy` component (or tagged via the location content data). Candidates tagged `private` only fire in private zones. Cost: one component read per disclosure attempt. + +Both produce meaningful spatial behavior: the player learns "Sera talks freely at the bar but clams up at the Terminal." That's not just realism -- it's an investigative tool. The player can manipulate disclosure by choosing where to encounter NPCs. + +### Runtime NPC KG guardrail + +Round 1 split: Tyre (3-line check, Sprint 17), Dudley (Tier 3, defer), Gestalt (runtime + build pipeline), Paula (authoring-time mandatory). + +**Revised position: Content-load validation in Sprint 17. Runtime check deferred.** + +Dudley is right that the runtime check adds query complexity to `process_knowledge_events` (cross-entity KG lookup to verify the granting NPC holds the fact). This is a `Query<&KnowledgeGraph>` fetch inside the event processing loop, which currently only uses `knowledge_query.get_mut(event.observer)`. Adding a second lookup for the granting NPC's KG changes the borrow patterns. + +Content-load validation catches the common case (author wrote a grant for a fact the NPC doesn't know in their initial KG). This is a ~20-line check in the content indexer. Runtime enforcement handles the dynamic case (NPC learned and then forgot a fact) but is architecturally heavier. + +**Sprint 17: content-load validation. Sprint 18: runtime enforcement if playtest shows it matters.** + +### Paula's Major secret `disclosure_threshold_override` + +Paula proposes a per-KG-entry flag that prevents sharing regardless of trust tier. This models "Kael will never tell anyone about the ring, even at maximum trust, because sharing is dangerous." + +**Architecturally sound. Add to the gossip transfer filter.** + +Implementation: a new field on `FactKnowledge`: + +```rust +pub struct FactKnowledge { + // ... existing fields ... + /// If true, this fact is never transferred via gossip or unprompted disclosure, + /// regardless of trust tier. Used for Major secrets whose sharing is dangerous. + #[serde(default)] + pub never_disclose: bool, +} +``` + +The gossip transfer system and the disclosure candidate system both check this flag before including a fact in the transfer/candidate set. One boolean check per entry. Trivial cost. + +Content authors set `never_disclose: true` on facts that represent existential secrets. This is cleaner than encoding the behavior in the trust-tier thresholds, because it's per-fact rather than per-tier. + +--- + +## D-Record Draft: Architecture Sections + +The following are draft sections for the workshop's D-record, covering architecture, ECS patterns, and performance budgets. + +### Section: Knowledge Grant Architecture + +**Grant event type.** All knowledge input flows through a single `KnowledgeEventType::KnowledgeGranted` variant. No separate event types for dialogue grants, evidence discovery, POI discovery, or NPC-to-NPC gossip transfer. The source field (`KnowledgeSource`) distinguishes provenance: `ToldBy` for NPC testimony, `DirectObservation` for physical evidence, `Heard` for overheard conversations, `Background` for initial character knowledge. + +**Grant payload.** The YAML `KnowledgeGrant` schema supports three variants via serde untagged enum: +- `Fact { fact_id, confidence }` -- creates/updates a `FactKnowledge` entry +- `Entity { entity_ref, position_hint, attributes, confidence }` -- creates/updates an `EntityKnowledge` entry with `ToldBy` source +- `Compound { grants: Vec }` -- multiple grants from a single line + +The `entity_ref` string resolves to `StableId` at content index time via the `ContentNameRegistry` resource, populated during NPC spawn. + +**Grant timing.** Grants fire at line selection time in the dialogue system, server-side. The event is pushed to `KnowledgeEventQueue` and processed on the same tick. This is D-010 compliant: deterministic, tick-stamped, server-authoritative. + +**Content validation.** Content-load validation checks that grant confidence strings parse to valid `KnowledgeConfidence` variants, fact_ids conform to `"category.topic"` format, and entity_refs resolve to known StableIds. Runtime NPC KG validation deferred to Sprint 18. + +### Section: NPC-to-NPC Knowledge Propagation + +**Hook.** Knowledge transfer piggybacked on the existing `run_npc_conversations` system (`server/src/simulation/conversation.rs`). A separate `transfer_npc_knowledge` system runs immediately after conversations (bevy ECS system ordering), using `kg_query.get_many_mut([entity_a, entity_b])` for concurrent mutable access to both participants' KGs. + +**Trust-gated filtering.** Transfer eligibility determined by `RelationshipEdge.trust` value between the two NPCs: + +| Trust | Tier | Eligible entries | +|-------|------|-----------------| +| < 0 | None | No transfer | +| 0..3 | Surface | Active facts with confidence >= KnowsOf | +| 3..7 | Real | Active facts at any confidence + entity observations | +| 7..10 | Secret | All Active entries except `never_disclose` flagged | + +**Confidence cap.** `transferred_confidence = min(source_confidence, KnowledgeConfidence::KnowsOf)`. Gossip chains never produce `KnowsDetails` or `Direct`. `Suspects` stays `Suspects`. + +**Rate limit.** 1-3 facts per conversation (`rng.random_range(1..=3)`), selected from eligible entries sorted by `last_updated_tick` descending (most recent first). + +**Source construction.** `KnowledgeSource::ToldBy { source_id: speaker_sid, tick: time.tick }`. Both values available at conversation termination. + +**Player overhearing.** When the player entity is within `VOICE_RANGE_TILES` (8) of an NPC-NPC conversation that transferred knowledge, the player's KG gains entity-level entries (the entities discussed) at `Suspects` confidence with `KnowledgeSource::Heard { tick, range: SoundRange::Medium }`. Specific fact transfer to the player deferred to Sprint 18 when content-authored NPC conversation lines replace placeholders. + +### Section: Unprompted Disclosure + +**Architecture.** Disclosure is a filtered KG query producing a `DisclosureCandidates` component, consumed by Layer 4 of the dialogue pipeline. Separate from `DerivedTellState` (different update frequency, different data shape). + +**Candidate derivation.** `derive_disclosure_candidates` runs lazily: only for Active-tier NPCs within proximity of the player, recomputed every 60 ticks (6 game-minutes). Queries the NPC's `KnowledgeGraph` for Active-state facts at confidence >= `KnowsOf`, filters by trait-based predicates and `never_disclose` flag. + +**NPC checks own KG only.** D-010 principle 2 (information boundaries) prohibits cross-entity KG queries for disclosure decisions. NPCs do not know what the player knows. Repeated disclosure of known information is acceptable and narratively meaningful. + +**Trait two-stage filter.** Stage 1: traits filter the candidate pool (WHAT the NPC is willing to disclose). Stage 2: traits bias Line Pool scoring (HOW the NPC says it). Both stages are content-authorable via trait-to-predicate mappings in configuration YAML. + +**Trigger gates** (all must pass): +1. Trust tier >= `surface` (NPC willing to speak to player) +2. Mood != Hostile +3. Contentment >= -10 +4. Witness inhibition: if nearby NPCs present, suppress `real`/`secret`-tier disclosures unless NPC-player trust is `secret` tier +5. Location privacy: disclosure candidates tagged `private` only fire in private zones +6. Per-NPC disclosure cooldown (300 ticks / 30 game-minutes) +7. Global disclosure limiter (1 per 10 ticks / 1 per game-minute) + +### Section: NPC Information Boundaries + +**MVP scope.** Sprint 17: `tell_state.rs` relationship reads from KG + unprompted disclosure from KG. No other system retrofits. + +**Self-knowledge.** NPCs read their own axis components directly for self-state (Secret, Contentment, Tolerance, Mood). The KG applies to knowledge of OTHER entities only. An NPC always knows its own internal state -- the information boundary applies to external knowledge. + +**Simulation tiers.** KG-driven behavior scoped to `With`. Background-tier NPCs (D-026, 500-2000) receive no KG-based boundary changes. Tier transition from Background -> Active hydrates the NPC's KG from Background state and accumulated gossip. + +**Fallback.** When an NPC's KG has no relevant entry for a decision, fall through to ground truth with `tracing::debug!` structured logging. No "ask around" behavior in v0.1. + +**Deferred.** Conversation partner KG check (Sprint 18 candidate). Routine KG awareness (v0.2+). Pathfinding from KG (not planned -- failure mode has no safe recovery). + +### Section: Contradiction Detection + +**Detection timing.** Event-driven at KG write time. In `observe_entity()` (`server/src/knowledge/graph.rs:111`), before overwriting the entry, compare incoming observation against current entry. If `current.source` is `ToldBy`, `current.last_known_position` differs from incoming position, and `|current_tick - told_tick| < CONTRADICTION_WINDOW_TICKS`, the contradiction fires. + +**`ContradictionClaim` struct.** New optional field on `EntityKnowledge`: + +```rust +pub struct ContradictionClaim { + pub source: KnowledgeSource, + pub claimed_position: Option, + pub detected_tick: u64, +} +``` + +On contradiction detection: `entry.state = KnowledgeState::Contradicted`, `entry.contradicted_claim = Some(...)`, then overwrite with the new observation. The prior claim is preserved for monologue and relationship downstream consumers. + +**Contradiction window.** `CONTRADICTION_WINDOW_TICKS = 1800` (3 game-hours). Covers a full work shift with travel buffer. Configurable per-playtest. + +**Location contradiction:** Automatic. Position comparison + time window. Tier 1 difficulty. + +**Attribute contradiction:** Content-authored pairs in YAML. Author specifies which attribute key+value combinations are mutually exclusive. Detection checks the authored lookup table when attributes are updated. Tier 2 difficulty. + +**Fact contradiction:** Content-authored `contradicts` field on KnowledgeGrant YAML entries. Detection fires when both contradicting facts exist in the same KG at Active state. + +**Event chain:** +1. `observe_entity()` detects contradiction, sets `Contradicted`, stores `ContradictionClaim` +2. `detect_anomalies` (`server/src/perception/anomaly.rs:44`) marks entity with `AnomalyMarker` (fires on `Contradicted` state, already tested) +3. Monologue system selects contradiction line, references `contradicted_claim.source` to name the source entity +4. Relationship system shifts `ToldBy` source entity to `PersonOfInterest` (D-033 amber color) + +Steps 2-4 are already implemented and passing tests. Step 1 is the new work. + +**Monologue content.** Contradiction monologue lines for FRIEND-pattern NPCs are hand-authored with explicit source naming ("Sera said Kael was at the dock"). Generic fallback template for auto-generated NPCs (Sprint 18). v0.1: hand-authored lines are sufficient given 2 FRIEND NPCs. + +### Section: Performance Budgets + +| Operation | Cost | Budget | Margin | +|-----------|------|--------|--------| +| Knowledge grant processing | ~1us per grant | 3ms/tick knowledge budget | 3000x | +| NPC-to-NPC transfer (per conversation) | ~6us (trust lookup + scan + write) | 3ms/tick | 500x | +| Disclosure candidate derivation (per NPC) | ~5us (KG scan of ~20 facts) | 3ms/tick | 600x | +| Location contradiction detection (per KG write) | ~10ns (position comparison) | 3ms/tick | 300,000x | +| Attribute contradiction detection (per attribute write) | ~500ns (authored pair lookup) | 3ms/tick | 6000x | +| `ContradictionClaim` struct memory (per contradicted entry) | ~56 bytes | ~14 KB per NPC KG | 0.4% overhead | + +All operations are well within the D-041 performance budget. The event-driven architecture (only process when KG is written) keeps everything off the per-tick hot path. + +### Section: Q-025 Closure + +**Decision: Close Q-025.** KG memory cap and eviction strategy is not needed for v0.1 or v0.2. + +Current budget: ~14 KB per Active NPC KG (50 entities + 20 facts). 80 Active NPCs = ~1.1 MB. 2,000 Background NPCs at 10 entries = ~5 MB. Total: ~6 MB. Gossip propagation increases entries by ~30% (1-3 facts per conversation, ~2 conversations per game-hour per NPC). Even doubled: ~12 MB. + +**Re-evaluation trigger:** Active NPC count exceeds 200, OR profiling shows KG memory exceeding 50 MB. Neither condition is expected before v0.3. + +--- + +## Implementation Dependencies and Sprint 17 Ordering + +``` +Week 1: + ContentNameRegistry + KnowledgeGrant YAML extension [0.5 day] + KnowledgeEventType::KnowledgeGranted + processing [1.5 days] + ContradictionClaim struct on EntityKnowledge [0.5 day] + ─── entity grants + contradiction struct land ─── + +Week 2: + transfer_npc_knowledge system (gossip) [2 days] + DisclosureCandidates + trigger system [2 days] + ↑ can parallel ↑ + tell_state.rs Option<&KnowledgeGraph> addition [0.5 day] + +Week 3: + Location contradiction detection in observe_entity [1.5 days] + Attribute contradiction authored pairs + detection [1.5 days] + Monologue integration (FRIEND arc lines) [0.5 day] + ─── contradiction chain complete ─── +``` + +**Total: ~10.5 days.** Gossip and disclosure are parallelizable (Week 2). Contradiction detection depends on gossip (needs ToldBy entries to exist). The critical path is: grant mechanism -> gossip -> contradiction detection. + +--- + +## Summary of Round 2 Positions + +| Tension/Item | Resolution | Rationale | +|---|---|---| +| **Tension A: Entity grants** | Sprint 17. ContentNameRegistry ~30 lines. | FactId workaround breaks contradiction chain. Registry cost is 0.5 days, not multi-day. | +| **Tension B: Structural fix** | Typed `ContradictionClaim` struct, not string encoding. | Zero breaking changes (Option + serde default). No refactor debt. No string parsing in hot path. | +| Global disclosure limit | Per-game-minute (1 per 10 ticks). Resource counter. | Addresses Gestalt's concern (generous enough to rarely fire) while preventing degenerate case. | +| Contradiction window | 1800 ticks (3 game-hours). Configurable const. | Covers a shift with travel buffer. Well within decay freshness range (3600 stale). | +| Disclosure compute trigger | Lazy evaluation in the disclosure system. 60-tick freshness. | No event infrastructure needed. Proximity check is O(1) per NPC. | +| Witness + location gates | Both included in v0.1 trigger list. | Cheap, complementary, produce meaningful spatial behavior. | +| Runtime NPC KG guardrail | Content-load validation Sprint 17. Runtime Sprint 18. | Dudley is right that runtime adds borrow complexity. Content-load catches the common case. | +| Major secret override | `never_disclose: bool` on `FactKnowledge`. | Clean, per-fact, one boolean check per entry in gossip/disclosure systems. | +| Naming | `ContradictionClaim` (not `ContradictionBasis`). | "Claim" describes the payload: the prior claim that was contradicted. | diff --git a/docs/workshops/knowledge-flow-npc-boundaries/workshop-outcomes.md b/docs/workshops/knowledge-flow-npc-boundaries/workshop-outcomes.md new file mode 100644 index 000000000..140919684 --- /dev/null +++ b/docs/workshops/knowledge-flow-npc-boundaries/workshop-outcomes.md @@ -0,0 +1,345 @@ +# Workshop Outcomes — Knowledge Flow & NPC Information Boundaries + +**Workshop date:** 2026-02-23 — 2026-02-24 +**Documented by:** Qatux +**Participants:** Tyre, Gestalt, Dudley, Paula +**Source files:** round-1-notes.md, round-2-notes.md, tyre-round1/2.md, gestalt-round1/2.md, dudley-round1/2.md, paula-round1/2.md + +--- + +## D-Record Drafts + +The following D-records are produced by this workshop. They are ready for registration in the appropriate decisions/ domain files. The D-numbers D-079 through D-083 are assigned sequentially after D-078 (current highest in perception.md). + +--- + +### D-079: Knowledge Grant Architecture + +- **Date:** 2026-02-24 +- **Decision:** All knowledge input (dialogue testimony, physical evidence, POI discovery, NPC-authored initial knowledge) flows through a single `KnowledgeEventType::KnowledgeGranted` event type. No separate event types for different grant sources. The `KnowledgeGrant` YAML schema is extended to an untagged enum supporting `Fact { fact_id, confidence }` and `Entity { entity_ref, attributes, confidence }` variants. The source field (`KnowledgeSource`) distinguishes provenance: `ToldBy { source_id: StableId, tick }` for NPC testimony, `DirectObservation { tick }` for physical evidence, `Heard { tick, range }` for overheard conversations. Grants fire at line selection time in `process_talk_interaction`, server-side, pushed to `KnowledgeEventQueue` and processed on the same tick. A `ContentEntityRegistry` resource (`BTreeMap`, populated at NPC spawn time) resolves `entity_ref` strings to `StableId`s at grant processing time. Content-load validation enforces: confidence strings parse to valid `KnowledgeConfidence` variants; fact_ids conform to `"category.topic"` format; entity_refs resolve to registered StableIds. Runtime guardrail: if the granting NPC's KG does not contain the fact being granted, the grant is dropped with `tracing::warn!`. +- **Rationale:** Unified event type keeps the knowledge system composable. Grant timing at line selection (not client display) ensures D-010 determinism — tick-stamped and server-authoritative. Entity grants are required for contradiction detection: testimony must create `EntityKnowledge` with `ToldBy` source so that a subsequent `DirectObservation` can detect a discrepancy. Physical evidence uses the same mechanism with `DirectObservation` source, which is treated as higher-confidence and cannot be contradicted by the NPC-denial path. +- **Raised by:** Workshop — unanimous on grant timing and unified event type. Entity grants in Sprint 17 per team lead decision (2026-02-24), overriding Paula's Round 2 acceptance of Dudley's FactId workaround. See round-2-notes.md §Tension A for full workshop split and rationale. +- **Dissent:** Paula (Round 2) accepted the FactId workaround with 3 binding conditions; team lead overrode in favour of the entity grants path championed by Tyre, Gestalt, and Dudley. Paula's conditions remain relevant where applicable: (1) `ToldBy` source must flow through `ContradictionDetected` event payload — satisfied by D-083 design; (2) Sprint 18 entity grants (full `EntityGrant { attributes: BTreeMap }` variant + `Compound` variant) are **committed scope**, not aspirational. Paula's Condition 3 is formally recorded here as a commitment. +- **Scope note — `Compound` grant variant:** `Compound { grants: Vec }` (multiple grants from a single line) deferred to Sprint 18. Sprint 17 ships `Fact` and `Entity` variants only. + +--- + +### D-080: NPC-to-NPC Knowledge Propagation + +- **Date:** 2026-02-24 +- **Decision:** NPC-to-NPC knowledge transfer is implemented via a `transfer_npc_knowledge` Bevy system that runs immediately after `run_npc_conversations` (system ordering: `transfer_npc_knowledge.after(run_npc_conversations)`). The system uses `kg_query.get_many_mut([entity_a, entity_b])` for dual-mutable KG access (required by Bevy ECS — two mutable borrows of the same component type cannot occur in a single query). Knowledge transfer occurs once per conversation at conversation start, not per-line. Rate: flat `rng.random_range(1..=3)` facts per conversation, drawn from eligible entries sorted by `last_updated_tick` descending (most recent first). Trust-gated filtering: `RelationshipEdge.trust` value determines eligible entries (None tier < 0: no transfer; Surface 0–3: Active facts at KnowsOf+ only; Real 3–7: facts at any confidence + entity observations; Secret 7–10: all Active entries). Confidence cap: `transferred_confidence = min(source_confidence, KnowledgeConfidence::KnowsOf)`. Facts with `disclosure_blocked: true` are never transferred regardless of trust tier. Source construction: `KnowledgeSource::ToldBy { source_id: speaker_sid, tick: time.tick }`. Player overhearing: when the player is within `VOICE_RANGE_TILES` (8) of a knowledge-transferring NPC pair, the player gains entity-level KG entries at `Suspects` confidence with `KnowledgeSource::Heard { tick, range: Medium }`. Specific overheard fact transfer to player deferred to Sprint 18. +- **Rationale:** The existing `run_npc_conversations` system already provides proximity detection, deterministic StableId-sorted pairing, and conversation lifecycle — reusing it avoids building a separate propagation system and keeps NPC conversations as the natural information-sharing hook. Confidence capping at `KnowsOf` prevents gossip chains from amplifying information; only direct observation produces `KnowsDetails` or `Direct`. `ToldBy` source construction is the prerequisite for contradiction detection (Topic 5). The `disclosure_blocked` flag models secrets whose sharing is existentially dangerous regardless of relationship trust (D-034 NPC design requirement). Closes Q-024. +- **Raised by:** Workshop — unanimous +- **Dissent:** None + +--- + +### D-081: Unprompted Disclosure Design + +- **Date:** 2026-02-24 +- **Decision:** Unprompted disclosure (ticket #172) is implemented as a filtered KG query producing a `DisclosureCandidates` component, consumed by Layer 4 of the dialogue pipeline. The system runs as `derive_disclosure_candidates` (Active-tier NPCs within player range only; recomputed every 30 ticks via `computed_tick` freshness check). NPCs check their own KG only — no cross-entity KG queries (D-010 principle 2). Candidate selection filters: Active state only; confidence ≥ KnowsOf (Cautious trait raises threshold); not in `per_fact_history` (not already disclosed this cooldown period); `disclosure_blocked != true`. Trait two-stage filter: Stage 1 (what) filters candidate pool by trait-based predicates (Cautious raises confidence floor, Gossipy lowers it, Loyal suppresses facts about protected entities). Stage 2 (how) biases line pool scoring in Layer 4. Trigger gates (all must pass): trust tier ≥ Surface toward player; mood not Hostile; contentment ≥ −10; candidates not empty; witness inhibition (no non-trusted NPCs within 5 tiles, OR NPC-player trust = Secret tier, OR Talkative trait override); location privacy (disclosure candidate's `disclosure_context` tag compatible with current zone type: `private`/`semi_private`/`any`); per-NPC cooldown (300 ticks / 30 game-minutes) not active; global rate limit not exceeded. Global rate limit: 1 disclosure per 10 ticks maximum across all NPCs; when multiple candidates ready in same window, select by ascending StableId (deterministic, D-010). Rate limiting layers: (1) per-fact per-NPC `per_fact_history` — primary, prevents repeats; (2) per-NPC 300-tick cooldown — prevents spam; (3) global 1/10-tick cap — prevents degenerate multi-NPC simultaneous fire. +- **Rationale:** NPCs checking only their own KG (Option A) is both diegetically correct and mechanically richer: NPCs can say things the player already knows, which creates dramatic irony (Sera praising Kael's integrity after the player found discrepancies). Option B (NPC checks player's KG) would collapse this asymmetry — NPCs would go silent at exactly the moments their speech would be most dramatically charged. Separate `DisclosureCandidates` component (not added to `DerivedTellState`) prevents DerivedTellState serialization bloat and allows different update frequencies for different concerns. The location privacy gate creates learnable spatial behavior patterns: "Sera talks freely at Lera's; she clams up at The Terminal." The per-fact `per_fact_history` is the primary narrative quality gate — an NPC who repeats the same fact is a quest marker, not a person. Implements ticket #172. +- **Raised by:** Workshop — unanimous on architecture; Paula and Gestalt on trigger gates; Tyre and Dudley on implementation structure +- **Dissent:** Gestalt (Round 1): opposed global rate limit as creating invisible NPC competition. Resolved in Round 2: Paula withdrew support, Gestalt conditionally accepted with deterministic StableId selection. Included. + +--- + +### D-082: NPC Information Boundaries — MVP Scope + +- **Date:** 2026-02-24 +- **Decision:** Sprint 17 MVP information boundary scope: (1) `tell_state.rs` reads NPC relationship state from KG for OTHER-entity state (replaces direct `relationships.entries` read for Friendly tell derivation); self-axis components (Secret, Contentment, Tolerance, Mood) continue as ground-truth reads. (2) Unprompted disclosure (D-081) uses KG for candidate selection. No other system retrofits in Sprint 17. `derive_tell_state` adds `Option<&KnowledgeGraph>` to query; `None` falls through to current axis-based behavior (existing tests continue passing). All KG-driven behavior scoped to `With` — Background-tier NPCs (D-026, 500–2000) receive no KG-based boundary changes. Deferred: conversation partner KG-awareness check (Sprint 18 candidate, `kg.knows_entity(&partner_sid)` in pairing loop); routine KG awareness (v0.2+); pathfinding KG integration (not planned — failure mode has no safe recovery). Fallback on missing KG entry: fall through to ground truth with `tracing::debug!` structured logging. +- **Rationale:** The minimum viable boundary produces maximum gameplay-visible difference for minimum risk: NPCs whose tells reflect their actual relationship knowledge (not just raw axes) and NPCs who only volunteer what they know. Pathfinding from KG has an unresolvable failure mode (NPC forgets path nodes = stuck NPCs = undefined movement behavior) and must not be implemented. Self-knowledge always comes from axis components because the NPC is the continuous observer of its own state — the information boundary applies to external knowledge only. The `Option<&KnowledgeGraph>` addition is backward-compatible. Implements ticket #142 MVP. +- **Raised by:** Workshop — unanimous +- **Dissent:** None. All participants agreed MVP boundary is tell_state + disclosure. Paula adds that `tell_state` should eventually incorporate KG-derived secret-exposure intensity (NPC whose KG shows investigation proximity acts more stressed) — deferred to a future sprint as enhancement. + +--- + +### D-083: Contradiction Detection Pipeline + +- **Date:** 2026-02-24 +- **Decision:** Contradiction detection is event-driven at KG write time — fires inside `observe_entity()` (`server/src/knowledge/graph.rs`) before overwriting an existing entry. Detection algorithm: if the existing entry has `source: ToldBy { tick: told_tick }` AND `last_known_position` differs from incoming position AND `|current_tick - told_tick| < CONTRADICTION_WINDOW_TICKS` (default 600; configurable per contradiction type), then: (1) `entry.state = KnowledgeState::Contradicted`; (2) `entry.contradicted_claim = Some(ContradictionClaim { source: entry.source.clone(), claimed_position: entry.last_known_position, detected_at_tick: current_tick })`; (3) proceed to write new observation. A `ContradictionDetected { observer, entity_sid, source_display_name: Option, subject_display_name: Option }` event is pushed; display names are resolved at detection time via `EntityRegistry + NpcName` so the monologue system is a pure string consumer. `EntityKnowledge` gains one new field: `pub contradicted_claim: Option` with `#[serde(skip_serializing_if = "Option::is_none")]` and `#[serde(default)]` — backward-compatible, zero migration required. Downstream event chain: `ContradictionDetected` → monologue system (named contradiction line) → relationship shift (`ToldBy` source entity → `PersonOfInterest`) → D-033 amber via existing pipeline → `AnomalyMarker` via `detect_anomalies` (already tested). Both the `ToldBy` entry and the `DirectObservation` entry receive `Contradicted` state (epistemic neutrality — the engine does not determine which is wrong). Location contradiction is automatic (position comparison + time window). Attribute contradiction uses content-authored YAML pairs (Sprint 18). Fact contradiction uses content-authored `contradicts` field on KnowledgeGrant entries (Sprint 18). `CONTRADICTION_WINDOW_TICKS = 600` (1 game-hour) as default constant, with content-authored override per claim type. Closes Q-026. +- **Rationale:** Event-driven detection avoids per-tick KG scan overhead (99% of ticks have no new information). The `ContradictionClaim` struct (typed) was chosen over string encoding in `known_attributes` (Dudley's Option B) because: (1) the struct is accessed with direct field reads — no string parsing in the detection hot path; (2) the monologue system needs a typed `KnowledgeSource` to resolve the source entity's name; (3) string encoding in `known_attributes` mixes semantic metadata with internal bookkeeping; (4) one optional struct field is additive — no breaking changes, no migration. Steps 2–5 of the downstream chain (anomaly marker, D-033 color, relationship shift, monologue selection) are already implemented and passing tests. Contradiction detection is the missing link that enables THE FRIEND arc to fire its canonical sequence (D-034). +- **Raised by:** Workshop — unanimous on architecture and downstream chain; Tyre and Gestalt on struct approach (converged independently); Dudley conceded Option B +- **Dissent:** Dudley (Round 1): Option B string encoding as Sprint 17 workaround. Conceded in Round 2. No standing dissent. + +--- + +## Question Closures + +### Q-024: NPC-to-NPC knowledge propagation timing + +- **Status:** CLOSED +- **Resolution:** Knowledge transfer occurs via a separate `transfer_npc_knowledge` Bevy system running after `run_npc_conversations`. Transfer fires once per conversation at conversation start. See D-080 for full specification. +- **Closed by:** Workshop — unanimous + +--- + +### Q-025: KG memory pressure and eviction strategy + +- **Status:** CLOSED +- **Resolution:** No memory cap or eviction strategy is needed for v0.1 or v0.2. Current analysis: ~14 KB per Active NPC KG (50 entities + 20 facts, D-041 budget). 80 Active NPCs = ~1.1 MB. 2,000 Background NPCs at 10 entries = ~5 MB. Total ~6 MB. With gossip propagation (1–3 facts per conversation): estimated ~12 MB peak at current NPC counts. **Re-evaluation trigger:** Active NPC count exceeds 200, OR profiling shows KG memory exceeding 50 MB. Neither condition expected before v0.3. +- **Closed by:** Workshop — Tyre, Gestalt, Dudley confirmed (Paula did not address; non-objection noted) + +--- + +### Q-026: Contradiction detection — when and how to detect + +- **Status:** CLOSED +- **Resolution:** Event-driven detection at KG write time in `observe_entity()`, using `ContradictionClaim` struct. Location contradiction is automatic (Sprint 17). Attribute and fact contradiction are content-authored (Sprint 18). See D-083 for full specification. Entity grants ship in Sprint 17 (team lead decision, 2026-02-24); the contradiction chain fires via `EntityKnowledge.ToldBy` as designed. +- **Closed by:** Workshop — unanimous on detection architecture. Unconditional. + +--- + +## Implementation Scope + +### Ticket #141 — Knowledge grant wiring (POI discovery) + +**Updated scope based on workshop:** +- Grant mechanism: `KnowledgeEventType::KnowledgeGranted` (D-079) handles POI discovery via `FactId("poi.dock_7_restricted")` — no separate event type needed +- When player enters POI trigger zone, push `KnowledgeGranted { fact_id: FactId("poi.*"), confidence, source: DirectObservation { tick } }` to queue +- `filter_by_access` → `KnowledgeGated("poi.dock_7_restricted")` already gates content on this fact (graph.rs:299–302) +- No new access control code; no new types; no new systems beyond the shared `KnowledgeGranted` event +- **Depends on:** Dudley's Ticket A + B (grant infrastructure) +- **Effort:** ~20 lines on top of Ticket B infrastructure; primarily content authoring for POI definitions + +### Ticket #142 — NPC information boundaries (MVP) + +**Updated scope based on workshop (D-082):** + +Sprint 17 deliverable: +1. `derive_tell_state` adds `Option<&KnowledgeGraph>` to query; uses KG for OTHER-entity relationship reads; falls back to axis components for self-state and when KG is absent +2. `DisclosureCandidates` component + `derive_disclosure_candidates` system (see #172 spec) +3. `process_unprompted_disclosure` system stub (trigger gates operational; content-authored lines sparse for Sprint 17) + +**Explicitly out of Sprint 17 scope:** +- Conversation partner KG-awareness check (Sprint 18) +- Routine KG awareness (v0.2+) +- Pathfinding KG integration (not planned) + +**Depends on:** Ticket B (KG populated with meaningful content); Ticket D (NPC KGs populated via gossip) + +--- + +## Updated Specifications + +### Ticket #172 — Unprompted Disclosure + +**Full specification (supersedes any prior spec):** + +**New components:** +```rust +#[derive(Component, Debug, Default)] +pub struct DisclosureCandidates { + pub candidates: Vec, + pub computed_tick: u64, +} + +#[derive(Component, Debug, Default)] +pub struct DisclosureCooldown { + pub per_fact_history: BTreeSet, // facts disclosed this cooldown period + pub npc_cooldown_until: u64, +} +``` + +**`derive_disclosure_candidates` system:** +- Runs every tick for Active NPCs; recomputes candidates when `time.tick - computed_tick > 30` +- Filters NPC KG: Active state; confidence ≥ KnowsOf (trait-adjusted); not in `per_fact_history`; `disclosure_blocked != true`; trust rough-gate (categories requiring Real trust excluded at Surface) +- Trait Stage 1 predicates: Cautious = requires KnowsDetails+; Gossipy = includes Suspects; Loyal = suppresses entity-linked facts where entity is trusted by NPC +- Candidate list capped at 10; sorted by confidence descending, then `last_updated_tick` descending + +**`process_unprompted_disclosure` system:** +- Trigger gates (all must pass): trust ≥ Surface; mood ≠ Hostile; contentment ≥ −10; candidates not empty; per-NPC cooldown clear; global rate limit (1/10 ticks, StableId-ordered); witness inhibition check; location privacy check +- On success: push `KnowledgeGranted` event for player + push selected fact to `per_fact_history`; reset `npc_cooldown_until` + +**Trait Stage 2 (line pool):** Trait modifier tags in `IndexedDialogueLine.tags` bias Layer 4 selection weights for delivery style. + +**`disclosure_context` YAML field on disclosure candidates:** `private` / `semi_private` / `any`. Checked at Layer 4 line selection against current location zone type. + +**Estimated effort:** ~220 lines (Dudley Ticket G); requires Gestalt + Paula algorithm spec (now provided in round-2-notes.md). + +--- + +### Ticket #173 — Trait filtering for unprompted disclosure + +**Full specification (supersedes any prior spec):** + +Traits operate as two-stage filters, both implemented: + +**Stage 1 — candidate pool filter (WHAT):** + +| Trait | Filter applied | +|-------|---------------| +| Cautious | Remove candidates with confidence < KnowsDetails; remove ToldBy-source facts (won't pass on rumors) | +| Gossipy | Include Suspects-confidence candidates (normally excluded) | +| Loyal | Remove facts linked to entities with trust_level ≥ Real in NPC Relationships | +| Talkative | Override witness inhibition gate; include candidates at all confidence levels | + +Traits map to filter predicates via a content-authorable YAML configuration (not hard-coded enum dispatch). + +**Stage 2 — line pool scoring (HOW):** +- Trait tags in `IndexedDialogueLine.tags` are matched by Layer 4 line selection +- Tag examples: `"cautious_delivery"`, `"gossip_delivery"`, `"professional_delivery"` +- Same underlying candidate fact can have multiple authored line variants with different trait delivery tags + +**`disclosure_eligible: bool` in authored NPC KG YAML:** +- Set to `false` for facts that should never enter the candidate pool regardless of trust tier or trait +- Implemented as `disclosure_blocked: bool` field on `FactKnowledge` (default `false`) +- Used for Major secrets with survival stakes (e.g., Kael's ring membership) + +**Estimated effort:** ~40 lines for trait filter predicates + content configuration schema + +--- + +## New Ticket Recommendations + +The following new tickets are recommended based on workshop outputs. These are recommendations for SI to formalize: + +--- + +### Recommended Ticket A: KnowledgeGrant schema + ContentEntityRegistry + +**Scope:** `server/src/content/types.rs` — replace `KnowledgeGrant` struct with untagged enum (`Fact` + `Entity` variants); add `disclosure_blocked: bool` to `FactKnowledge`. New file `server/src/knowledge/content_registry.rs` — `ContentEntityRegistry` resource. NPC spawn sites: add `content_registry.register(content_id, sid)`. Content index validation: parse confidence strings, validate entity_refs. + +**Blocks:** Tickets B, C, D, E, F, G (all downstream work) +**Effort estimate:** ~120 lines +**Sprint:** 17 + +--- + +### Recommended Ticket B: KnowledgeGranted event + `process_knowledge_events` handler + +**Scope:** `server/src/knowledge/events.rs` — add `KnowledgeEventType::KnowledgeGranted` variant with `ProcessedKnowledgeGrant` enum (`Fact` and `Entity` subtypes); add match arm handler. `server/src/knowledge/types.rs` — add `TryFrom<&str> for KnowledgeConfidence`. `server/src/simulation/dialogue.rs` — wire `knowledge_grant` field in `process_talk_interaction` after line selection. Runtime NPC KG guardrail: 3-line check in handler. + +**Blocks:** Tickets D, F, G; ticket #141 POI discovery wiring +**Effort estimate:** ~150 lines +**Sprint:** 17 + +--- + +### Recommended Ticket C: ContradictionClaim struct + detection in `observe_entity` + +**Scope:** `server/src/knowledge/types.rs` — add `ContradictionClaim` struct; add `contradicted_claim: Option` to `EntityKnowledge`; add `CONTRADICTION_WINDOW_TICKS: u64 = 600` constant. `server/src/knowledge/graph.rs` — add pre-overwrite contradiction check in `observe_entity()`. `server/src/knowledge/events.rs` — add `ContradictionDetected` event variant; add processing (relationship shift for ToldBy source entity). Tests: location contradiction, time window boundary, no false positive for non-ToldBy sources. + +**Blocks:** Ticket F (requires ContradictionClaim + event for monologue integration) +**Effort estimate:** ~120 lines + ~40 lines tests +**Sprint:** 17 + +--- + +### Recommended Ticket D: NPC-to-NPC knowledge transfer system + +**Scope:** New file `server/src/simulation/npc_knowledge_transfer.rs` — `transfer_npc_knowledge` system. Trust-tier mapping; confidence downgrade via `min(source_confidence, KnowsOf)`; `disclosure_blocked` check; ToldBy source construction; 1–3 random fact selection (most-recent-first); player overheard grant at Suspects confidence; system ordering: `after(run_npc_conversations)`. + +**Blocks:** Ticket F (ToldBy entries needed for contradiction to fire); Ticket G (NPC KGs need meaningful content before disclosure is useful) +**Effort estimate:** ~160 lines +**Sprint:** 17 + +--- + +### Recommended Ticket E: `tell_state.rs` KG awareness (MVP boundary #142) + +**Scope:** `server/src/npc/tell_state.rs` — add `Option<&KnowledgeGraph>` to `derive_tell_state` query; replace Friendly-tell `relationships.entries` direct read with `kg.relationship_with(&entity_sid)` for OTHER-entity state; self-axes remain ground-truth reads. + +**Blocks:** Nothing (standalone improvement, existing tests continue passing) +**Effort estimate:** ~35 lines +**Sprint:** 17 + +--- + +### Recommended Ticket F: Contradiction monologue + event chain completion + +**Scope:** `server/src/simulation/monologue.rs` — add `EntityRegistry` + `Query<&NpcName>` to system signature (or read pre-resolved names from event payload); add `resolve_name` helper; add match arm for `ContradictionDetected`. `server/src/knowledge/events.rs` — in `ContradictionDetected` processing, call relationship shift for ToldBy source entity; pre-resolve display names into event struct. Monologue pool (content — Mellanie): generic fallback template line + hand-authored FRIEND-specific lines for Sera/Kael contradiction phases. Integration test: full THE FRIEND arc sequence (Tick T1 grant creates ToldBy → Tick T2 observation triggers ContradictionClaim → monologue fires with correct names → Sera/Kael shift to PersonOfInterest → AnomalyMarker set). + +**Blocks:** Nothing (downstream consumers — anomaly.rs, D-033 pipeline — already built and tested) +**Effort estimate:** ~90 lines + content (Mellanie) +**Sprint:** 17 + +--- + +### Recommended Ticket G: DisclosureCandidates + unprompted disclosure (#172 + #173) + +**Scope:** New file `server/src/npc/disclosure.rs` — `DisclosureCandidates` component; `DisclosureCooldown` component; `derive_disclosure_candidates` system; `process_unprompted_disclosure` system; trait filter predicates (Cautious, Gossipy, Loyal, Talkative); global rate limit resource. `server/src/simulation/dialogue.rs` — Layer 4 reads `DisclosureCandidates` for NPC-initiated line selection. System ordering: `derive_disclosure_candidates.after(process_knowledge_events)`, `process_unprompted_disclosure.after(derive_disclosure_candidates)`. + +**Blocks:** Nothing +**Depends on:** Tickets B + D (KG needs meaningful NPC content before disclosure is useful) +**Effort estimate:** ~220 lines +**Sprint:** 17 (implement after Tickets B + D) + +--- + +## THE FRIEND Arc Validation Sequence + +**Status:** All systems in steps 3–6 are already implemented and passing tests. Steps 1–2 are new work (Tickets A+B for grant; Ticket C for ContradictionClaim; Ticket D for ToldBy population in NPC KGs). + +**Canonical sequence (D-079/D-083 integration test, using entity grant path):** + +1. **Tick T1 — Testimony:** Detective talks to Sera → `process_talk_interaction` selects line with `knowledge_grant: { entity_ref: "kael", attributes: { location: "dock-7", shift: "second" }, confidence: "knows_of" }` → `KnowledgeGranted` event pushed → `process_knowledge_events` creates `EntityKnowledge(kael_sid)` in player KG: `source: ToldBy { source_id: sera_sid, tick: T1 }`, `last_known_position: dock_7_pos`, `confidence: KnowsOf`, `state: Active`. + +2. **Tick T2 — Observation:** Player LOS includes Kael in corridor B-7 → Perception fires `DirectObservation` → `observe_entity(kael_sid, b7_pos, T2)` runs → pre-overwrite contradiction check: existing entry has `ToldBy` source; `b7_pos ≠ dock_7_pos`; `|T2 - T1| < 600` → `entry.state = Contradicted`; `entry.contradicted_claim = Some(ContradictionClaim { source: ToldBy { sera_sid, T1 }, claimed_position: dock_7_pos, detected_at_tick: T2 })` → entry overwritten with `DirectObservation`. + +3. **Tick T2 — ContradictionDetected event pushed:** `{ observer: detective_entity, entity_sid: kael_sid, source_display_name: Some("Sera"), subject_display_name: Some("Kael") }`. + +4. **Tick T2/T3 — Downstream cascade (existing systems, already tested):** + - `detect_anomalies` finds `Contradicted` state → `AnomalyMarker` on Kael + - Relationship system: `EntityKnowledge(kael_sid).relationship → PersonOfInterest`; `EntityKnowledge(sera_sid).relationship → PersonOfInterest` + - D-033 pipeline: both Kael and Sera → amber (#e8c547) + - Monologue system: fires contradiction line: *"Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."* + +5. **Next player Talk with Sera:** Confrontation option unlocked for Sera and Kael. Sera behaves normally (she does not know the detective has seen Kael in B-7 — her KG has no such entry). The detective holds the contradiction alone until confrontation. + +--- + +## Sprint 17 vs Sprint 18 Scope Summary + +### Sprint 17 (this workshop produces) + +| Component | Ticket | Lines est. | +|-----------|--------|-----------| +| `KnowledgeGrant` schema + `ContentEntityRegistry` | A | ~120 | +| `KnowledgeGranted` event + handler + dialogue wire | B | ~150 | +| `ContradictionClaim` struct + detection in `observe_entity` | C | ~160 | +| NPC-to-NPC `transfer_npc_knowledge` system | D | ~160 | +| `tell_state.rs` KG awareness | E | ~35 | +| Contradiction monologue + event chain | F | ~90 | +| `DisclosureCandidates` + unprompted disclosure | G | ~220 | +| **Total** | | **~935 lines** | + +Q-024, Q-025, Q-026 closures in decisions/questions.md. + +### Sprint 18 (committed scope from this workshop) + +- Full `EntityGrant { attributes: BTreeMap }` variant (currently only `entity_ref` + position in Sprint 17) +- `Compound` grant variant (facts + entity in one line) +- Conversation partner KG-awareness check (`kg.knows_entity(&partner_sid)` in pairing loop) +- Attribute contradiction via content-authored YAML pairs (typed attribute key conventions) +- Fact contradiction via `contradicts` field on KnowledgeGrant entries +- Template monologue system for named contradictions (generic entity substitution for auto-generated NPCs) +- Specific overheard fact transfer to player (currently: entity-level Suspects grant only) +- Runtime NPC KG decay → disclosure candidate staleness handling + +### Deferred (v0.2+) + +- Routine KG awareness +- Pathfinding KG integration (not planned — no safe failure recovery) +- "Ask around" emergent information-seeking behavior +- NPC disclosing own KG-contradicted knowledge ("I could have sworn Kael was at the dock...") + +--- + +## Team Lead Decisions + +| Decision | Date | Override / Note | +|----------|------|----------------| +| **Entity grants ship in Sprint 17** | 2026-02-24 | Team lead call. Overrides Paula's Round 2 acceptance of Dudley's FactId workaround. Adopts Tyre/Gestalt/Dudley position: compound `KnowledgeGrant` enum with `Entity` variant, `ContentEntityRegistry` at NPC spawn (~0.5 day infrastructure). Paula's conditions (ToldBy in event, Sprint 18 formal commitment) honoured where applicable. See round-2-notes.md §Tension A for full workshop record. | + +--- + +## Open Questions Not Resolved by This Workshop + +| ID | Question | Required for | Decision owner | +|----|----------|-------------|---------------| +| — | `CONTRADICTION_WINDOW_TICKS` final value: 600 (majority) vs 1800 (Tyre) | Contradiction detection sensitivity | Can ship 600; playtest adjusts | + +--- + +*Document produced from workshop rounds 1–2. D-records pending registration in decisions/ domain files by Qatux. New tickets A–G pending SI formalization.*