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