Workshop outputs from Knowledge Flow & NPC Information Boundaries workshop (2026-02-24): Decisions registered in decisions/perception.md: - D-079: Knowledge Grant Architecture (unified KnowledgeGranted event, Fact+Entity enum, ContentEntityRegistry) - D-080: NPC-to-NPC Knowledge Propagation (transfer_npc_knowledge system, trust-tier gating, ToldBy source) - D-081: Unprompted Disclosure Design (DisclosureCandidates component, two-stage trait filter, trigger gates) - D-082: NPC Information Boundaries MVP Scope (tell_state + disclosure; pathfinding KG integration explicitly not planned) - D-083: Contradiction Detection Pipeline (event-driven at observe_entity, ContradictionClaim struct, downstream chain) Questions closed in decisions/questions.md: - Q-024: closed by D-080 (immediate during conversation, not queued) - Q-025: confirmed closed (gossip propagation ships Sprint 17; ~12MB peak, no cap needed before v0.3) - Q-026: closed by D-083 (event-driven at KG write time, ContradictionClaim struct) Tickets created (#545–#551, Sprint 17, team server): - #545 KnowledgeGrant schema + ContentEntityRegistry (critical, blocks all downstream) - #546 KnowledgeGranted event + process_knowledge_events handler - #547 ContradictionClaim struct + detection in observe_entity - #548 NPC-to-NPC knowledge transfer system - #549 tell_state.rs KG awareness — MVP information boundary - #550 Contradiction monologue + event chain completion - #551 DisclosureCandidates + unprompted disclosure Existing tickets updated: #141, #142 (sprint 17 + team assigned, descriptions updated); #172, #173 (full mechanical spec from D-081). Workshop source files committed: all round docs + workshop-outcomes.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
471 lines
29 KiB
Markdown
471 lines
29 KiB
Markdown
# 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<String, StableId>,
|
|
}
|
|
|
|
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<StableId> {
|
|
self.names.get(name).copied()
|
|
}
|
|
}
|
|
```
|
|
|
|
This is a `BTreeMap<String, StableId>` (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<String>,
|
|
#[serde(default)]
|
|
attributes: BTreeMap<String, String>,
|
|
confidence: String,
|
|
},
|
|
Compound {
|
|
grants: Vec<KnowledgeGrant>,
|
|
},
|
|
}
|
|
```
|
|
|
|
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<ContradictionClaim>` on `EntityKnowledge`
|
|
2. **Gestalt (Round 1):** `contradiction_basis: Option<ContradictionBasis>` -- 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<ContradictionClaim>` 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<TilePosition>,
|
|
/// 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<ContradictionClaim>,
|
|
}
|
|
```
|
|
|
|
### 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<T>` 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<KnowledgeGrant> }` -- 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<ActiveSim>`. 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<TilePosition>,
|
|
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. |
|