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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user