Skip to main content

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}