Skip to main content

bevy_brink/
wake_delta.rs

1//! Row-directed wake dirtying (issue #1146, the #1101 fix): *which* shared
2//! `World` cells a batch turn actually wrote, so
3//! [`mark_wake_dirty`](crate::mark_wake_dirty) can re-evaluate only the wake
4//! conditions whose **effect read row** intersects that set.
5//!
6//! Before this, the only dependency signal the wake layer had was bevy's
7//! resource-level change detection on [`BrinkGlobals`](crate::BrinkGlobals):
8//! *any* mutation re-checked *every* parked all-detect-capable policy. Every
9//! real batch turn writes bookkeeping (visit counts, turn index), so that
10//! signal fires on every frame a flow steps — enough, for a persistent
11//! policy whose condition stays true, to re-wake a flow no dependency of
12//! which moved (#1101's spurious wake; measured at ~1-in-12 sleep-suite runs
13//! before this fix).
14//!
15//! The ledger closes that: Apply records the cells it wrote
16//! ([`WorldDelta`]), and the wake pass intersects them with each condition's
17//! inferred read row (`docs/effects-spec.md` §11 — the A2/A6 row machinery
18//! consumed for scheduler precision). A `gate`-reading condition is
19//! automatically inert to a turn that only bumped a visit count.
20//!
21//! ## Attribution — why [`BrinkWorldDelta::drain`] returns an `Option`
22//!
23//! The ledger is only usable when it is a **complete** account of every
24//! change since the last drain. [`BrinkGlobals::inner`](crate::BrinkGlobals)
25//! is a public field: a host system (or a test) can write an ink global
26//! directly, and the serial driver ([`advance_flow`](crate::advance_flow))
27//! writes it without going through batch Apply at all. Neither is recorded
28//! here. Three facts together decide it, and between them every unrecorded
29//! write lands on one side or the other of the driver's own Apply:
30//!
31//! - **recorded** — a batch Apply positively recorded a turn since the last
32//!   drain. Without one (a serial-mode host, an idle frame), the ledger
33//!   explains nothing at all.
34//! - **foreign** — a batch driver observed [`BrinkGlobals`] already changed
35//!   when it *started* its turn, i.e. somebody else wrote between that
36//!   driver's previous run and this one. Sticky until the next drain. This
37//!   catches every unrecorded write that lands **before** an Apply.
38//! - **the recorded change tick** — the [`BrinkGlobals`] change tick as of
39//!   the last Apply. If the resource's live tick has moved past it, somebody
40//!   wrote **after** that Apply, which is the other side of the same window
41//!   (and the side the batch driver cannot see at all, since the wake pass
42//!   is ordered before it).
43//!
44//! [`drain`](BrinkWorldDelta::drain) hands back `Some(delta)` only when all
45//! three agree; otherwise `None`, and the wake pass falls back to the
46//! pre-#1146 coarse behavior (any change re-checks every parked policy).
47//! Over-report, never under-report — a missed wake is the engine-race bug
48//! class (`docs/decision-log.md` 2026-07-18).
49//!
50//! ## The plugin's own systems must not manufacture that signal
51//!
52//! The third fact is a plain tick comparison, so **any plugin-internal
53//! system that takes `&mut BrinkGlobals` for a read defeats it**: bevy moves
54//! the change tick on the `&mut` alone, and a write that never happened
55//! makes every window unattributable. Building a flow's context view needs
56//! `&mut` even when nothing is written, so this is easy to reintroduce. Both
57//! internal readers handle it explicitly, and a new one must too:
58//!
59//! - [`run_flow_sleep`](crate::run_flow_sleep)'s Evaluate phase snapshots
60//!   and restores the tick, then records the honest residue (an attributed
61//!   bookkeeping touch) into this ledger instead (issue #1146).
62//! - [`gc_on_turn_done`](crate::gc_on_turn_done) — armed on every
63//!   turn-completing frame once a host registers a
64//!   [`HandleKind`](crate::HandleKind) — uses `bypass_change_detection`
65//!   (issue #1632; before that fix, #1101's spurious re-wake was still live
66//!   for every handle-using host).
67
68use std::collections::BTreeSet;
69use std::marker::PhantomData;
70
71use bevy_ecs::change_detection::Tick;
72use bevy_ecs::resource::Resource;
73
74/// The set of shared-`World` cells written over one accounting window — the
75/// changed-set [`mark_wake_dirty`](crate::mark_wake_dirty) intersects each
76/// wake condition's read row against.
77///
78/// Two granularities, because that is exactly what the row can express
79/// (`brink_format::DirectEffects::reads` is "global cells this row may
80/// read"):
81///
82/// - [`globals`](Self::globals) — per-cell, by global **slot index** (the
83///   `Program::global_index` numbering `World::set_global` takes), so a
84///   condition reading `gate` is unaffected by a write to `mood`.
85/// - [`bookkeeping`](Self::touched_bookkeeping) — one coarse bit covering
86///   visit counts, turn counts, the turn index, and RNG state. Effect rows
87///   model **no** read of these (see [`FlowSleep::reads_bookkeeping`]), so
88///   there is nothing finer to intersect against.
89///
90/// [`FlowSleep::reads_bookkeeping`]: crate::FlowSleep::reads_bookkeeping
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct WorldDelta {
93    globals: BTreeSet<u32>,
94    bookkeeping: bool,
95}
96
97impl WorldDelta {
98    /// The global slot indices written over this window.
99    #[must_use]
100    pub fn globals(&self) -> &BTreeSet<u32> {
101        &self.globals
102    }
103
104    /// Whether any bookkeeping cell (visit count, turn count, turn index, RNG
105    /// state) was written over this window.
106    #[must_use]
107    pub fn touched_bookkeeping(&self) -> bool {
108        self.bookkeeping
109    }
110
111    /// Nothing was written at all — no policy can need re-evaluation on this
112    /// window's account.
113    #[must_use]
114    pub fn is_empty(&self) -> bool {
115        self.globals.is_empty() && !self.bookkeeping
116    }
117
118    /// Record a write to the global at slot `idx`.
119    pub(crate) fn note_global(&mut self, idx: u32) {
120        self.globals.insert(idx);
121    }
122
123    /// Record a write to any bookkeeping cell.
124    pub(crate) fn note_bookkeeping(&mut self) {
125        self.bookkeeping = true;
126    }
127
128    /// Fold `other`'s cells into this delta (the ledger accumulates across
129    /// every turn between two drains — a wake pass may not run every frame).
130    pub(crate) fn absorb(&mut self, other: &WorldDelta) {
131        self.globals.extend(other.globals.iter().copied());
132        self.bookkeeping |= other.bookkeeping;
133    }
134}
135
136/// The per-marker changed-cell ledger: batch Apply records into it, the wake
137/// pass drains it. See the module docs for the attribution contract.
138///
139/// Inserted by [`BrinkPlugin`](crate::BrinkPlugin), so it is always present
140/// for a marker whose plugin is installed. Growth is bounded by the story's
141/// global count plus one bool, so an app with no sleeping flows (nothing ever
142/// drains it) cannot accumulate without limit.
143#[derive(Resource, Debug)]
144pub struct BrinkWorldDelta<M: Send + Sync + 'static = ()> {
145    delta: WorldDelta,
146    recorded: bool,
147    foreign: bool,
148    /// [`BrinkGlobals`](crate::BrinkGlobals)'s change tick as of the last
149    /// recorded Apply — see the module docs' attribution contract.
150    recorded_tick: Option<Tick>,
151    _marker: PhantomData<fn() -> M>,
152}
153
154impl<M: Send + Sync + 'static> Default for BrinkWorldDelta<M> {
155    fn default() -> Self {
156        Self {
157            delta: WorldDelta::default(),
158            recorded: false,
159            foreign: false,
160            recorded_tick: None,
161            _marker: PhantomData,
162        }
163    }
164}
165
166impl<M: Send + Sync + 'static> BrinkWorldDelta<M> {
167    /// Record one batch turn's Apply changeset, plus
168    /// [`BrinkGlobals`](crate::BrinkGlobals)'s change tick as it stands at
169    /// the end of that Apply. Unions into whatever is already pending —
170    /// several turns may pass between two drains.
171    pub(crate) fn record(&mut self, delta: &WorldDelta, globals_tick: Option<Tick>) {
172        self.delta.absorb(delta);
173        self.recorded = true;
174        self.recorded_tick = globals_tick;
175    }
176
177    /// Record that the wake pass evaluated at least one condition this frame
178    /// (`run_flow_sleep`'s Evaluate phase, issue #1146).
179    ///
180    /// A purity-gated condition writes no global cell, but building its
181    /// context still takes `&mut` on the shared world, and the evaluation can
182    /// legitimately move **bookkeeping** (a counted container's visit count,
183    /// an RNG draw). `run_flow_sleep` restores the resource's change tick so
184    /// that unavoidable `&mut` stops manufacturing a world-changed signal,
185    /// and hands the honest residue here instead — an attributed
186    /// bookkeeping-only touch.
187    ///
188    /// This is **unconditional**: it notes a bookkeeping touch every time an
189    /// Evaluate phase runs at all, whether or not that particular pass
190    /// actually moved a visit count / turn index / RNG draw. The chosen
191    /// consequence — a `FlowSleep::reads_bookkeeping` condition's own prior
192    /// evaluation is enough to re-flag it for another one, forever, once it
193    /// has evaluated a single time — is documented on
194    /// [`reads_bookkeeping`](crate::FlowSleep::reads_bookkeeping) and covered
195    /// by a regression test in `crate::sleep::tests`. It is the over-report
196    /// side of this module's "never under-report" law, not a missed-wake
197    /// risk.
198    pub(crate) fn record_condition_evaluation(&mut self) {
199        self.delta.note_bookkeeping();
200        self.recorded = true;
201    }
202
203    /// Note that a writer this ledger cannot account for touched the shared
204    /// world (a host system, a serial driver, a direct
205    /// `BrinkGlobals::inner` write). Sticky until the next drain: the pending
206    /// delta is no longer a complete account, so [`drain`](Self::drain) will
207    /// report `None`.
208    pub(crate) fn note_foreign(&mut self) {
209        self.foreign = true;
210    }
211
212    /// Take the pending changeset and reset the ledger.
213    ///
214    /// `Some(delta)` only when the ledger is a **complete** account of every
215    /// shared-world change since the previous drain; `None` means the caller
216    /// must fall back to the coarse "anything may have changed" posture.
217    /// Either way the ledger is reset, so the next window starts clean.
218    ///
219    /// `globals_last_changed` / `globals_changed` are
220    /// [`BrinkGlobals`](crate::BrinkGlobals)'s live change tick and its
221    /// change bit as the *draining* system sees them: a live tick past the
222    /// one the last Apply recorded means somebody wrote after that Apply, the
223    /// half of the window a batch driver's own entry check cannot see (see
224    /// the module docs). A `globals_changed` of `false` means nothing moved
225    /// the resource at all since this system last ran, so the recorded tick
226    /// has nothing to disagree with.
227    pub(crate) fn drain(
228        &mut self,
229        globals_last_changed: Option<Tick>,
230        globals_changed: bool,
231    ) -> Option<WorldDelta> {
232        let complete = self.recorded
233            && !self.foreign
234            && (!globals_changed || globals_last_changed == self.recorded_tick);
235        let delta = std::mem::take(&mut self.delta);
236        self.recorded = false;
237        self.foreign = false;
238        self.recorded_tick = None;
239        complete.then_some(delta)
240    }
241
242    /// The changeset pending since the last drain — inspector/debug read.
243    /// Says nothing about whether it is a complete account; see
244    /// [`drain`](Self::drain).
245    #[must_use]
246    pub fn pending(&self) -> &WorldDelta {
247        &self.delta
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    /// The tick an Apply is pretended to have left `BrinkGlobals` at, and the
256    /// matching "the draining system agrees" arguments.
257    const APPLY_TICK: Tick = Tick::new(7);
258
259    fn drain_agreeing(ledger: &mut BrinkWorldDelta<()>) -> Option<WorldDelta> {
260        ledger.drain(Some(APPLY_TICK), true)
261    }
262
263    #[test]
264    fn drain_reports_none_until_a_turn_is_recorded() {
265        let mut ledger = BrinkWorldDelta::<()>::default();
266        assert!(
267            drain_agreeing(&mut ledger).is_none(),
268            "a ledger no driver has recorded into explains nothing — the caller must stay \
269             conservative"
270        );
271    }
272
273    #[test]
274    fn drain_reports_the_recorded_cells_and_resets() {
275        let mut ledger = BrinkWorldDelta::<()>::default();
276        let mut turn = WorldDelta::default();
277        turn.note_global(3);
278        turn.note_bookkeeping();
279        ledger.record(&turn, Some(APPLY_TICK));
280
281        let drained = drain_agreeing(&mut ledger).expect("a recorded turn is a complete account");
282        assert!(drained.globals().contains(&3));
283        assert!(drained.touched_bookkeeping());
284
285        assert!(
286            drain_agreeing(&mut ledger).is_none(),
287            "draining resets the ledger — the next window starts clean and unattributed"
288        );
289    }
290
291    #[test]
292    fn drain_accumulates_every_turn_between_two_drains() {
293        let mut ledger = BrinkWorldDelta::<()>::default();
294        let mut first = WorldDelta::default();
295        first.note_global(1);
296        ledger.record(&first, Some(Tick::new(3)));
297        let mut second = WorldDelta::default();
298        second.note_global(2);
299        ledger.record(&second, Some(APPLY_TICK));
300
301        let drained = drain_agreeing(&mut ledger).expect("recorded");
302        assert!(
303            drained.globals().contains(&1) && drained.globals().contains(&2),
304            "a wake pass that skipped a frame must still see the earlier turn's cells"
305        );
306    }
307
308    #[test]
309    fn a_foreign_write_makes_the_window_unattributable() {
310        let mut ledger = BrinkWorldDelta::<()>::default();
311        ledger.note_foreign();
312        let mut turn = WorldDelta::default();
313        turn.note_global(1);
314        ledger.record(&turn, Some(APPLY_TICK));
315        assert!(
316            drain_agreeing(&mut ledger).is_none(),
317            "a write landing before the Apply (host system, serial driver) must force the \
318             conservative path — never under-report"
319        );
320        assert!(
321            drain_agreeing(&mut ledger).is_none(),
322            "the foreign flag clears on drain, but the window is then unrecorded again"
323        );
324    }
325
326    #[test]
327    fn a_write_after_the_apply_makes_the_window_unattributable() {
328        let mut ledger = BrinkWorldDelta::<()>::default();
329        let mut turn = WorldDelta::default();
330        turn.note_bookkeeping();
331        ledger.record(&turn, Some(APPLY_TICK));
332        assert!(
333            ledger.drain(Some(Tick::new(9)), true).is_none(),
334            "a live change tick past the one the Apply recorded means somebody wrote after it \
335             — the half of the window the batch driver's own entry check cannot see"
336        );
337    }
338
339    #[test]
340    fn an_unchanged_resource_has_nothing_for_the_recorded_tick_to_disagree_with() {
341        let mut ledger = BrinkWorldDelta::<()>::default();
342        ledger.record_condition_evaluation();
343        let drained = ledger
344            .drain(Some(Tick::new(99)), false)
345            .expect("nothing moved the resource, so the recorded tick cannot be stale");
346        assert!(
347            drained.touched_bookkeeping(),
348            "a condition evaluation's bookkeeping residue survives even though it \
349             deliberately leaves no change tick behind"
350        );
351    }
352
353    #[test]
354    fn an_empty_delta_is_distinguishable_from_an_unattributed_one() {
355        let mut ledger = BrinkWorldDelta::<()>::default();
356        ledger.record(&WorldDelta::default(), Some(APPLY_TICK));
357        let drained = drain_agreeing(&mut ledger).expect("recorded, even though it wrote nothing");
358        assert!(
359            drained.is_empty(),
360            "a turn that wrote nothing is a complete account of *no* change — not a reason \
361             to re-check every parked policy"
362        );
363    }
364}