ixa/triggers/property_value_count.rs
1//! Trigger criterion for the count of entities with a particular property value.
2//!
3//! [`PropertyValueCountTrigger`] observes
4//! [`EntityCreatedEvent`](crate::entity::events::EntityCreatedEvent) and
5//! [`PropertyChangeEvent`](crate::entity::events::PropertyChangeEvent) for a specific
6//! entity/property pair and emits when the count of entities with a configured property value
7//! crosses a configured threshold.
8//!
9//! ## Construction
10//!
11//! ```rust,ignore
12//! PropertyValueCountTrigger::<E, P>::increases_to(value, threshold)
13//! PropertyValueCountTrigger::<E, P>::decreases_to(value, threshold)
14//! PropertyValueCountTrigger::<E, P>::changes_to(value, threshold)
15//! PropertyValueCountTrigger::<E, P>::changes_to(value, threshold).once()
16//! PropertyValueCountTrigger::<E, P>::changes_to(value, threshold).repeating()
17//! ```
18//!
19//! ## Observation
20//!
21//! The observation data passed to
22//! [`TriggerCriterion::emit_with`](super::TriggerCriterion::emit_with) is
23//! [`PropertyValueCountTriggerEvent`]. It contains the entity ID whose creation or property write
24//! caused the crossing, the tracked property value, the new count, the observed
25//! [`Direction`](super::Direction), the configured direction filter as `Option<Direction>`, and the
26//! selected [`TriggerMode`](super::TriggerMode):
27//!
28//! ```rust,ignore
29//! pub struct PropertyValueCountTriggerEvent<E, P>
30//! where
31//! E: Entity,
32//! P: Property<E>,
33//! {
34//! pub entity_id: EntityId<E>,
35//! pub value: P,
36//! pub count: usize,
37//! pub direction_filter: Option<Direction>,
38//! pub direction: Direction,
39//! pub mode: TriggerMode,
40//! }
41//! ```
42//!
43//! ## Semantics
44//!
45//! The initial count is measured when the trigger is registered. The criterion emits only on a
46//! later threshold crossing. Since counts change one entity at a time, a crossing occurs when the
47//! new count equals the threshold and differs from the previous count. [`Direction::Increasing`]
48//! means the count increased to the threshold, while [`Direction::Decreasing`] means the count
49//! decreased to the threshold. `changes_to` leaves the direction filter unset and emits for either
50//! observed direction. `increases_to` and `decreases_to` set the direction filter to the
51//! corresponding observed direction.
52//!
53//! By default, the criterion uses [`TriggerMode::Repeating`](super::TriggerMode::Repeating) and
54//! emits every time the count crosses the threshold and passes the configured direction filter. Call
55//! [`PropertyValueCountTrigger::once`] to emit only for the first crossing, or
56//! [`PropertyValueCountTrigger::repeating`] to return to the default repeating behavior.
57//!
58//! Entity creation can cause a crossing if the new entity has the tracked value. Property writes
59//! can cause a crossing when they move an entity into or out of the tracked value. A no-op write
60//! where `previous == current` still emits a property-change event at the entity layer, but it does
61//! not change this trigger's tracked count and therefore cannot by itself cross the threshold.
62//!
63//! ## Example
64//!
65//! ```rust
66//! use ixa::{Context, ContextEntitiesExt, define_entity, define_property, IxaEvent};
67//! use ixa::entity::EntityId;
68//! use ixa::triggers::{
69//! ContextTriggersExt, Direction, PropertyValueCountTrigger, TriggerCriterion, TriggerMode,
70//! };
71//!
72//! define_entity!(Person);
73//! define_property!(
74//! enum InfectionStatus {
75//! Susceptible,
76//! Infectious,
77//! },
78//! Person,
79//! default_const = InfectionStatus::Susceptible
80//! );
81//!
82//! // The event records which person caused us to reach the threshold and
83//! // the value of the threshold itself (as `count`).
84//! #[derive(IxaEvent)]
85//! struct InfectiousThresholdReached {
86//! person: EntityId<Person>,
87//! count: usize
88//! }
89//!
90//! let mut context = Context::new();
91//!
92//! context.register_trigger(
93//! PropertyValueCountTrigger::increases_to(
94//! InfectionStatus::Infectious,
95//! 2,
96//! ).emit_with(|observation| InfectiousThresholdReached {
97//! person: observation.entity_id,
98//! count: observation.count
99//! }),
100//! );
101//!
102//! context.subscribe_to_event(|_context, _event: InfectiousThresholdReached| {
103//! // respond when the infectious count crosses from below 2 to at least 2
104//! });
105//! ```
106
107use std::cell::Cell;
108use std::marker::PhantomData;
109use std::rc::Rc;
110
111use super::{Direction, TriggerCriterion, TriggerMode};
112use crate::entity::events::{EntityCreatedEvent, PropertyChangeEvent};
113use crate::entity::property::Property;
114use crate::entity::{ContextEntitiesExt, Entity, EntityId};
115use crate::{Context, EntityPropertyTuple};
116
117pub struct PropertyValueCountTrigger<E, P>
118where
119 E: Entity,
120 P: Property<E>,
121{
122 value: P,
123 threshold: usize,
124 direction_filter: Option<Direction>,
125 mode: TriggerMode,
126 _entity: PhantomData<fn() -> E>,
127}
128
129#[derive(Clone, Copy, Debug)]
130pub struct PropertyValueCountTriggerEvent<E, P>
131where
132 E: Entity,
133 P: Property<E>,
134{
135 pub entity_id: EntityId<E>,
136 pub value: P,
137 pub count: usize,
138 pub direction_filter: Option<Direction>,
139 pub direction: Direction,
140 pub mode: TriggerMode,
141}
142
143impl<E, P> PropertyValueCountTrigger<E, P>
144where
145 E: Entity,
146 P: Property<E>,
147{
148 #[must_use]
149 pub fn increases_to(value: P, threshold: usize) -> Self {
150 Self {
151 value,
152 threshold,
153 direction_filter: Some(Direction::Increasing),
154 mode: TriggerMode::Repeating,
155 _entity: PhantomData,
156 }
157 }
158
159 #[must_use]
160 pub fn decreases_to(value: P, threshold: usize) -> Self {
161 Self {
162 value,
163 threshold,
164 direction_filter: Some(Direction::Decreasing),
165 mode: TriggerMode::Repeating,
166 _entity: PhantomData,
167 }
168 }
169
170 #[must_use]
171 pub fn changes_to(value: P, threshold: usize) -> Self {
172 Self {
173 value,
174 threshold,
175 direction_filter: None,
176 mode: TriggerMode::Repeating,
177 _entity: PhantomData,
178 }
179 }
180
181 #[must_use]
182 pub fn once(mut self) -> Self {
183 self.mode = TriggerMode::Once;
184 self
185 }
186
187 #[must_use]
188 pub fn repeating(mut self) -> Self {
189 self.mode = TriggerMode::Repeating;
190 self
191 }
192}
193
194impl<E, P> TriggerCriterion for PropertyValueCountTrigger<E, P>
195where
196 E: Entity,
197 P: Property<E>,
198{
199 type Observation = PropertyValueCountTriggerEvent<E, P>;
200
201 fn install<F>(self, context: &mut Context, on_match: F)
202 where
203 F: Fn(&mut Context, Self::Observation) + 'static,
204 {
205 match self.mode {
206 TriggerMode::Once => {
207 let state = Rc::new(Cell::new(CountTriggerState {
208 active: true,
209 count: context
210 .query_entity_count(EntityPropertyTuple::<E, _>::new((self.value,))),
211 }));
212 let on_match = Rc::new(on_match);
213
214 context.subscribe_to_event({
215 let state = Rc::clone(&state);
216 let on_match = Rc::clone(&on_match);
217 move |context, event: EntityCreatedEvent<E>| {
218 let current = context.get_property::<E, P>(event.entity_id);
219 if current == self.value {
220 let mut state_value = state.get();
221 if !state_value.active {
222 return;
223 }
224 let previous_count = state_value.count;
225 state_value.count += 1;
226 let direction = Direction::Increasing;
227 if self.direction_filter != Some(Direction::Decreasing) {
228 let threshold_crossed = state_value.count == self.threshold
229 && previous_count != state_value.count
230 && self
231 .direction_filter
232 .is_none_or(|filter| filter == direction);
233 if threshold_crossed {
234 on_match(
235 context,
236 PropertyValueCountTriggerEvent {
237 entity_id: event.entity_id,
238 value: self.value,
239 count: state_value.count,
240 direction_filter: self.direction_filter,
241 direction,
242 mode: self.mode,
243 },
244 );
245 state_value.active = false;
246 }
247 }
248 state.set(state_value);
249 }
250 }
251 });
252
253 context.subscribe_to_event({
254 let state = Rc::clone(&state);
255 let on_match = Rc::clone(&on_match);
256 move |context, event: PropertyChangeEvent<E, P>| {
257 let mut state_value = state.get();
258 if !state_value.active {
259 return;
260 }
261 let previous_count = state_value.count;
262 state_value.count =
263 match (event.previous == self.value, event.current == self.value) {
264 (false, true) => state_value.count + 1,
265 (true, false) => state_value.count - 1,
266 _ => state_value.count,
267 };
268 let direction = if state_value.count > previous_count {
269 Some(Direction::Increasing)
270 } else if state_value.count < previous_count {
271 Some(Direction::Decreasing)
272 } else {
273 None
274 };
275 if let Some(direction) = direction {
276 let threshold_crossed = state_value.count == self.threshold
277 && self
278 .direction_filter
279 .is_none_or(|filter| filter == direction);
280 if threshold_crossed {
281 on_match(
282 context,
283 PropertyValueCountTriggerEvent {
284 entity_id: event.entity_id,
285 value: self.value,
286 count: state_value.count,
287 direction_filter: self.direction_filter,
288 direction,
289 mode: self.mode,
290 },
291 );
292 state_value.active = false;
293 }
294 }
295 state.set(state_value);
296 }
297 });
298 }
299 TriggerMode::Repeating => {
300 let count = Rc::new(Cell::new(
301 context.query_entity_count(EntityPropertyTuple::<E, _>::new((self.value,))),
302 ));
303 let on_match = Rc::new(on_match);
304
305 context.subscribe_to_event({
306 let count = Rc::clone(&count);
307 let on_match = Rc::clone(&on_match);
308 move |context, event: EntityCreatedEvent<E>| {
309 let current = context.get_property::<E, P>(event.entity_id);
310 if current == self.value {
311 let previous_count = count.get();
312 let current_count = previous_count + 1;
313 count.set(current_count);
314 let direction = Direction::Increasing;
315 if self.direction_filter != Some(Direction::Decreasing) {
316 let threshold_crossed = current_count == self.threshold
317 && previous_count != current_count
318 && self
319 .direction_filter
320 .is_none_or(|filter| filter == direction);
321 if threshold_crossed {
322 on_match(
323 context,
324 PropertyValueCountTriggerEvent {
325 entity_id: event.entity_id,
326 value: self.value,
327 count: current_count,
328 direction_filter: self.direction_filter,
329 direction,
330 mode: self.mode,
331 },
332 );
333 }
334 }
335 }
336 }
337 });
338
339 context.subscribe_to_event({
340 let count = Rc::clone(&count);
341 let on_match = Rc::clone(&on_match);
342 move |context, event: PropertyChangeEvent<E, P>| {
343 let previous_count = count.get();
344 let current_count =
345 match (event.previous == self.value, event.current == self.value) {
346 (false, true) => previous_count + 1,
347 (true, false) => previous_count - 1,
348 _ => previous_count,
349 };
350 count.set(current_count);
351 let direction = if current_count > previous_count {
352 Some(Direction::Increasing)
353 } else if current_count < previous_count {
354 Some(Direction::Decreasing)
355 } else {
356 None
357 };
358 if let Some(direction) = direction {
359 let threshold_crossed = current_count == self.threshold
360 && self
361 .direction_filter
362 .is_none_or(|filter| filter == direction);
363 if threshold_crossed {
364 on_match(
365 context,
366 PropertyValueCountTriggerEvent {
367 entity_id: event.entity_id,
368 value: self.value,
369 count: current_count,
370 direction_filter: self.direction_filter,
371 direction,
372 mode: self.mode,
373 },
374 );
375 }
376 }
377 }
378 });
379 }
380 }
381 }
382}
383
384#[derive(Clone, Copy)]
385struct CountTriggerState {
386 active: bool,
387 count: usize,
388}