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
+415 -5
View File
@@ -108,7 +108,46 @@ impl KnowledgeGraph {
// --- Write Operations ---
/// Record a direct observation of another entity (entity is in LOS).
pub fn observe_entity(&mut self, target: StableId, position: TilePosition, tick: u64) {
///
/// Returns `Some(ContradictionClaim)` if a position contradiction was
/// detected against a recent `ToldBy` source (D-083). The caller should
/// push a `ContradictionDetected` event when this returns `Some`.
pub fn observe_entity(
&mut self,
target: StableId,
position: TilePosition,
tick: u64,
) -> Option<ContradictionClaim> {
// --- Pre-overwrite contradiction check (D-083) ---
//
// If the existing entry has a ToldBy source with a different position,
// and the told-tick is within CONTRADICTION_WINDOW_TICKS of now,
// this is a contradiction: someone lied or was wrong about where
// this entity would be.
let contradiction = self.entities.get(&target).and_then(|existing| {
if let KnowledgeSource::ToldBy {
source_id,
tick: told_tick,
} = &existing.source
{
let age = tick.saturating_sub(*told_tick);
let claimed_pos = existing.last_known_position?;
if age <= CONTRADICTION_WINDOW_TICKS && claimed_pos != position {
Some(ContradictionClaim {
told_by: *source_id,
told_tick: *told_tick,
claimed_position: claimed_pos,
observed_position: position,
detected_tick: tick,
})
} else {
None
}
} else {
None
}
});
let entry = self
.entities
.entry(target)
@@ -121,18 +160,25 @@ impl KnowledgeGraph {
state: KnowledgeState::Active,
relationship: RelationshipState::Unknown,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
});
entry.last_known_position = Some(position);
entry.last_observed_tick = tick;
entry.last_updated_tick = tick;
entry.confidence = KnowledgeConfidence::Direct;
entry.source = KnowledgeSource::DirectObservation { tick };
// Stale entries become Active again on fresh observation.
// Contradicted entries stay Contradicted even if you're looking
// at the entity right now — the contradiction is still unresolved.
if entry.state == KnowledgeState::Stale {
if contradiction.is_some() {
entry.state = KnowledgeState::Contradicted;
entry.contradicted_claim = contradiction.clone();
} else if entry.state == KnowledgeState::Stale {
// Stale entries become Active again on fresh observation.
entry.state = KnowledgeState::Active;
}
// Contradicted entries without a new contradiction stay Contradicted —
// the previous contradiction is still unresolved.
contradiction
}
/// Entity has left the observer's LOS. Downgrade from Direct.
@@ -168,6 +214,7 @@ impl KnowledgeGraph {
state: KnowledgeState::Active,
relationship: RelationshipState::Unknown,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
});
let type_str = match interaction_type {
@@ -387,6 +434,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
};
let g = KnowledgeGraph::with_background(vec![(fact_id.clone(), fact)]);
@@ -513,6 +561,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
),
(
@@ -522,6 +571,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
),
];
@@ -626,6 +676,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
)]);
@@ -648,6 +699,7 @@ mod tests {
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 0,
disclosure_blocked: false,
},
)]);
@@ -746,4 +798,362 @@ mod tests {
"FactionOnly must pass when observer knows the matching faction_id"
);
}
// --- Contradiction detection tests (D-083, #547) ---
#[test]
fn contradiction_detected_when_told_by_position_differs() {
let mut g = KnowledgeGraph::new();
let target = StableId(1);
let informant = StableId(99);
// Someone told us the target is at (10, 10) at tick 100
g.entities.insert(
target,
EntityKnowledge {
last_known_position: Some(make_position(10, 10)),
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
// Direct observation at (15, 10) at tick 200 — within window (600 ticks)
let result = g.observe_entity(target, make_position(15, 10), 200);
// Contradiction should be detected
assert!(result.is_some(), "Should detect contradiction");
let claim = result.unwrap();
assert_eq!(claim.told_by, informant);
assert_eq!(claim.told_tick, 100);
assert_eq!(claim.claimed_position, make_position(10, 10));
assert_eq!(claim.observed_position, make_position(15, 10));
assert_eq!(claim.detected_tick, 200);
// Entry should be Contradicted
let entry = g.entity_knowledge(&target).unwrap();
assert_eq!(entry.state, KnowledgeState::Contradicted);
assert!(entry.contradicted_claim.is_some());
// But confidence is upgraded to Direct (we're looking at them)
assert_eq!(entry.confidence, KnowledgeConfidence::Direct);
}
#[test]
fn no_contradiction_when_told_by_position_matches() {
let mut g = KnowledgeGraph::new();
let target = StableId(1);
let informant = StableId(99);
// Told target is at (10, 10)
g.entities.insert(
target,
EntityKnowledge {
last_known_position: Some(make_position(10, 10)),
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
// Observe at SAME position — no contradiction
let result = g.observe_entity(target, make_position(10, 10), 200);
assert!(result.is_none(), "Same position should not be a contradiction");
let entry = g.entity_knowledge(&target).unwrap();
assert_eq!(entry.state, KnowledgeState::Active);
assert!(entry.contradicted_claim.is_none());
}
#[test]
fn no_contradiction_outside_time_window() {
let mut g = KnowledgeGraph::new();
let target = StableId(1);
let informant = StableId(99);
// Told at tick 100, position (10, 10)
g.entities.insert(
target,
EntityKnowledge {
last_known_position: Some(make_position(10, 10)),
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
// Observe at different position BUT outside window (100 + 601 = 701)
let result = g.observe_entity(target, make_position(15, 10), 701);
assert!(
result.is_none(),
"Outside CONTRADICTION_WINDOW_TICKS should not trigger contradiction"
);
}
#[test]
fn contradiction_at_exact_window_boundary() {
let mut g = KnowledgeGraph::new();
let target = StableId(1);
let informant = StableId(99);
g.entities.insert(
target,
EntityKnowledge {
last_known_position: Some(make_position(10, 10)),
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
// Exactly at window boundary: 100 + 600 = 700 (age == CONTRADICTION_WINDOW_TICKS)
let result = g.observe_entity(target, make_position(15, 10), 700);
assert!(
result.is_some(),
"Exactly at window boundary (age == 600) should still detect contradiction"
);
}
#[test]
fn no_contradiction_for_direct_observation_source() {
let mut g = KnowledgeGraph::new();
let target = StableId(1);
// Previous knowledge from DirectObservation (not ToldBy)
g.observe_entity(target, make_position(10, 10), 100);
// New observation at different position — NOT a contradiction
// (we just moved, or they moved; no one lied)
let result = g.observe_entity(target, make_position(15, 10), 200);
assert!(
result.is_none(),
"DirectObservation source should never trigger contradiction"
);
let entry = g.entity_knowledge(&target).unwrap();
assert_eq!(entry.state, KnowledgeState::Active);
}
#[test]
fn no_contradiction_for_background_source() {
let mut g = KnowledgeGraph::new();
let target = StableId(1);
// Background knowledge with a position
g.entities.insert(
target,
EntityKnowledge {
last_known_position: Some(make_position(10, 10)),
last_observed_tick: 0,
last_updated_tick: 0,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
let result = g.observe_entity(target, make_position(15, 10), 100);
assert!(
result.is_none(),
"Background source should not trigger contradiction"
);
}
#[test]
fn no_contradiction_when_told_by_has_no_position() {
let mut g = KnowledgeGraph::new();
let target = StableId(1);
let informant = StableId(99);
// ToldBy but no position was claimed
g.entities.insert(
target,
EntityKnowledge {
last_known_position: None, // no position claimed
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
let result = g.observe_entity(target, make_position(15, 10), 200);
assert!(
result.is_none(),
"ToldBy without position should not trigger contradiction"
);
}
#[test]
fn contradicted_claim_entry_field_matches_returned_claim() {
// Verify that entry.contradicted_claim is populated with identical
// data to the ContradictionClaim returned by observe_entity (D-083).
let mut g = KnowledgeGraph::new();
let target = StableId(1);
let informant = StableId(42);
g.entities.insert(
target,
EntityKnowledge {
last_known_position: Some(make_position(5, 5)),
last_observed_tick: 0,
last_updated_tick: 50,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant,
tick: 50,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
let returned = g
.observe_entity(target, make_position(12, 5), 300)
.expect("contradiction should be detected");
let entry = g.entity_knowledge(&target).unwrap();
let stored = entry.contradicted_claim.as_ref().expect("field should be populated");
assert_eq!(stored.told_by, returned.told_by);
assert_eq!(stored.told_tick, returned.told_tick);
assert_eq!(stored.claimed_position, returned.claimed_position);
assert_eq!(stored.observed_position, returned.observed_position);
assert_eq!(stored.detected_tick, returned.detected_tick);
}
#[test]
fn second_observation_keeps_contradicted_state_when_no_new_told_by() {
// After a contradiction is detected, subsequent DirectObservation
// does NOT clear the Contradicted state (D-083: "unresolved").
let mut g = KnowledgeGraph::new();
let target = StableId(7);
let informant = StableId(8);
// Set up ToldBy knowledge
g.entities.insert(
target,
EntityKnowledge {
last_known_position: Some(make_position(3, 3)),
last_observed_tick: 0,
last_updated_tick: 10,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant,
tick: 10,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
// First observation: contradiction detected
let claim = g.observe_entity(target, make_position(9, 3), 200);
assert!(claim.is_some(), "contradiction should fire");
assert_eq!(
g.entity_knowledge(&target).unwrap().state,
KnowledgeState::Contradicted
);
// Second observation (now source is DirectObservation, different position):
// state must stay Contradicted — contradiction is still unresolved.
let claim2 = g.observe_entity(target, make_position(11, 3), 300);
assert!(claim2.is_none(), "no new contradiction: DirectObservation source");
assert_eq!(
g.entity_knowledge(&target).unwrap().state,
KnowledgeState::Contradicted,
"Contradicted state must persist until explicitly resolved"
);
}
#[test]
fn multiple_entities_only_told_by_one_contradicts() {
// Edge case: observer knows two entities.
// Entity A has ToldBy source, Entity B has DirectObservation.
// Only Entity A should produce a contradiction.
let mut g = KnowledgeGraph::new();
let entity_a = StableId(10);
let entity_b = StableId(20);
let informant = StableId(99);
// Entity A: ToldBy with position
g.entities.insert(
entity_a,
EntityKnowledge {
last_known_position: Some(make_position(1, 1)),
last_observed_tick: 0,
last_updated_tick: 100,
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::ToldBy {
source_id: informant,
tick: 100,
},
state: KnowledgeState::Active,
relationship: RelationshipState::Known,
known_attributes: BTreeMap::new(),
contradicted_claim: None,
},
);
// Entity B: DirectObservation (no informant to lie)
g.observe_entity(entity_b, make_position(5, 5), 100);
// Observe both at different positions at tick 200
let a_result = g.observe_entity(entity_a, make_position(8, 1), 200);
let b_result = g.observe_entity(entity_b, make_position(9, 5), 200);
assert!(a_result.is_some(), "Entity A (ToldBy source) should contradict");
assert!(b_result.is_none(), "Entity B (DirectObservation) should not contradict");
assert_eq!(
g.entity_knowledge(&entity_a).unwrap().state,
KnowledgeState::Contradicted
);
assert_eq!(
g.entity_knowledge(&entity_b).unwrap().state,
KnowledgeState::Active
);
}
}