feat(perception): anomaly detection and recognition monologue during delay

Add AnomalyMarker component and detect_anomalies() system that flags
entities with KG relationship PersonOfInterest or Contradicted state
for urgent cognitive delay (0.3s vs 0.6s normal). Add
trigger_recognition_monologue() that fires monologue at delay START
(when grey blob appears), not at completion — the monologue IS the
recognition process per D-060. Includes v0.1 fallback recognition
lines and cooldown tracking. Fixes #450, #451.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 17:41:14 +01:00
co-authored by Claude Opus 4.6
parent ed7498cb24
commit 695d2ac843
5 changed files with 745 additions and 5 deletions
+264
View File
@@ -0,0 +1,264 @@
//! Anomaly detection system (#450, D-060).
//!
//! Marks entities as anomalous when the observer's KnowledgeGraph has them as
//! PersonOfInterest or Contradicted. AnomalyMarker is a transient per-tick
//! component cleared at tick start and recomputed from KG state.
//!
//! Used by:
//! - emit_observation_events: RecognitionTrigger::Urgent for fog recognition
//! - #451 (future): monologue ObserveAnomaly trigger priority
use bevy_ecs::prelude::*;
use crate::bridge::types::RelationshipState;
use crate::knowledge::types::KnowledgeState;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::simulation::movement::PlayerCharacter;
/// Transient marker: entity flagged as anomalous this tick.
///
/// Cleared at tick start, recomputed by `detect_anomalies` each tick
/// from the observer's KnowledgeGraph. An entity is anomalous when
/// the observer knows it as PersonOfInterest or its KG state is Contradicted.
///
/// The player entity is never marked anomalous (prevents self-checks).
#[derive(Component, Debug)]
pub struct AnomalyMarker;
/// Clear all AnomalyMarker components at tick start.
///
/// System ordering: runs before detect_anomalies.
pub fn clear_anomaly_markers(mut commands: Commands, markers: Query<Entity, With<AnomalyMarker>>) {
for entity in markers.iter() {
commands.entity(entity).remove::<AnomalyMarker>();
}
}
/// Detect anomalous entities based on observer's KG state.
///
/// Marks entities as anomalous when KG.relationship == PersonOfInterest
/// OR KG.state == Contradicted. Skips the player entity.
///
/// System ordering: after clear_anomaly_markers, before emit_observation_events.
pub fn detect_anomalies(
mut commands: Commands,
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
registry: Res<EntityRegistry>,
) {
let Ok((player_entity, observer_kg)) = observer_query.single() else {
return;
};
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
let Some(entity) = registry.to_entity(stable_id) else {
continue;
};
// Don't mark Player entity as anomalous
if entity == player_entity {
continue;
}
let is_anomalous = knowledge.relationship == RelationshipState::PersonOfInterest
|| knowledge.state == KnowledgeState::Contradicted;
if is_anomalous {
commands.entity(entity).insert(AnomalyMarker);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::movement::TilePosition;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
#[test]
fn detect_marks_contradicted_entity() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
.id();
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_anomalies);
schedule.run(&mut world);
// Apply deferred commands
world.flush();
assert!(
world.get::<AnomalyMarker>(npc).is_some(),
"Contradicted entity should have AnomalyMarker"
);
}
#[test]
fn detect_marks_person_of_interest() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
let player = world
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
.id();
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_anomalies);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnomalyMarker>(npc).is_some(),
"PersonOfInterest entity should have AnomalyMarker"
);
}
#[test]
fn detect_skips_active_known_entity() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
.id();
let npc_sid = registry.register(npc);
// Active state, Known relationship — not anomalous
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
let player = world
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
.id();
registry.register(player);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_anomalies);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnomalyMarker>(npc).is_none(),
"Active/Known entity should NOT have AnomalyMarker"
);
}
#[test]
fn detect_does_not_mark_player() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(10, 10, 0),
KnowledgeGraph::new(),
))
.id();
let player_sid = registry.register(player);
// Even if player is somehow in own KG as POI, don't mark
let mut kg = world.get_mut::<KnowledgeGraph>(player).unwrap();
kg.observe_entity(player_sid, TilePosition::new(10, 10, 0), 50);
kg.set_relationship(&player_sid, RelationshipState::PersonOfInterest);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_anomalies);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnomalyMarker>(player).is_none(),
"Player entity should never be marked anomalous"
);
}
#[test]
fn clear_removes_all_markers() {
let mut world = setup_world();
// Manually add markers
let e1 = world.spawn(AnomalyMarker).id();
let e2 = world.spawn(AnomalyMarker).id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(clear_anomaly_markers);
schedule.run(&mut world);
world.flush();
assert!(world.get::<AnomalyMarker>(e1).is_none());
assert!(world.get::<AnomalyMarker>(e2).is_none());
}
#[test]
fn clear_then_detect_refreshes_markers() {
let mut world = setup_world();
let mut registry = EntityRegistry::new(0);
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
.id();
let npc_sid = registry.register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
let player = world
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
.id();
registry.register(player);
world.insert_resource(registry);
// Run clear then detect
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
clear_anomaly_markers,
detect_anomalies.after(clear_anomaly_markers),
));
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnomalyMarker>(npc).is_some(),
"Marker should be refreshed after clear+detect cycle"
);
}
}
+22
View File
@@ -69,6 +69,9 @@ pub struct PendingRecognition {
pub delay_until_tick: u64,
/// What triggered this recognition.
pub trigger: RecognitionTrigger,
/// Whether a recognition monologue has been fired for this entry (#451).
/// Set by trigger_recognition_monologue to prevent re-firing each tick.
pub monologue_fired: bool,
}
/// Component: cognitive delay buffer for entity recognition (D-060).
@@ -123,6 +126,11 @@ impl CognitiveDelay {
&self.pending
}
/// Mutable access to pending recognitions (for monologue tracking, #451).
pub fn pending_mut(&mut self) -> &mut Vec<PendingRecognition> {
&mut self.pending
}
/// Number of pending recognitions.
pub fn len(&self) -> usize {
self.pending.len()
@@ -206,6 +214,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
assert_eq!(delay.len(), 1);
@@ -225,6 +234,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let cancelled = delay.cancel(&StableId(1));
@@ -251,6 +261,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let ready = delay.drain_ready(105);
@@ -270,6 +281,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let ready = delay.drain_ready(106);
@@ -290,6 +302,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let ready = delay.drain_ready(200);
@@ -310,6 +323,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 103, // Urgent: 3 ticks from tick 100
trigger: RecognitionTrigger::Urgent,
monologue_fired: false,
});
delay.push(PendingRecognition {
target: t2,
@@ -317,6 +331,7 @@ mod tests {
position: TilePosition::new(10, 10, 0),
delay_until_tick: 106, // Normal: 6 ticks from tick 100
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
// Tick 103: only urgent should drain
@@ -345,6 +360,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
assert!(delay.is_pending(&StableId(1)));
@@ -356,6 +372,7 @@ mod tests {
position: TilePosition::new(10, 10, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
assert!(delay.is_pending(&StableId(1)));
@@ -379,6 +396,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((KnowledgeGraph::new(), cd));
@@ -413,6 +431,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((KnowledgeGraph::new(), cd));
@@ -449,6 +468,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 103, // Urgent
trigger: RecognitionTrigger::Urgent,
monologue_fired: false,
});
cd.push(PendingRecognition {
target: t2,
@@ -456,6 +476,7 @@ mod tests {
position: TilePosition::new(10, 10, 0),
delay_until_tick: 106, // Normal
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
world.spawn((KnowledgeGraph::new(), cd));
@@ -502,6 +523,7 @@ mod tests {
position: TilePosition::new(5, 5, 0),
delay_until_tick: 106,
trigger: RecognitionTrigger::Normal,
monologue_fired: false,
});
let observer = world.spawn((KnowledgeGraph::new(), cd)).id();
+5
View File
@@ -5,6 +5,7 @@
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod anomaly;
pub mod cognitive_delay;
pub mod interpretation;
pub mod observation;
@@ -25,6 +26,10 @@ impl Plugin for PerceptionPlugin {
.add_systems(
Update,
(
anomaly::clear_anomaly_markers
.before(anomaly::detect_anomalies),
anomaly::detect_anomalies
.before(observation::emit_observation_events),
cognitive_delay::process_cognitive_delay
.after(observation::emit_observation_events)
.before(crate::knowledge::events::process_knowledge_events),
+13 -5
View File
@@ -28,6 +28,7 @@ pub fn emit_observation_events(
>,
mut event_queue: ResMut<KnowledgeEventQueue>,
entity_positions: Query<&TilePosition>,
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
) {
let Some(snapshot) = &buffer.snapshot else {
return;
@@ -78,22 +79,29 @@ pub fn emit_observation_events(
} else if let Some(ref mut delay) = cognitive_delay {
// New entity + cognitive delay available: buffer recognition
if !delay.is_pending(&stable_id) {
// TODO(#450): wire RecognitionTrigger::Urgent for observe_anomaly triggers
let trigger = RecognitionTrigger::Normal;
// #450: Urgent trigger for anomalous entities (D-060)
let trigger = if anomaly_markers.get(entity).is_ok() {
RecognitionTrigger::Urgent
} else {
RecognitionTrigger::Normal
};
let delay_until = time.tick + trigger.delay_ticks();
delay.push(PendingRecognition {
target: entity,
stable_id,
position: *pos,
delay_until_tick: time.tick + trigger.delay_ticks(),
delay_until_tick: delay_until,
trigger,
monologue_fired: false,
});
tracing::debug!(
"Cognitive delay queued: stable_id={}, position=({},{},{}), delay_until={}",
"Cognitive delay queued: stable_id={}, position=({},{},{}), trigger={:?}, delay_until={}",
stable_id.0,
pos.x,
pos.y,
pos.z,
time.tick + trigger.delay_ticks(),
trigger,
delay_until,
);
}
} else {