feat(simulation): knowledge grant schema, events, and contradiction detection (#545, #546, #547)

KnowledgeGrant untagged enum (Fact + Entity variants), ContentEntityRegistry
resource, KnowledgeGranted event processing, ContradictionClaim struct with
600-tick window detection in observe_entity. Wires knowledge_grant field in
dialogue line selection. Implements D-079, D-083. Closes Q-026.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:14:53 +01:00
co-authored by Claude Opus 4.6
parent 381526ff74
commit 3d636fd4bb
13 changed files with 1121 additions and 12 deletions
+2
View File
@@ -127,6 +127,7 @@ pub fn check_contraband_scan(
source: KnowledgeSource::DirectObservation { tick: time.tick },
state: KnowledgeState::Active,
acquired_tick: time.tick,
disclosure_blocked: false,
},
);
@@ -463,6 +464,7 @@ mod tests {
source: KnowledgeSource::DirectObservation { tick: 0 },
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
);
+119 -2
View File
@@ -25,7 +25,11 @@ use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, Npc
use crate::content::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::content::types::KnowledgeGrant;
use crate::content::LinePoolIndexResource;
use crate::knowledge::content_registry::ContentEntityRegistry;
use crate::knowledge::events::{ProcessedEntityGrant, ProcessedFactGrant, ProcessedKnowledgeGrant};
use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, StableId};
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory};
use crate::npc::relationships::{TrustEvent, TrustEventQueue};
@@ -422,6 +426,7 @@ pub fn process_talk_interaction(
time: Res<SimulationTime>,
line_pool: Option<Res<LinePoolIndexResource>>,
registry: Res<EntityRegistry>,
content_registry: Res<ContentEntityRegistry>,
mut rng: ResMut<SimRng>,
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
mut trust_queue: ResMut<TrustEventQueue>,
@@ -442,6 +447,7 @@ pub fn process_talk_interaction(
Option<&mut InteractionMemory>,
Option<&NpcName>,
Option<&NpcColorIndex>,
Option<&KnowledgeGraph>,
)>,
) {
let Some(line_pool) = line_pool else {
@@ -462,8 +468,8 @@ pub fn process_talk_interaction(
let target = talk_request.target;
// Look up NPC dialogue profile, mood, interaction history, name, and color (#325)
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt)) =
// Look up NPC dialogue profile, mood, interaction history, name, color, and KG (#325, D-079)
let Ok((profile, mood_opt, mut interaction_mem_opt, npc_name_opt, color_idx_opt, npc_kg_opt)) =
npc_query.get_mut(target)
else {
tracing::debug!(
@@ -541,6 +547,19 @@ pub fn process_talk_interaction(
cooldown.record(&line.id, time.tick);
// Knowledge grant (D-079): fire at line selection time, server-authoritative.
if let Some(grant) = &line.knowledge_grant {
emit_knowledge_grant(
grant,
player_entity,
speaker_stable,
&content_registry,
npc_kg_opt,
time.tick,
&mut event_queue,
);
}
// Emit IncompleteInteraction if overwriting an existing dialogue session
if let Some(prev) = active_dialogue_opt {
event_queue.push(crate::knowledge::KnowledgeEvent {
@@ -594,6 +613,102 @@ pub fn process_talk_interaction(
commands.entity(player_entity).remove::<TalkRequest>();
}
// ---------------------------------------------------------------------------
// Knowledge grant helper (D-079)
// ---------------------------------------------------------------------------
/// Emit a KnowledgeGranted event for a dialogue line's knowledge_grant field.
///
/// Called at line selection time (server-authoritative, tick-stamped).
/// Source is always `ToldBy { source_id: speaker_stable, tick }`.
///
/// Fact grants: dropped with tracing::warn! if the granting NPC's KG
/// does not contain the fact (D-079 runtime guardrail).
/// Entity grants: no guardrail — always emitted if entity_ref resolves.
#[allow(clippy::too_many_arguments)]
fn emit_knowledge_grant(
grant: &KnowledgeGrant,
player_entity: Entity,
speaker_stable: StableId,
content_registry: &ContentEntityRegistry,
npc_kg_opt: Option<&KnowledgeGraph>,
tick: u64,
event_queue: &mut crate::knowledge::KnowledgeEventQueue,
) {
let source = KnowledgeSource::ToldBy {
source_id: speaker_stable,
tick,
};
match grant {
KnowledgeGrant::Fact { fact_id, confidence } => {
let conf = match KnowledgeConfidence::try_from(confidence.as_str()) {
Ok(c) => c,
Err(e) => {
tracing::warn!("KnowledgeGrant confidence parse error: {}", e);
return;
}
};
let fid = FactId(fact_id.clone());
// Guardrail: NPC must know this fact to grant it (D-079).
let npc_knows = npc_kg_opt
.map(|kg| kg.knows_fact(&fid))
.unwrap_or(false);
if !npc_knows {
tracing::warn!(
"KnowledgeGrant dropped: NPC {:?} does not know fact '{}' — grant guardrail",
speaker_stable,
fact_id
);
return;
}
event_queue.push(crate::knowledge::KnowledgeEvent {
observer: player_entity,
tick,
event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted {
grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant {
fact_id: fid,
confidence: conf,
}),
source,
},
});
}
KnowledgeGrant::Entity {
entity_ref,
attributes,
confidence,
} => {
let conf = match KnowledgeConfidence::try_from(confidence.as_str()) {
Ok(c) => c,
Err(e) => {
tracing::warn!("KnowledgeGrant confidence parse error: {}", e);
return;
}
};
let Some(target_id) = content_registry.resolve(entity_ref) else {
tracing::warn!(
"KnowledgeGrant::Entity dropped: entity_ref '{}' not in ContentEntityRegistry",
entity_ref
);
return;
};
event_queue.push(crate::knowledge::KnowledgeEvent {
observer: player_entity,
tick,
event_type: crate::knowledge::KnowledgeEventType::KnowledgeGranted {
grant: ProcessedKnowledgeGrant::Entity(ProcessedEntityGrant {
target_id,
attributes: attributes.clone(),
confidence: conf,
}),
source,
},
});
}
}
}
// ---------------------------------------------------------------------------
// System: process_walk_away (D-064)
// ---------------------------------------------------------------------------
@@ -1330,6 +1445,7 @@ mod tests {
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world.init_resource::<EntityRegistry>();
world.init_resource::<ContentEntityRegistry>();
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world.init_resource::<TrustEventQueue>();
world.init_resource::<crate::simulation::monologue::PostConversationQueue>();
@@ -1828,6 +1944,7 @@ mod tests {
let mut world = setup_dialogue_world();
world.init_resource::<KnowledgeEventQueue>();
world.init_resource::<crate::knowledge::ContradictionDetectedQueue>();
let npc = world.spawn_empty().id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);