Standardized YAML frontmatter on all 12 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
29 KiB
title, description, type, status, workshop, agent, round, created
| title | description | type | status | workshop | agent | round | created |
|---|---|---|---|---|---|---|---|
| Round 2: Tyre — Cross-Review and Synthesis | Tyre's Round 2 cross-review resolving entity grants for Sprint 17 via ContentEntityRegistry and sizing the implementation | workshop | archived | knowledge-flow-npc-boundaries | tyre | 2 | 2026-02-23 |
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:
- Sera's dialogue grants
FactId("entity.kael.location.second_shift_dock")-- this creates aFactKnowledgeentry inKnowledgeGraph.facts. - Player observes Kael in B-7 --
observe_entity(kael_sid, b7_pos, tick)updatesKnowledgeGraph.entities[kael_sid]. - Contradiction detection fires on
entities[kael_sid]write. It checks the existingEntityKnowledge.sourcefor aToldByvariant. There is none -- the ToldBy information is infacts, notentities. 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:
/// 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:
# 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:
// 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:
- Tyre (Round 1):
contradicted_claim: Option<ContradictionClaim>onEntityKnowledge - Gestalt (Round 1):
contradiction_basis: Option<ContradictionBasis>-- structurally identical, different name - 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:
-
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 everyobserve_entitycall means regex or split-based parsing in a performance-sensitive path. The typed struct is a direct field access -- zero parsing. -
No type safety. A typo in the key format (
"claim.1234.positon") silently breaks detection. The typed struct is checked at compile time. -
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). -
Creates a Sprint 18 refactor obligation. Dudley acknowledges this. The refactor touches every system that reads/writes
known_attributes, every test that constructsEntityKnowledge, and the serialization format. The clean struct approach has zero refactor debt.
The typed struct approach:
-
One new optional field.
Option<ContradictionClaim>isNonefor 99%+ of entries. Serde with#[serde(skip_serializing_if = "Option::is_none")]means zero wire overhead for non-contradicted entries. -
Direct field access.
entry.contradicted_claim.as_ref().map(|c| c.position)-- no parsing, no string splitting. -
Self-documenting. The struct fields tell you exactly what a contradiction contains.
-
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:
Claimdescribes what the struct holds: the prior claim that was contradicted. "Sera claimed Kael was at the dock."Basisis 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:
/// 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:
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:
#[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:
/// 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
ActiveSimNPCs within 3 tiles of the disclosing NPC (reuse the same proximity scan from the conversation system). If count > 0 and disclosure is trust-tierrealor above, suppress unless NPC-player trust is atsecrettier (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
ZonePrivacycomponent (or tagged via the location content data). Candidates taggedprivateonly 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:
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 aFactKnowledgeentryEntity { entity_ref, position_hint, attributes, confidence }-- creates/updates anEntityKnowledgeentry withToldBysourceCompound { 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):
- Trust tier >=
surface(NPC willing to speak to player) - Mood != Hostile
- Contentment >= -10
- Witness inhibition: if nearby NPCs present, suppress
real/secret-tier disclosures unless NPC-player trust issecrettier - Location privacy: disclosure candidates tagged
privateonly fire in private zones - Per-NPC disclosure cooldown (300 ticks / 30 game-minutes)
- 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:
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:
observe_entity()detects contradiction, setsContradicted, storesContradictionClaimdetect_anomalies(server/src/perception/anomaly.rs:44) marks entity withAnomalyMarker(fires onContradictedstate, already tested)- Monologue system selects contradiction line, references
contradicted_claim.sourceto name the source entity - Relationship system shifts
ToldBysource entity toPersonOfInterest(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. |