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}