bevy_brink/sleep.rs
1//! BH-4: `FlowSleep` and the reactive-wake contract (`docs/effects-spec.md`
2//! §13.1; decision-log 2026-07-18; tracking #897, this slice #973).
3//!
4//! Reactive sleep is **host-driven** — there is no ink-level `await` construct
5//! (that is a recorded future direction). The game sets a flow's **standing
6//! wake policy** by attaching a [`FlowSleep`] component; ink authors write
7//! ordinary knots. The precise contract, as ruled:
8//!
9//! 1. [`FlowSleep`] does **not** park a flow — flows park at their own natural
10//! yield points (turn end, `-> DONE`). The policy governs *waking*: a parked
11//! flow under a policy is **skipped by Collect** in both drivers
12//! ([`advance_batch`] and
13//! [`advance_batch_parallel`](crate::batch::parallel::advance_batch_parallel)
14//! each filter it out — [`FlowSleep::wants_collect`] is the shared
15//! predicate), so a parked flow costs **zero** per turn no matter which
16//! driver a host uses.
17//! 2. A dependency changing triggers **re-evaluation, not waking**: the
18//! condition (a pure ink fn — purity is provable from its effect row) is
19//! re-evaluated only when a dependency moved, and the flow wakes **only when
20//! the condition is true** ("re-evaluate, don't wake"). Re-evaluation runs
21//! in the **owning flow's context** (shared World ⊕ that flow's own locals,
22//! never a bare world) via [`call_ink_function`](crate::call_ink_function).
23//! 3. A woken flow runs a normal turn; the condition has no mid-turn influence.
24//! 4. Policies are **persistent by default** (re-arm when the flow re-parks);
25//! [`WakeArming::Once`] covers one-shots; [`WakeArming::Latch`] (issue
26//! #1081) covers the reversible boolean-latch shape (wake on a
27//! transition, then go quiet until the opposite transition — a door that
28//! re-locks); the host may clear (remove the component) or replace a
29//! policy anytime, and [`FlowSleep::cancel`] resolves a policy to a
30//! permanent **false** (the flow is never woken by it again).
31//! 5. The policy applies to **turn-boundary parks only**
32//! ([`StoryStatus::Done`]). Choice-blocked ([`WaitingForChoice`]) and
33//! external-blocked flows keep their own resume paths (`choose`,
34//! `resolve_external`); an `-> END` flow ([`Ended`]) is dead and the policy
35//! is inert (the component is dropped).
36//! 6. A flow spawned with [`FlowSleep::dormant`] is **dormant**: parked at
37//! entry, its first turn runs on the first condition-true.
38//!
39//! ## The Detect phase (`#913`)
40//!
41//! Point 2's "only when a dependency moved" is where BH-1's `detect` bits are
42//! consumed. A capability's per-container `detect` bit
43//! ([`ContainerAccess::detect`](crate::ContainerAccess), **AND-merged** across
44//! the container's reads — `#913`) says whether reads of it are backed by
45//! bevy's own change ticks (`true`) or must be polled (`false`). A
46//! [`FlowSleep`]'s [`DetectSummary`] folds the condition's dependency bits into
47//! a single verdict:
48//!
49//! - **no external-capability dependency** ([`DetectSummary::bits`] empty,
50//! vacuously [`DetectSummary::all_detect_capable`] `true`): the condition
51//! reads only ink World state, so it is re-evaluated only when the shared
52//! World actually changed — the cheap path. Since issue #1146 "changed"
53//! here is **row-directed**: a batch turn's Apply records *which* cells it
54//! wrote ([`BrinkWorldDelta`]), and a policy is re-evaluated only when that
55//! changeset intersects the reads its condition's effect row declares. A
56//! turn's bookkeeping writes (visit counts, turn index) are therefore inert
57//! for a condition that reads a global, which is what stopped #1101's
58//! spurious re-wake. Where the ledger cannot account for the whole window
59//! (a serial-mode driver, a host writing [`BrinkGlobals::inner`] directly)
60//! or the condition's row is missing/opaque, this degrades to plain bevy
61//! change detection on [`BrinkGlobals`] — the pre-#1146 behavior.
62//! - **any must-poll dependency** (`#913` AND-merge folded a bit to `false`,
63//! so `all_detect_capable` is `false`): re-evaluated every wake pass. That
64//! capability's reads are not change-detection-backed, so there is no cheap
65//! signal to gate on.
66//! - **every dependency change-detection-capable** (`bits` non-empty, `#913`
67//! verdict all-`true`): the §12.5 cheap path (#996). Each dependency
68//! capability's concrete component is tracked by a
69//! [`detect_capability_changes`](crate::capability::detect_capability_changes)
70//! system (wired per component by `register_capability`), which records —
71//! through bevy's own `Changed<C>` window — whether that component moved
72//! this frame. [`mark_wake_dirty`] re-evaluates such a condition only when
73//! the shared World changed **or** one of its watched components' change
74//! ticks advanced — not every frame. A capability the wake layer cannot
75//! observe (unregistered, or with no verdict recorded yet) folds to a
76//! conservative must-poll: it cannot prove the component is unchanged, and a
77//! missed wake is the engine-race bug class.
78//!
79//! Re-evaluation is **always sound** regardless of the verdict: the detect bits
80//! only tune the *cadence*. `#913`'s AND-merge must land before this cheap
81//! path — a last-write-wins `true` on a capability that is really must-poll
82//! would gate re-evaluation on a component-tick signal that its non-detectable
83//! read never fires, reintroducing the missed wake §12.5 is careful to avoid.
84//!
85//! ## Systems (auto-registered by [`BrinkPlugin`](crate::BrinkPlugin))
86//!
87//! - [`mark_wake_dirty`] (ordinary system): consults each parked policy's
88//! [`DetectSummary`], [`BrinkGlobals`] change detection, and the
89//! per-capability component-tick verdict (§12.5, #996) and flags which parked
90//! flows need a re-evaluation this frame.
91//! - [`run_flow_sleep`] (exclusive system, gated on
92//! `any_with_component::<FlowSleep<M>>`): re-evaluates the flagged conditions
93//! in each flow's own context, wakes on true, and re-arms/removes policies at
94//! turn boundaries. Order-independent w.r.t. [`advance_batch`]: waking takes
95//! effect the following frame either way. Before admitting a flagged policy
96//! for evaluation it also runs the attach-time purity gate
97//! ([`check_named_condition_purity`], issue #995, §13.1 point 2): a
98//! condition whose effect row shows writes — including writes performed
99//! transitively through a host-registered `EXTERNAL` binding the
100//! [`CapabilityManifest`] declares `writes` for (issue #1040, the #995
101//! follow-up; `docs/effects-spec.md` §9/§13), or through a
102//! [`bind_brink_command`](crate::bindings::BrinkBindingsAppExt::bind_brink_command)-bound
103//! `EXTERNAL` regardless of manifest presence (issue #1609, an #1096
104//! follow-up) — is rejected loudly ([`WakeConditionPurityError`]) and never
105//! called, not even once. A dynamically-resolved fn-value condition
106//! ([`FlowSleep::with_condition_value`], issue #1078) runs the same gate
107//! via [`check_value_condition_purity`] instead.
108//!
109//! [`WaitingForChoice`]: brink_runtime::StoryStatus::WaitingForChoice
110//! [`Ended`]: brink_runtime::StoryStatus::Ended
111//! [`StoryStatus::Done`]: brink_runtime::StoryStatus::Done
112//! [`advance_batch`]: crate::advance_batch
113
114use std::collections::{BTreeMap, BTreeSet};
115use std::marker::PhantomData;
116
117use bevy_asset::Assets;
118use bevy_ecs::change_detection::{DetectChanges, DetectChangesMut};
119use bevy_ecs::component::Component;
120use bevy_ecs::entity::Entity;
121use bevy_ecs::query::QueryState;
122use bevy_ecs::reflect::ReflectComponent;
123use bevy_ecs::system::{Local, Query, Res, ResMut};
124use bevy_ecs::world::World as EcsWorld;
125use bevy_log::warn;
126use bevy_reflect::Reflect;
127use brink_format::{DefinitionId, DirectEffects, EffectRowEntry, Value};
128use brink_runtime::{Program, StoryStatus};
129use thiserror::Error;
130
131use crate::asset::{BrinkProgram, ProgramAsset};
132use crate::bindings::{BrinkBindings, call_ink_function, call_ink_function_value};
133use crate::capability::{
134 CapabilityChanges, CapabilityManifest, CapabilityRegistry, ContainerAccess,
135};
136use crate::flow::BrinkFlow;
137use crate::globals::BrinkGlobals;
138use crate::wake_delta::{BrinkWorldDelta, WorldDelta};
139
140/// When a woken flow re-parks, does its policy re-arm or retire?
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Reflect)]
142pub enum WakeArming {
143 /// Re-arm every time the flow re-parks at a turn boundary — a standing
144 /// subscription. The default (`docs/effects-spec.md` §13.1 point 4).
145 #[default]
146 Persistent,
147 /// Fire exactly once: after the first wake runs its turn, the policy is
148 /// removed and the flow reverts to ordinary per-turn advancement.
149 Once,
150 /// A reversible boolean latch (issue #1081, `docs/effects-spec.md` §13.1's
151 /// wake contract conventions): wakes on a rising edge (the condition
152 /// transitioning to the value this policy is currently watching for),
153 /// then re-arms watching for the **opposite** value — so the next wake
154 /// only fires on the falling edge, and so on indefinitely. Never retires.
155 ///
156 /// This expresses "wake on a transition, then go quiet until the
157 /// opposite transition" — the natural shape for a boolean-latch reactive
158 /// entity (a door: wake+open on switch-on, wake+re-lock on switch-off)
159 /// — **without** requiring the condition itself to track any state: the
160 /// condition stays an ordinary level predicate (e.g. "is the switch
161 /// on?"), and this policy does the edge detection by comparing each
162 /// reading against [`FlowSleep::latch_waiting_for`] rather than against
163 /// a fixed `true`.
164 Latch,
165}
166
167/// The lifecycle state of a [`FlowSleep`] policy — inspector-visible, and the
168/// single field [`FlowSleep::wants_collect`] reads to tell Collect whether the
169/// flow steps this turn.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect)]
171pub enum SleepState {
172 /// Parked: the flow is asleep under this policy and is **skipped by
173 /// Collect**. The default for a freshly attached policy.
174 Parked,
175 /// Woken: the condition evaluated true; Collect steps the flow this turn.
176 /// On reaching its next turn boundary the policy re-arms
177 /// ([`WakeArming::Persistent`], [`WakeArming::Latch`]) or is removed
178 /// ([`WakeArming::Once`]).
179 Woken,
180 /// Cancelled by the host ([`FlowSleep::cancel`]): the condition is
181 /// permanently **false**. The flow stays parked and is never re-evaluated
182 /// or woken by this policy again (the host must remove or replace it).
183 Cancelled,
184 /// The condition evaluation faulted (a runtime error, a missing function,
185 /// an async external on the exclusive path). Logged once; the flow stays
186 /// parked and is not re-evaluated — never silently retried into a spin,
187 /// never mistaken for a false condition.
188 Faulted,
189}
190
191/// The distilled `detect`-bit verdict for a policy's condition dependency set
192/// (`#913`, ruled 2026-07-18). Built from the per-container AND-merged
193/// [`ContainerAccess::detect`](crate::ContainerAccess) map, or supplied
194/// directly by a host that knows its condition's dependencies.
195#[derive(Debug, Clone, PartialEq, Eq, Reflect)]
196pub struct DetectSummary {
197 /// The per-capability merged bits the summary was built from (kept for
198 /// inspector visibility / debugging). Empty means the condition has no
199 /// external-capability dependencies (it reads only ink World state).
200 pub bits: BTreeMap<String, bool>,
201 /// `true` iff **every** dependency capability is change-detection-backed
202 /// (the AND of all `bits`, vacuously `true` when `bits` is empty). Drives
203 /// the re-evaluation cadence: `true` → re-evaluate only on a World change;
204 /// `false` → poll every wake pass.
205 pub all_detect_capable: bool,
206}
207
208impl Default for DetectSummary {
209 /// The no-external-dependency case: an empty `bits` map is vacuously
210 /// all-detect-capable (see [`DetectSummary::from_bits`]) — a condition
211 /// that reads only ink World state is always change-detection-backed via
212 /// `BrinkGlobals`. A derived `Default` would instead default
213 /// `all_detect_capable` to `false`, silently forcing every policy built
214 /// without an explicit [`FlowSleep::with_detect`] onto the must-poll path
215 /// (the exact defect this hand-written impl exists to prevent).
216 fn default() -> Self {
217 Self::from_bits(BTreeMap::new())
218 }
219}
220
221impl DetectSummary {
222 /// Build a summary from a per-capability `detect` bit map — typically the
223 /// AND-merged [`ContainerAccess::detect`](crate::ContainerAccess) of the
224 /// condition's container.
225 #[must_use]
226 pub fn from_bits(bits: BTreeMap<String, bool>) -> Self {
227 let all_detect_capable = bits.values().all(|&b| b);
228 Self {
229 bits,
230 all_detect_capable,
231 }
232 }
233
234 /// Build a summary from a joined [`ContainerAccess`] — reads its
235 /// AND-merged `detect` map directly. Use when the wake condition's
236 /// dependency container has an entry in a loaded
237 /// [`CapabilityTable`](crate::CapabilityTable).
238 #[must_use]
239 pub fn from_container_access(access: &ContainerAccess) -> Self {
240 Self::from_bits(access.detect.clone())
241 }
242}
243
244/// A standing reactive-wake policy on a flow entity (`docs/effects-spec.md`
245/// §13.1). Attach it to a fulfilled flow entity; the plugin's wake systems do
246/// the rest. See the module docs for the full contract.
247///
248/// Construct via [`FlowSleep::persistent`] / [`FlowSleep::once`], optionally
249/// chaining [`with_args`](Self::with_args), [`with_detect`](Self::with_detect),
250/// and [`dormant`](Self::dormant).
251#[derive(Component, Reflect)]
252#[reflect(Component)]
253#[expect(
254 clippy::struct_excessive_bools,
255 reason = "each bool is an independent lifecycle/cadence flag with its \
256 own doc comment, not a state machine in disguise (`dormant`, \
257 `needs_eval`, and `evaluated_once` are orthogonal cadence \
258 signals; `waiting_for` (issue #1081) is Latch-only edge state) \
259 — see the ChoiceFlags precedent in brink-format::opcode"
260)]
261pub struct FlowSleep<M: Send + Sync + 'static = ()> {
262 /// The ink function name whose (pure) return value is the wake condition.
263 /// A diagnostic label only when [`condition_value`](Self::condition_value)
264 /// is `Some` — see [`with_condition_value`](Self::with_condition_value).
265 condition: String,
266 /// A dynamically-resolved fn-value token (`Value::FnRef`/`Closure`) that
267 /// overrides `condition`'s by-name resolution — see
268 /// [`with_condition_value`](Self::with_condition_value).
269 #[reflect(ignore)]
270 condition_value: Option<Value>,
271 /// Arguments passed to the condition function, in declaration order.
272 #[reflect(ignore)]
273 args: Vec<Value>,
274 /// The `detect`-bit verdict for the condition's dependencies (`#913`).
275 detect: DetectSummary,
276 /// Re-arm vs one-shot.
277 arming: WakeArming,
278 /// Current lifecycle state.
279 state: SleepState,
280 /// `true` until the flow has run at least once under this policy: a
281 /// dormant-spawned flow is eligible for its first wake evaluation even
282 /// though it has never reached a turn boundary.
283 dormant: bool,
284 /// Set by [`mark_wake_dirty`] when a dependency may have moved; consumed
285 /// (and cleared) by [`run_flow_sleep`] when it re-evaluates the condition.
286 needs_eval: bool,
287 /// Whether the condition has been evaluated at least once — so a dormant,
288 /// all-detect-capable policy still gets its initial evaluation even on a
289 /// frame the World didn't change.
290 evaluated_once: bool,
291 /// [`WakeArming::Latch`] only: the boolean value the condition must next
292 /// equal to fire (flipped every time it does). Starts `true` — a fresh
293 /// latch watches for the condition to become true first. Inert (never
294 /// read or flipped) for `Persistent`/`Once`.
295 waiting_for: bool,
296 /// Issue #1146: does this condition read story **bookkeeping** (visit
297 /// counts, turn counts, the turn index, RNG state)? Host-declared via
298 /// [`reads_bookkeeping`](Self::reads_bookkeeping) — effect rows model
299 /// only global cells, so the row cannot answer this. Only consulted on
300 /// the row-directed cheap path; a condition whose row is unknown or
301 /// opaque re-evaluates on any change regardless.
302 reads_bookkeeping: bool,
303 #[reflect(ignore)]
304 _marker: PhantomData<fn() -> M>,
305}
306
307impl<M: Send + Sync + 'static> FlowSleep<M> {
308 /// A **persistent** policy: `condition` is an ink function name returning a
309 /// truthy value when the flow should wake. Re-arms every time the flow
310 /// re-parks (§13.1 point 4).
311 #[must_use]
312 pub fn persistent(condition: impl Into<String>) -> Self {
313 Self::new(condition.into(), WakeArming::Persistent)
314 }
315
316 /// A **one-shot** policy: fires once on the first condition-true, then the
317 /// component is removed and the flow reverts to ordinary advancement.
318 #[must_use]
319 pub fn once(condition: impl Into<String>) -> Self {
320 Self::new(condition.into(), WakeArming::Once)
321 }
322
323 /// A **reversible latch** policy (issue #1081): wakes on a transition to
324 /// `true`, then re-arms watching for the transition back to `false`, and
325 /// so on indefinitely. See [`WakeArming::Latch`] for the full contract.
326 #[must_use]
327 pub fn latch(condition: impl Into<String>) -> Self {
328 Self::new(condition.into(), WakeArming::Latch)
329 }
330
331 fn new(condition: String, arming: WakeArming) -> Self {
332 Self {
333 condition,
334 condition_value: None,
335 args: Vec::new(),
336 detect: DetectSummary::default(),
337 arming,
338 // A non-dormant policy starts **eligible to run** (`Woken`): a flow
339 // parks at its natural yield points (§13.1 point 1), so it must be
340 // allowed to run to its first turn boundary before the policy
341 // engages. `run_flow_sleep` re-parks it (→ `Parked`) once it
342 // reaches that boundary, and the condition governs waking from
343 // then on. `dormant()` overrides this to `Parked` (parked at entry).
344 state: SleepState::Woken,
345 dormant: false,
346 needs_eval: false,
347 evaluated_once: false,
348 waiting_for: true,
349 reads_bookkeeping: false,
350 _marker: PhantomData,
351 }
352 }
353
354 /// Pass arguments to the condition function (declaration order). Builder.
355 #[must_use]
356 pub fn with_args(mut self, args: Vec<Value>) -> Self {
357 self.args = args;
358 self
359 }
360
361 /// Attach a dynamically-resolved fn-value token (`Value::FnRef`/
362 /// `Closure`) as the wake condition — for a host that obtained the
363 /// condition dynamically (a global's current value, a returned callback
364 /// token, a `bind_brink_query` result) rather than naming it statically
365 /// via [`persistent`](Self::persistent)/[`once`](Self::once).
366 ///
367 /// When set, this takes over **both** halves of condition resolution:
368 /// the attach-time purity gate checks `value`'s target row
369 /// ([`check_value_condition_purity`] instead of
370 /// [`check_named_condition_purity`]), and evaluation invokes the token
371 /// directly (`call_ink_function_value` instead of resolving `condition`
372 /// by path). `condition`'s string (from [`persistent`](Self::persistent)/
373 /// [`once`](Self::once)) remains only a diagnostic label at that point —
374 /// it is never resolved by path while a `condition_value` is set. Builder.
375 #[must_use]
376 pub fn with_condition_value(mut self, value: Value) -> Self {
377 self.condition_value = Some(value);
378 self
379 }
380
381 /// Attach the condition's dependency [`DetectSummary`] (`#913`), tuning the
382 /// re-evaluation cadence. Without this the summary is empty — treated as
383 /// all-detect-capable (re-evaluate only on a World change), the right
384 /// default for a condition reading only ink World state. A summary whose
385 /// [`bits`](DetectSummary::bits) names external (component) capabilities
386 /// that are **all** change-detection-capable (`#913` AND-merge verdict
387 /// all-`true`) gets the §12.5 cheap path (#996): re-evaluated only when one
388 /// of those components changed — provided each is registered via
389 /// `register_capability` (an unregistered capability the wake layer cannot
390 /// observe must-polls conservatively). A summary with any must-poll bit
391 /// re-evaluates every pass. Builder.
392 #[must_use]
393 pub fn with_detect(mut self, detect: DetectSummary) -> Self {
394 self.detect = detect;
395 self
396 }
397
398 /// Declare that this condition reads story **bookkeeping** — a visit
399 /// count (`{knot}`, `TURNS_SINCE(-> knot)`), a turn count, the turn index
400 /// (`TURNS()`, `CHOICE_COUNT()`), or RNG state (issue #1146). Builder.
401 ///
402 /// The row-directed wake-dirtying path re-evaluates a condition only when
403 /// a cell it reads was actually written, and a condition's read set comes
404 /// from its **effect row** — which models global cells *only*
405 /// (`brink_format::DirectEffects::reads`). Bookkeeping reads are invisible
406 /// to it, so a condition that depends on one must say so here, or it will
407 /// sit parked through the turns that move it. Nothing else needs this: a
408 /// condition reading ink globals is covered by its row automatically, and
409 /// a condition whose row is missing/opaque already re-evaluates on any
410 /// change.
411 ///
412 /// Graduating a `reads`-bookkeeping row dimension (so this is inferred
413 /// rather than declared) is tracked as the follow-up to #1146.
414 ///
415 /// **Known tradeoff, not a bug:** once a condition declaring this is
416 /// evaluated even once, it stays perpetually flagged for re-evaluation
417 /// thereafter, even if nothing it actually depends on ever changes again.
418 /// Every Evaluate pass notes an unconditional bookkeeping touch in the
419 /// changed-cell ledger (the unavoidable `&mut BrinkGlobals` residue
420 /// building a condition's context takes), and that residue is itself
421 /// indistinguishable, to the row-directed path, from a real bookkeeping
422 /// write — so a `reads_bookkeeping()` reader's own prior evaluation
423 /// re-triggers the next one. This is deliberately on the over-report side
424 /// of the ledger's "never under-report" law (module docs,
425 /// `crate::wake_delta`): the cost is a self-sustaining re-evaluation,
426 /// never a missed wake.
427 #[must_use]
428 pub fn reads_bookkeeping(mut self) -> Self {
429 self.reads_bookkeeping = true;
430 self
431 }
432
433 /// Mark this policy **dormant** (`docs/effects-spec.md` §13.1 point 6): the
434 /// flow is parked at entry and its first turn runs on the first
435 /// condition-true. Attach to a freshly fulfilled flow before it has stepped.
436 /// Builder.
437 #[must_use]
438 pub fn dormant(mut self) -> Self {
439 self.dormant = true;
440 // Dormant means parked at entry — override the non-dormant "run the
441 // first turn" default so Collect skips it until the first condition-true.
442 self.state = SleepState::Parked;
443 self
444 }
445
446 /// Cancel the policy: its condition is henceforth a permanent **false**
447 /// (§13.1 — "cancellation → false"). The flow stays parked and is never
448 /// re-evaluated or woken by this policy again. To fully detach, remove the
449 /// component; to change the wake condition, replace it with a new one.
450 pub fn cancel(&mut self) {
451 self.state = SleepState::Cancelled;
452 self.needs_eval = false;
453 }
454
455 /// The ink function name of the wake condition — a diagnostic label only
456 /// when [`condition_value`](Self::condition_value) is `Some`.
457 #[must_use]
458 pub fn condition(&self) -> &str {
459 &self.condition
460 }
461
462 /// The dynamically-resolved fn-value token, if
463 /// [`with_condition_value`](Self::with_condition_value) set one.
464 #[must_use]
465 pub fn condition_value(&self) -> Option<&Value> {
466 self.condition_value.as_ref()
467 }
468
469 /// The current [`SleepState`].
470 #[must_use]
471 pub fn state(&self) -> SleepState {
472 self.state
473 }
474
475 /// The re-arm policy.
476 #[must_use]
477 pub fn arming(&self) -> WakeArming {
478 self.arming
479 }
480
481 /// [`WakeArming::Latch`] only: the boolean value the condition must next
482 /// equal to fire. This doubles as an outside observer's read of which
483 /// side of the latch the policy currently sits on — e.g. for a door
484 /// whose condition is "is the switch on?": `true` means the policy is
485 /// waiting for the switch to turn on (the door is currently locked);
486 /// `false` means it is waiting for the switch to turn off (the door is
487 /// currently open). Always `true` (and unused) for `Persistent`/`Once`.
488 #[must_use]
489 pub fn latch_waiting_for(&self) -> bool {
490 self.waiting_for
491 }
492
493 /// Whether every dependency capability is change-detection-backed (`#913`
494 /// AND-merge verdict). `false` means the condition polls every pass. `true`
495 /// enables the cheap path: for an empty [`bits`](DetectSummary::bits) map
496 /// (no external dependency) that is re-evaluate-on-`BrinkGlobals`-change;
497 /// for a non-empty one it is re-evaluate-on-watched-component-change (§12.5,
498 /// #996), provided each named capability is registered via
499 /// `register_capability` so [`mark_wake_dirty`] can observe its ticks — an
500 /// unregistered capability the wake layer cannot observe still must-polls.
501 #[must_use]
502 pub fn dependencies_all_detect_capable(&self) -> bool {
503 self.detect.all_detect_capable
504 }
505
506 /// The dependency [`DetectSummary`] this policy was built with.
507 #[must_use]
508 pub fn detect_summary(&self) -> &DetectSummary {
509 &self.detect
510 }
511
512 /// Whether the host declared this condition a reader of story
513 /// bookkeeping — see [`reads_bookkeeping`](Self::reads_bookkeeping).
514 #[must_use]
515 pub fn declares_bookkeeping_reads(&self) -> bool {
516 self.reads_bookkeeping
517 }
518
519 /// Whether Collect should step this flow this turn — the predicate
520 /// [`advance_batch`](crate::advance_batch)'s Collect phase applies. Only a
521 /// [`Woken`](SleepState::Woken) policy admits the flow; a parked, cancelled,
522 /// or faulted policy costs zero (skipped).
523 #[must_use]
524 pub fn wants_collect(&self) -> bool {
525 matches!(self.state, SleepState::Woken)
526 }
527}
528
529// ── Wake-condition purity (issue #995, BH-4 follow-up) ──────────────────────
530//
531// `docs/effects-spec.md` §13.1 point 2 requires a `FlowSleep` condition to be
532// a **pure** fn: it is re-evaluated whenever a dependency moves, and the
533// re-evaluate contract can never tolerate that re-evaluation observing (or
534// causing) a mutation. Before this slice, nothing checked that — a condition
535// naming a knot/function that writes a global would be called anyway, every
536// wake pass. `check_named_condition_purity` (a `&str` condition, the shape
537// every `FlowSleep` uses today) and `check_value_condition_purity` (a `Value`
538// fn-value token — `FnRef`/`Closure` — for a host that resolves its condition
539// dynamically) both resolve to a `DefinitionId` and inspect its `EffectRows`
540// row (T2-3, `docs/effects-spec.md` §11): any global-cell write in the row's
541// direct part, or in a dispatch's static fallback (v1 does no runtime
542// narrowing — §7 — so a dispatch's conservative fallback always applies), or
543// an opaque row (the §3 pessimal top: effects inference couldn't summarize a
544// call it makes) makes the condition impure and rejects loudly.
545//
546// **Closed follow-up** (issue #1040, tracked from #995/#897): the check also
547// walks every `EXTERNAL` call a row (or a dispatch's static fallback) makes
548// and consults the [`CapabilityManifest`]'s declared `effects.writes` for it
549// (`docs/effects-spec.md` §9's "the manifest IS the external's row", §13.2's
550// grammar) — a manifest entry declaring one or more `writes` capabilities
551// rejects the condition exactly like an ink-level global write does. Unlike
552// the BH-1 access join (`compute_container_access`), this needs no
553// `CapabilityRegistry`/`ComponentId` resolution: the yes/no purity verdict
554// only needs the capability *names* a manifest entry lists, not what
555// `ComponentId` they resolve to.
556//
557// A manifest entry with no `writes` (reads-only, or no `effects` key at all —
558// §13.2's opt-in default) is accepted: it does not touch a write-capable ECS
559// capability, and purity is exactly "no writes". An `EXTERNAL` name with **no
560// manifest entry at all** is likewise accepted (unless it is a
561// `bind_brink_command` binding — see the #1609 paragraph below), deliberately
562// matching the BH-1 access join's posture (`resolve_call_atom`,
563// `crate::capability`): "a call whose `NameId` doesn't resolve, or that has
564// no manifest entry at all, contributes no access — silently, since not
565// every `EXTERNAL` touches ECS state (§13.2's `effects` key is opt-in)".
566// Rejecting an unregistered external here would fault the flow permanently —
567// the same missed-wake bug class the #913 detect-merge ruling treats as the
568// worse failure mode (`docs/decision-log.md` 2026-07-18, "a missed wake is
569// the engine-race bug class") — for a binding (e.g. a `bind_brink_fn`
570// helper) that legitimately never touches ECS state and was accepted before
571// this check existed. Only a manifest entry that affirmatively declares
572// `writes` is rejected; the manifest is an honesty contract, not a security
573// boundary (the host is the TCB — `docs/effects-spec.md` §9).
574//
575// A story whose `EffectRows` table is empty entirely (a converter-built
576// program, or a program that never went through the compiler's effects
577// emission) is outside the guarantee this checks: "compiler rows guarantee
578// purity only for ink-authored conditions reaching codegen" (issue #995) — so
579// an empty table skips the check rather than rejecting every condition a
580// story like that could ever declare.
581//
582// `run_flow_sleep` calls this at the moment a parked policy is first admitted
583// for evaluation (dormant policies: immediately; persistent ones: their first
584// park) — before the condition is ever called, so an impure condition is
585// never evaluated even once. Faulted the same way a runtime eval error is
586// (never silently retried into a spin), but with its own distinct, named
587// error so the two classes of failure are never confused in a log.
588//
589// **Closed follow-up** (issue #1078, tracked from #1062): `check_row_purity`
590// (via `check_value_condition_purity`) has always covered a dynamically-
591// resolved fn-value token (`Value::FnRef`/`Closure`), but nothing in
592// `FlowSleep`/`run_flow_sleep` could ever produce one to check — the named
593// path (`FlowSleep::persistent`/`once`) was the only wake-condition shape
594// `run_flow_sleep` resolved. `FlowSleep::with_condition_value` adds the
595// missing shape: a host that obtains its condition dynamically (a global's
596// current value, a returned callback token, a `bind_brink_query` result)
597// attaches the token directly, and `run_flow_sleep`'s gather/evaluate phases
598// branch on it exactly like the named path — `check_value_condition_purity`
599// gates admission, `call_ink_function_value` (`crate::bindings`) evaluates.
600//
601// **Closed** (issue #1609, a #1096 follow-up): a `bind_brink_command`-bound
602// `EXTERNAL` with no [`CapabilityManifest`] entry used to pass
603// `check_external_calls_purity` above — "no manifest entry at all accepts"
604// is deliberate (a `bind_brink_fn` helper that never touches ECS state
605// shouldn't need one), but it meant a wake condition naming a
606// `call_ink_function`/`call_ink_function_value` path that reaches a
607// `bind_brink_command` binding was accepted as pure even though, since
608// #1096's fix, that path fires a real Bevy event on every re-evaluation
609// pass — where before #1096 it was inert (silently ran the in-story
610// fallback instead). `check_named_condition_purity`/
611// `check_value_condition_purity` (and the `check_row_purity`/
612// `check_external_calls_purity` helpers they share) now take an optional
613// [`BrinkBindings<M>`] reference and reject any call whose target name is
614// present in [`BrinkBindings::is_command`] — bevy-brink has that
615// binding-kind information locally, so this needs no manifest entry at all
616// to answer, unlike the #1040 manifest-`writes` check above. `run_flow_sleep`
617// fetches the app's `BrinkBindings<M>` resource (absent entirely if the host
618// never registered any binding) alongside the `CapabilityManifest` and
619// threads it through. This is scoped to `bind_brink_command` only: a pure
620// (`bind_brink_fn`) binding is not rejected by this check. A world-query
621// (`bind_brink_query`) binding is *also* not rejected here, but not because
622// its World access is out of reach — `call_ink_function`/
623// `call_ink_function_value` (`crate::bindings`, the same drivers
624// `run_flow_sleep` uses to evaluate a condition) resolve a query binding
625// **inline**, synchronously, mid-evaluation (`docs/bevy-brink.md`'s binding
626// table: "flow pauses (`Pending`); a driver runs it via `run_system_with`
627// between suspensions, then resumes" — the pause/resume is internal to the
628// single call, not a cross-frame park a wake condition's re-evaluation could
629// dodge). Widening this gate to cover write-capable query bindings is a
630// design question left open (whether/how to distinguish a read-only query
631// system from a writing one), not something this fix's scope covers.
632//
633// `resolve_brink_calls` (`crate::call`, the deferred
634// `commands.brink_call(...)` path) also drives `call_ink_function` under the
635// hood and so can also fire a command event — but it is an explicit,
636// engine-initiated call, not a `FlowSleep` re-evaluation, so it is outside
637// this gate's contract (`docs/effects-spec.md` §13.1 point 2 only binds
638// wake-condition purity) and unaffected by this fix.
639
640/// A wake condition failed the attach-time purity check. See the module
641/// section above for the contract this enforces.
642#[derive(Debug, Clone, PartialEq, Eq, Error)]
643pub enum WakeConditionPurityError {
644 /// The condition name/path didn't resolve to any definition in this
645 /// story.
646 #[error(
647 "wake condition `{condition}` does not resolve to a known definition in this story \
648 (check the name/path is correct for the loaded story)"
649 )]
650 UnknownCondition {
651 /// The condition name/path (or a `divert_target_path` label for a
652 /// value-resolved condition) that failed to resolve.
653 condition: String,
654 },
655 /// The condition value wasn't a function value (`FnRef`/`Closure`) at
656 /// all — [`check_value_condition_purity`] only has a target definition to
657 /// inspect for an actual fn-value token.
658 #[error(
659 "wake condition value is not a function value (FnRef/Closure) — no target definition to \
660 check for purity"
661 )]
662 NotAFunctionValue,
663 /// The condition resolved to a definition, but this story's (non-empty)
664 /// `EffectRows` table has no row for it — an internal invariant
665 /// violation (every knot/stitch ships one once the table is populated at
666 /// all), never a panic: conservatively treated as impure.
667 #[error(
668 "wake condition `{condition}` resolved to a definition with no EffectRows entry — an \
669 internal invariant expects one once a story's EffectRows table is populated at all; \
670 treating conservatively as impure"
671 )]
672 MissingEffectRow {
673 /// The condition name/path/label this row lookup failed for.
674 condition: String,
675 },
676 /// The condition (or a dispatch fallback its row folds in) writes one or
677 /// more global cells.
678 #[error(
679 "wake condition `{condition}` is not pure: it writes global(s) {writes:?} — a FlowSleep \
680 condition is re-evaluated whenever a dependency moves (docs/effects-spec.md §13.1 point \
681 2), and a writing condition would let that re-evaluation observe or cause a mutation"
682 )]
683 Writes {
684 /// The condition name/path/label.
685 condition: String,
686 /// The written globals' names, sorted and deduplicated.
687 writes: Vec<String>,
688 },
689 /// The condition's row (or a dispatch fallback) is opaque — effects
690 /// inference couldn't summarize a call it makes (§3's pessimal top).
691 /// Purity can't be proven, so it's conservatively rejected.
692 #[error(
693 "wake condition `{condition}`'s effect row is opaque (a call it makes couldn't be \
694 summarized by effects inference) — purity can't be proven, so it is conservatively \
695 rejected"
696 )]
697 Opaque {
698 /// The condition name/path/label.
699 condition: String,
700 },
701 /// The condition (or a dispatch fallback its row folds in) calls a
702 /// host-registered `EXTERNAL` binding whose [`CapabilityManifest`] entry
703 /// declares one or more `writes` capabilities (issue #1040, the #995
704 /// follow-up; `docs/effects-spec.md` §9/§13).
705 #[error(
706 "wake condition `{condition}` is not pure: it calls EXTERNAL `{external}`, whose \
707 capability manifest declares writes {writes:?} — a FlowSleep condition is \
708 re-evaluated whenever a dependency moves (docs/effects-spec.md §13.1 point 2), and a \
709 writing binding would let that re-evaluation observe or cause a mutation \
710 (docs/effects-spec.md §9/§13, issue #1040)"
711 )]
712 ExternalWrites {
713 /// The condition name/path/label.
714 condition: String,
715 /// The `EXTERNAL` binding's name.
716 external: String,
717 /// The written capability names the manifest declares, sorted and
718 /// deduplicated.
719 writes: Vec<String>,
720 },
721 /// The condition (or a dispatch fallback its row folds in) calls a
722 /// [`bind_brink_command`](crate::bindings::BrinkBindingsAppExt::bind_brink_command)-bound
723 /// `EXTERNAL` (issue #1609, an #1096 follow-up). Rejected regardless of
724 /// [`CapabilityManifest`] presence: `bevy-brink` knows the binding kind
725 /// locally, and a command binding mutates the World when its parsed
726 /// event is triggered, so a wake condition reaching it would let
727 /// re-evaluation fire that mutation repeatedly (§13.1 point 2).
728 #[error(
729 "wake condition `{condition}` is not pure: it calls `{external}`, a bind_brink_command \
730 binding — a command binding mutates the World when triggered, and a FlowSleep \
731 condition is re-evaluated whenever a dependency moves (docs/effects-spec.md §13.1 \
732 point 2), so a command-bound wake condition is rejected regardless of \
733 CapabilityManifest presence (issue #1609)"
734 )]
735 CommandBinding {
736 /// The condition name/path/label.
737 condition: String,
738 /// The command-bound external's name.
739 external: String,
740 },
741}
742
743/// Find the `EffectRows` entry for `def`, if this story's table carries one.
744fn effect_row_for(effect_rows: &[EffectRowEntry], def: DefinitionId) -> Option<&EffectRowEntry> {
745 effect_rows.iter().find(|row| row.def == def)
746}
747
748/// Resolve global-cell `DefinitionId`s to their variable names for a
749/// human-readable error — falling back to the id's own debug form rather than
750/// panicking if the name table doesn't carry it (shouldn't happen for a
751/// well-formed row, but this is a diagnostic path, not a hot one).
752fn write_names(program: &Program, ids: &[DefinitionId]) -> Vec<String> {
753 ids.iter()
754 .map(|id| {
755 program
756 .global_var_name(*id)
757 .map_or_else(|| format!("<{id}>"), str::to_owned)
758 })
759 .collect()
760}
761
762/// The shared purity check once a condition token has resolved to a `row`
763/// (see the module section above for exactly what this inspects).
764///
765/// `bindings` is `None` when the host never registered any
766/// [`BrinkBindings<M>`] (no `bind_brink_*` call at all — the resource is
767/// inserted lazily) — in that case there is no `commands` bucket to consult,
768/// so the #1609 command-binding check below is trivially skipped, same as an
769/// empty registry would be.
770fn check_row_purity<M: Send + Sync + 'static>(
771 program: &Program,
772 row: &EffectRowEntry,
773 manifest: &CapabilityManifest,
774 bindings: Option<&BrinkBindings<M>>,
775 condition_label: &str,
776) -> Result<(), WakeConditionPurityError> {
777 if row.direct.opaque
778 || row
779 .dispatches
780 .iter()
781 .any(|dispatch| dispatch.fallback.opaque)
782 {
783 return Err(WakeConditionPurityError::Opaque {
784 condition: condition_label.to_owned(),
785 });
786 }
787
788 check_external_calls_purity(program, &row.direct, manifest, bindings, condition_label)?;
789 for dispatch in &row.dispatches {
790 check_external_calls_purity(
791 program,
792 &dispatch.fallback,
793 manifest,
794 bindings,
795 condition_label,
796 )?;
797 }
798
799 let mut writes = write_names(program, &row.direct.writes);
800 for dispatch in &row.dispatches {
801 writes.extend(write_names(program, &dispatch.fallback.writes));
802 }
803 if writes.is_empty() {
804 Ok(())
805 } else {
806 writes.sort();
807 writes.dedup();
808 Err(WakeConditionPurityError::Writes {
809 condition: condition_label.to_owned(),
810 writes,
811 })
812 }
813}
814
815/// Issue #1040 (the #995 follow-up) + issue #1609: check every `EXTERNAL`
816/// call atom in `direct` (a row's direct part, or a dispatch's static
817/// fallback) against, in order: `bindings`'s `commands` registry (#1609 — a
818/// `bind_brink_command`-bound name rejects **unconditionally**, no manifest
819/// entry needed), then `manifest`'s declared `effects.writes` (#1040). See
820/// the module section above for the full contract: a command-bound name, or
821/// a manifest entry declaring `writes`, rejects; a reads-only entry, a
822/// no-`effects`-key entry, or no manifest entry at all for a non-command
823/// name (the same opt-in posture `crate::capability::resolve_call_atom`
824/// applies) all accept.
825fn check_external_calls_purity<M: Send + Sync + 'static>(
826 program: &Program,
827 direct: &DirectEffects,
828 manifest: &CapabilityManifest,
829 bindings: Option<&BrinkBindings<M>>,
830 condition_label: &str,
831) -> Result<(), WakeConditionPurityError> {
832 for call in &direct.calls {
833 let external_name = program
834 .name_checked(call.name)
835 .map_or_else(|| format!("<{:?}>", call.name), str::to_owned);
836 // #1609: a command-bound name rejects unconditionally — bevy-brink
837 // knows this binding kind locally, so no manifest entry is needed
838 // (or consulted) to reject it.
839 if bindings.is_some_and(|b| b.is_command(&external_name)) {
840 return Err(WakeConditionPurityError::CommandBinding {
841 condition: condition_label.to_owned(),
842 external: external_name,
843 });
844 }
845 // No manifest entry at all accepts, same as a reads-only/no-`effects`
846 // entry — see the doc comment above.
847 if let Some(external) = manifest.external(&external_name)
848 && !external.effects.writes.is_empty()
849 {
850 let mut writes = external.effects.writes.clone();
851 writes.sort();
852 writes.dedup();
853 return Err(WakeConditionPurityError::ExternalWrites {
854 condition: condition_label.to_owned(),
855 external: external_name,
856 writes,
857 });
858 }
859 }
860 Ok(())
861}
862
863/// Check purity for a **named** wake condition (`FlowSleep::condition`'s
864/// shape) — resolves `condition` to a `DefinitionId` via
865/// [`Program::definition_id_for_path`], then inspects its `EffectRows` row.
866///
867/// `Ok(())` when `effect_rows` is empty entirely: a story that never shipped
868/// an `EffectRows` table (converter-built, or otherwise never ran the
869/// compiler's effects emission) is outside the guarantee this checks — see
870/// the module section above.
871///
872/// `manifest` threads the host's [`CapabilityManifest`] (issue #1040) so a
873/// condition calling a host-registered `EXTERNAL` binding is checked against
874/// that binding's declared `effects.writes`, not just the row's own
875/// ink-level writes. `bindings` threads the host's [`BrinkBindings<M>`]
876/// registry (issue #1609) so a condition calling a `bind_brink_command`
877/// binding is rejected outright, regardless of manifest presence — pass
878/// `None` if the host never registered any binding for marker `M`.
879///
880/// # Errors
881/// See [`WakeConditionPurityError`].
882pub fn check_named_condition_purity<M: Send + Sync + 'static>(
883 program: &Program,
884 effect_rows: &[EffectRowEntry],
885 manifest: &CapabilityManifest,
886 bindings: Option<&BrinkBindings<M>>,
887 condition: &str,
888) -> Result<(), WakeConditionPurityError> {
889 if effect_rows.is_empty() {
890 return Ok(());
891 }
892 let def = program.definition_id_for_path(condition).ok_or_else(|| {
893 WakeConditionPurityError::UnknownCondition {
894 condition: condition.to_owned(),
895 }
896 })?;
897 let row = effect_row_for(effect_rows, def).ok_or_else(|| {
898 WakeConditionPurityError::MissingEffectRow {
899 condition: condition.to_owned(),
900 }
901 })?;
902 check_row_purity(program, row, manifest, bindings, condition)
903}
904
905/// Check purity for a **dynamic fn-value** wake condition — a `Value`
906/// (`FnRef`/`Closure`) resolved token rather than a static name, e.g. one a
907/// host obtained from a global or a `bind_brink_query` result. Resolves the
908/// value's target via [`Value::fn_target`], then inspects the same
909/// `EffectRows` row [`check_named_condition_purity`] does.
910///
911/// Same empty-`effect_rows` bypass as [`check_named_condition_purity`]. Same
912/// `manifest`/`bindings` threading (issues #1040/#1609) as
913/// [`check_named_condition_purity`].
914///
915/// # Errors
916/// See [`WakeConditionPurityError`]. [`WakeConditionPurityError::NotAFunctionValue`]
917/// if `value` isn't a function value at all.
918pub fn check_value_condition_purity<M: Send + Sync + 'static>(
919 program: &Program,
920 effect_rows: &[EffectRowEntry],
921 manifest: &CapabilityManifest,
922 bindings: Option<&BrinkBindings<M>>,
923 value: &Value,
924) -> Result<(), WakeConditionPurityError> {
925 if effect_rows.is_empty() {
926 return Ok(());
927 }
928 let def = value
929 .fn_target()
930 .ok_or(WakeConditionPurityError::NotAFunctionValue)?;
931 let label = program
932 .divert_target_path(def)
933 .unwrap_or_else(|| format!("<{def}>"));
934 let row = effect_row_for(effect_rows, def).ok_or_else(|| {
935 WakeConditionPurityError::MissingEffectRow {
936 condition: label.clone(),
937 }
938 })?;
939 check_row_purity(program, row, manifest, bindings, &label)
940}
941
942// ── Row-directed wake dirtying (issue #1146, the #1101 fix) ─────────────────
943//
944// A wake condition's **read row** is the dependency set the scheduler needs:
945// re-evaluate a parked policy only when a cell the condition actually reads
946// was written (`docs/effects-spec.md` §11's rows, consumed for scheduler
947// precision). The changed-cell side is `crate::wake_delta`; this side turns a
948// condition into the read set to intersect it with.
949
950/// What a wake condition's effect row says it may read.
951#[derive(Debug, Clone, PartialEq, Eq)]
952enum ConditionReads {
953 /// The row could not be consulted, or does not bound the reads: no
954 /// `ProgramAsset` loaded, a story with no `EffectRows` table at all
955 /// (converter-built — the same bypass the purity gate takes), a condition
956 /// that doesn't resolve, a missing row, an **opaque** row (§3's pessimal
957 /// top — "touches every cell"), or a listed read whose global cell can't
958 /// be resolved to a slot. Any change re-evaluates: over-report, never
959 /// under-report.
960 Unknown,
961 /// The condition reads exactly these global slot indices (possibly none —
962 /// a condition that reads no cell can only be moved by something the row
963 /// does model as a dependency, e.g. a capability component).
964 Globals(BTreeSet<u32>),
965}
966
967/// Resolve `sleep`'s condition to the global slots its effect row says it may
968/// read. See [`ConditionReads::Unknown`] for every case that degrades to the
969/// conservative "assume it reads everything" answer.
970///
971/// Global cells are returned as **slot indices** (not `DefinitionId`s) so the
972/// caller can intersect directly against the [`WorldDelta`] a batch Apply
973/// records, which is keyed by the same `World::set_global` numbering.
974fn condition_reads<M: Send + Sync + 'static>(
975 asset: Option<&ProgramAsset>,
976 sleep: &FlowSleep<M>,
977) -> ConditionReads {
978 let Some(asset) = asset else {
979 return ConditionReads::Unknown;
980 };
981 if asset.effect_rows.is_empty() {
982 return ConditionReads::Unknown;
983 }
984 let def = if let Some(value) = &sleep.condition_value {
985 value.fn_target()
986 } else {
987 asset.program.definition_id_for_path(&sleep.condition)
988 };
989 let Some(def) = def else {
990 return ConditionReads::Unknown;
991 };
992 let Some(row) = effect_row_for(&asset.effect_rows, def) else {
993 return ConditionReads::Unknown;
994 };
995 if row.direct.opaque
996 || row
997 .dispatches
998 .iter()
999 .any(|dispatch| dispatch.fallback.opaque)
1000 {
1001 return ConditionReads::Unknown;
1002 }
1003
1004 let mut slots = BTreeSet::new();
1005 // v1 emits no dispatch entries (call-through-value folds into the direct
1006 // part), but a populated dispatch list round-trips — fold each static
1007 // fallback's reads in exactly as the purity gate folds its writes.
1008 let reads = row
1009 .direct
1010 .reads
1011 .iter()
1012 .chain(row.dispatches.iter().flat_map(|d| d.fallback.reads.iter()));
1013 for id in reads {
1014 // `DefinitionId` → slot index via the program's own global table. A
1015 // read the loaded program doesn't declare (a stale row, a `VAR` a
1016 // story patch removed) can't be proven unchanged, so it degrades the
1017 // whole row rather than being silently dropped.
1018 let Some(slot) = asset.program.global_slot(*id) else {
1019 return ConditionReads::Unknown;
1020 };
1021 slots.insert(slot);
1022 }
1023 ConditionReads::Globals(slots)
1024}
1025
1026/// Does `delta` (a complete account of the shared world's changes this
1027/// window) touch anything `sleep`'s condition reads?
1028///
1029/// The bookkeeping bit is matched against the host's
1030/// [`FlowSleep::reads_bookkeeping`] declaration, not the row: effect rows
1031/// model global cells only, so a visit-count/turn-index read is invisible to
1032/// them (see that builder's docs). An [`ConditionReads::Unknown`] row skips
1033/// the question entirely and re-evaluates on any change at all.
1034fn delta_touches_condition<M: Send + Sync + 'static>(
1035 delta: &WorldDelta,
1036 reads: &ConditionReads,
1037 sleep: &FlowSleep<M>,
1038) -> bool {
1039 match reads {
1040 ConditionReads::Unknown => !delta.is_empty(),
1041 ConditionReads::Globals(slots) => {
1042 (delta.touched_bookkeeping() && sleep.reads_bookkeeping)
1043 || delta.globals().iter().any(|slot| slots.contains(slot))
1044 }
1045 }
1046}
1047
1048/// Ink truthiness for a wake condition's return value: a `Bool(true)`, a
1049/// nonzero `Int`, or a nonzero `Float` wakes the flow. Every other value
1050/// (including `Null` and non-numeric types) is treated as **false** —
1051/// conservative: a malformed condition parks rather than spuriously wakes.
1052fn is_condition_true(value: &Value) -> bool {
1053 match value {
1054 Value::Bool(b) => *b,
1055 Value::Int(n) => *n != 0,
1056 Value::Float(f) => *f != 0.0,
1057 _ => false,
1058 }
1059}
1060
1061/// Decide whether a parked policy needs its condition re-evaluated this pass,
1062/// given the two change signals `mark_wake_dirty` can observe: the shared ink
1063/// World ([`BrinkGlobals`], `world_changed`) and — new in #996 — the
1064/// per-capability component-tick verdict [`CapabilityChanges`] (§12.5).
1065///
1066/// The cases, in order:
1067///
1068/// - **Never evaluated yet** (`!evaluated_once`): always re-evaluate. A
1069/// dormant policy must get its first evaluation even on a quiet frame.
1070/// - **No external-capability dependency** ([`DetectSummary::bits`] empty): the
1071/// condition reads only ink World state, so `world_changed` is the whole
1072/// signal — re-evaluate only when it is set. Since #1146 that flag is
1073/// already **row-directed** where the caller could prove it (see
1074/// [`mark_wake_dirty`]): it means "a cell this condition reads moved", not
1075/// merely "something in `BrinkGlobals` moved".
1076/// - **Any dependency capability is must-poll** (`#913` AND-merge folded a bit
1077/// to `false`, so `all_detect_capable` is `false`): re-evaluate every pass;
1078/// that capability's reads are not change-detection-backed.
1079/// - **Every dependency capability is change-detection-capable** (`#913`
1080/// verdict all-`true`, `bits` non-empty): the §12.5 cheap path. Re-evaluate
1081/// only if the shared World changed (the condition may also read ink globals)
1082/// **or** one of the watched components' change ticks advanced this pass
1083/// ([`CapabilityChanges`]). A capability the wake layer cannot observe —
1084/// unregistered (no [`CapabilityRegistry::type_id`]), or tracked but with no
1085/// verdict recorded yet ([`CapabilityChanges::changed`] returns `None`) —
1086/// folds to a conservative must-poll: it cannot prove the component is
1087/// unchanged, and a missed wake is the engine-race bug class (over-report,
1088/// never under-report — §3 soundness direction).
1089fn wake_needs_reeval<M: Send + Sync + 'static>(
1090 sleep: &FlowSleep<M>,
1091 registry: &CapabilityRegistry<M>,
1092 changes: &CapabilityChanges<M>,
1093 world_changed: bool,
1094) -> bool {
1095 if !sleep.evaluated_once {
1096 return true;
1097 }
1098 let detect = &sleep.detect;
1099 if detect.bits.is_empty() {
1100 // No external-capability dependency (`bits` empty is vacuously
1101 // all-detect-capable — see `DetectSummary::from_bits`): the shared ink
1102 // World is the only signal, so re-evaluate exactly when it changed.
1103 return world_changed;
1104 }
1105 if !detect.all_detect_capable {
1106 return true;
1107 }
1108 // All dependency capabilities are change-detection-capable and non-empty:
1109 // the §12.5 cheap path.
1110 if world_changed {
1111 return true;
1112 }
1113 detect.bits.keys().any(|name| {
1114 // Untracked (name unregistered, or no verdict recorded yet) → `true`
1115 // (conservative must-poll); tracked → the recorded changed bit.
1116 registry
1117 .type_id(name)
1118 .and_then(|ty| changes.changed(ty))
1119 .unwrap_or(true)
1120 })
1121}
1122
1123/// Ordinary (non-exclusive) system: flag which parked policies need their
1124/// condition re-evaluated this frame, consuming the `#913` `detect` verdict and
1125/// the change signals it can observe.
1126///
1127/// - The [`BrinkWorldDelta`] changed-cell ledger (issue #1146) covers
1128/// conditions that read the shared ink World **per cell**: a policy is
1129/// flagged only when a global its condition's effect row lists as a read was
1130/// actually written this window, so a turn that only bumped visit counts
1131/// leaves a `gate`-reading condition alone (the #1101 spurious re-wake).
1132/// Bookkeeping reads are host-declared ([`FlowSleep::reads_bookkeeping`]) —
1133/// rows model global cells only. When the ledger cannot account for the
1134/// whole window (a serial driver, a direct host write into
1135/// [`BrinkGlobals::inner`]) it degrades to the coarse signal below, and so
1136/// does a condition whose row is missing or opaque.
1137/// - [`BrinkGlobals`] change detection is that coarse signal: any change
1138/// re-checks every parked all-detect-capable policy.
1139/// - The per-capability component-tick verdict [`CapabilityChanges`] (§12.5,
1140/// #996) covers component-backed **detect-capable** conditions — e.g. an
1141/// `is_player_nearby` reading `Transform`, or a door's `should_open` reading
1142/// a `Switch` — so they re-evaluate only when the watched component actually
1143/// changed, not every frame. A [`detect_capability_changes`](crate::capability::detect_capability_changes)
1144/// tracker (wired per registered component by `register_capability`, ordered
1145/// before this system) supplies that verdict.
1146///
1147/// The per-policy decision is [`wake_needs_reeval`]; see it for the exact
1148/// cadence and the conservative-must-poll fallback for capabilities this layer
1149/// cannot observe. Only [`Parked`](SleepState::Parked) policies are touched;
1150/// woken, cancelled, and faulted policies are left alone.
1151#[expect(
1152 clippy::needless_pass_by_value,
1153 reason = "bevy systems take Res/Query by value"
1154)]
1155pub fn mark_wake_dirty<M: Send + Sync + 'static>(
1156 globals: Option<Res<BrinkGlobals<M>>>,
1157 registry: Res<CapabilityRegistry<M>>,
1158 changes: Res<CapabilityChanges<M>>,
1159 wake_delta: Option<ResMut<BrinkWorldDelta<M>>>,
1160 // Optional so the system still runs in a bare `App` with no `AssetPlugin`
1161 // (the unit tests below drive it that way); absent, every condition's read
1162 // row is `Unknown` and the pass stays conservative.
1163 programs: Option<Res<Assets<ProgramAsset>>>,
1164 mut sleepers: Query<(&mut FlowSleep<M>, Option<&BrinkProgram<M>>)>,
1165) {
1166 let coarse_changed = globals.as_ref().is_some_and(DetectChanges::is_changed);
1167 let globals_tick = globals.as_ref().map(DetectChanges::last_changed);
1168 // Issue #1146: drain the changed-cell ledger once per pass, whatever the
1169 // sleepers turn out to need. `Some` means it is a complete account of
1170 // every shared-world change since this system last ran, so it *replaces*
1171 // the coarse resource-level bit rather than refining it; `None` (a serial
1172 // driver, a direct host write, a frame no batch turn ran) falls back to
1173 // that bit exactly as before this fix.
1174 let delta = wake_delta
1175 .map(ResMut::into_inner)
1176 .and_then(|ledger| ledger.drain(globals_tick, coarse_changed));
1177 for (mut sleep, program_ref) in &mut sleepers {
1178 if sleep.state != SleepState::Parked {
1179 continue;
1180 }
1181 let world_changed = match &delta {
1182 None => coarse_changed,
1183 Some(delta) => {
1184 // Only pay for the row lookup when something was actually
1185 // written this window.
1186 !delta.is_empty() && {
1187 let asset = program_ref
1188 .zip(programs.as_ref())
1189 .and_then(|(program_ref, assets)| assets.get(&program_ref.handle));
1190 let reads = condition_reads(asset, &sleep);
1191 delta_touches_condition(delta, &reads, &sleep)
1192 }
1193 }
1194 };
1195 if wake_needs_reeval(&sleep, ®istry, &changes, world_changed) && !sleep.needs_eval {
1196 sleep.needs_eval = true;
1197 }
1198 }
1199}
1200
1201/// One parked flow scheduled for condition re-evaluation this pass.
1202struct WakeCandidate {
1203 entity: Entity,
1204 /// The named condition to resolve by path — ignored (a diagnostic label
1205 /// only) when `condition_value` is `Some`.
1206 condition: String,
1207 /// A dynamically-resolved fn-value token (issue #1078): when present,
1208 /// evaluation invokes it directly instead of resolving `condition` by
1209 /// path.
1210 condition_value: Option<Value>,
1211 args: Vec<Value>,
1212}
1213
1214/// What to do with a woken/dead policy once its turn boundary is reached.
1215enum ReparkAction {
1216 /// Persistent policy re-parks: back to [`SleepState::Parked`].
1217 Rearm,
1218 /// One-shot policy (or a dead `-> END` flow): remove the component.
1219 Remove,
1220}
1221
1222/// The gather query [`run_flow_sleep`] caches across frames (kept as a
1223/// `type` alias to satisfy `clippy::type_complexity` on the `Local` param).
1224type SleepGatherQuery<M> = QueryState<(
1225 Entity,
1226 &'static FlowSleep<M>,
1227 &'static BrinkFlow<M>,
1228 &'static BrinkProgram<M>,
1229)>;
1230
1231/// Exclusive system: re-evaluate flagged wake conditions in each flow's own
1232/// context, wake on true, and re-arm/remove policies at turn boundaries
1233/// (`docs/effects-spec.md` §13.1). Gated by the plugin on
1234/// `any_with_component::<FlowSleep<M>>`, so it does no work when no flow sleeps.
1235///
1236/// Order-independent w.r.t. [`advance_batch`](crate::advance_batch): whether it
1237/// runs before or after the batch driver in a frame, a wake takes effect on the
1238/// following frame's Collect.
1239#[expect(
1240 clippy::too_many_lines,
1241 reason = "four coherent phases (gather, purity faults, re-park/retire, evaluate) that share \
1242 locals across the whole pass; splitting would just move the length into extra \
1243 parameter-passing"
1244)]
1245pub fn run_flow_sleep<M: Send + Sync + 'static>(
1246 world: &mut EcsWorld,
1247 // Cache the gather query across frames instead of rebuilding a fresh
1248 // `QueryState` (archetype match + component-id resolution) on every call
1249 // — the BH-5 prefetch pattern (#937 lineage; #1007 secondary). `iter`
1250 // still folds in any archetypes added since the last frame, so newly
1251 // spawned sleeping flows are picked up.
1252 mut gather: Local<SleepGatherQuery<M>>,
1253) {
1254 // ── Gather ── inspect (FlowSleep, BrinkFlow, BrinkProgram) without
1255 // holding the borrow across the `call_ink_function` re-entries below.
1256 // Only entities that are fully fulfilled flows carry `BrinkFlow`, so
1257 // unfulfilled requests are never touched (no spurious NotAFlow faults).
1258 let mut candidates: Vec<WakeCandidate> = Vec::new();
1259 let mut reparks: Vec<(Entity, ReparkAction)> = Vec::new();
1260 // Purity faults (issue #995): a condition rejected before its first
1261 // evaluation this pass — never called even once. Named separately from
1262 // `reparks` because it needs the (distinct) `WakeConditionPurityError` to
1263 // log, not just a re-arm/remove action.
1264 let mut purity_faults: Vec<(Entity, WakeConditionPurityError)> = Vec::new();
1265 {
1266 let programs = world.resource::<Assets<ProgramAsset>>();
1267 // The manifest is app-global (not per-marker `M` — `CapabilityRegistry`
1268 // is, but `CapabilityManifest` is the one host-authored table every
1269 // marker's stories share, per `crate::capability`'s module docs), so a
1270 // single fetch here covers every candidate this pass gathers.
1271 let manifest = world.resource::<CapabilityManifest>();
1272 // The `BrinkBindings<M>` registry (issue #1609): `None` if the host
1273 // never registered any `bind_brink_*` binding for this marker (the
1274 // resource is inserted lazily) — `check_named_condition_purity`/
1275 // `check_value_condition_purity` treat that the same as an empty
1276 // `commands` bucket.
1277 let bindings = world.get_resource::<BrinkBindings<M>>();
1278 for (entity, sleep, flow, program_ref) in gather.iter(world) {
1279 let status = flow.inner.status();
1280 // An `-> END` flow is dead; the policy is inert (§13.1 point 5).
1281 if status == StoryStatus::Ended {
1282 reparks.push((entity, ReparkAction::Remove));
1283 continue;
1284 }
1285 match sleep.state {
1286 SleepState::Cancelled | SleepState::Faulted => {}
1287 SleepState::Woken => {
1288 // The woken turn finished (reached a natural yield): re-arm
1289 // or retire. If it is still mid-turn / parked on an external
1290 // it is left as-is for that resume path.
1291 if status == StoryStatus::Done {
1292 reparks.push((
1293 entity,
1294 match sleep.arming {
1295 WakeArming::Persistent | WakeArming::Latch => ReparkAction::Rearm,
1296 WakeArming::Once => ReparkAction::Remove,
1297 },
1298 ));
1299 }
1300 }
1301 SleepState::Parked => {
1302 // Policy applies to turn-boundary parks only; a dormant
1303 // policy is additionally eligible before its first turn.
1304 let eligible = sleep.dormant || status == StoryStatus::Done;
1305 if eligible && sleep.needs_eval {
1306 // Purity gate (issue #995, §13.1 point 2): admitted
1307 // into `candidates` only if the condition's effect row
1308 // proves no writes. A missing `ProgramAsset` (the
1309 // story unloaded mid-frame) just skips this pass —
1310 // `needs_eval` stays set and it's retried once the
1311 // asset is back.
1312 //
1313 // A dynamically-resolved fn-value token (issue #1078:
1314 // `FlowSleep::with_condition_value`) is checked via
1315 // `check_value_condition_purity` — the same row
1316 // inspection as the named path, just resolved through
1317 // the value's own target instead of a path lookup.
1318 if let Some(asset) = programs.get(&program_ref.handle) {
1319 let purity = if let Some(value) = &sleep.condition_value {
1320 check_value_condition_purity(
1321 &asset.program,
1322 &asset.effect_rows,
1323 manifest,
1324 bindings,
1325 value,
1326 )
1327 } else {
1328 check_named_condition_purity(
1329 &asset.program,
1330 &asset.effect_rows,
1331 manifest,
1332 bindings,
1333 &sleep.condition,
1334 )
1335 };
1336 match purity {
1337 Ok(()) => candidates.push(WakeCandidate {
1338 entity,
1339 condition: sleep.condition.clone(),
1340 condition_value: sleep.condition_value.clone(),
1341 args: sleep.args.clone(),
1342 }),
1343 Err(err) => purity_faults.push((entity, err)),
1344 }
1345 }
1346 }
1347 }
1348 }
1349 }
1350 }
1351
1352 // ── Purity faults ── reject before evaluation so an impure condition is
1353 // never called, not even once (issue #995). Faulted the same way a
1354 // runtime eval error is (never silently retried into a spin) — logged
1355 // with its own distinct, named error so the two failure classes are
1356 // never confused.
1357 for (entity, err) in purity_faults {
1358 if let Some(mut sleep) = world.get_mut::<FlowSleep<M>>(entity)
1359 && sleep.state == SleepState::Parked
1360 {
1361 warn!(
1362 "brink wake condition `{}` rejected for flow {:?}: {err} — policy parked \
1363 (Faulted); a FlowSleep condition must be pure (docs/effects-spec.md §13.1 \
1364 point 2)",
1365 sleep.condition, entity
1366 );
1367 sleep.state = SleepState::Faulted;
1368 sleep.needs_eval = false;
1369 }
1370 }
1371
1372 // ── Re-park / retire ── apply before evaluation so a persistent flow that
1373 // just finished its woken turn is Parked again and can re-wake this pass.
1374 for (entity, action) in reparks {
1375 match action {
1376 ReparkAction::Rearm => {
1377 if let Some(mut sleep) = world.get_mut::<FlowSleep<M>>(entity) {
1378 sleep.state = SleepState::Parked;
1379 sleep.dormant = false;
1380 sleep.needs_eval = false;
1381 }
1382 }
1383 ReparkAction::Remove => {
1384 if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
1385 entity_mut.remove::<FlowSleep<M>>();
1386 }
1387 }
1388 }
1389 }
1390
1391 // ── Evaluate ── each condition runs in its owning flow's context (shared
1392 // World ⊕ that flow's locals) and cannot advance the visible story.
1393 //
1394 // Issue #1146: building that context needs `&mut BrinkGlobals<M>`, which
1395 // trips bevy's change detection the instant the reference is taken —
1396 // even though the condition is purity-gated above and provably writes no
1397 // global cell. Left alone that is a **self-sustaining** wake signal: an
1398 // evaluation marks the world changed, the change re-flags the same
1399 // policy next frame, and it evaluates forever (and, worse, poisons the
1400 // changed-cell ledger's attribution for every *other* sleeper under `M`,
1401 // since a batch driver cannot tell that write apart from a host's).
1402 // Snapshot the change tick around the phase and restore it afterwards;
1403 // the one thing an evaluation can still legitimately move — bookkeeping
1404 // (a counted container's visit count, an RNG draw), which no effect row
1405 // models — is recorded in the ledger instead, so a policy that declares
1406 // it reads bookkeeping still sees it.
1407 if candidates.is_empty() {
1408 return;
1409 }
1410 let globals_tick = world
1411 .get_resource_ref::<BrinkGlobals<M>>()
1412 .map(|globals| globals.last_changed());
1413
1414 for candidate in candidates {
1415 // A dynamically-resolved fn-value token (issue #1078) invokes
1416 // directly; a named condition resolves by path — mirrors the purity
1417 // gate's own branch above.
1418 let outcome = if let Some(value) = &candidate.condition_value {
1419 call_ink_function_value::<M>(world, candidate.entity, value, &candidate.args)
1420 } else {
1421 call_ink_function::<M>(
1422 world,
1423 candidate.entity,
1424 &candidate.condition,
1425 &candidate.args,
1426 )
1427 };
1428 let Some(mut sleep) = world.get_mut::<FlowSleep<M>>(candidate.entity) else {
1429 continue;
1430 };
1431 // A concurrent cancel between gather and evaluate must win.
1432 if sleep.state != SleepState::Parked {
1433 continue;
1434 }
1435 sleep.needs_eval = false;
1436 sleep.evaluated_once = true;
1437 match outcome {
1438 Ok(value) => {
1439 let raw = is_condition_true(&value);
1440 // `Persistent`/`Once` wake on a plain true reading; `Latch`
1441 // (issue #1081) wakes only on the edge it is currently
1442 // watching for (`waiting_for`), then flips that target so
1443 // the next wake requires the opposite edge.
1444 let fires = match sleep.arming {
1445 WakeArming::Persistent | WakeArming::Once => raw,
1446 WakeArming::Latch => raw == sleep.waiting_for,
1447 };
1448 if fires {
1449 // Wake: Collect steps it next turn. `dormant` is cleared
1450 // when the woken turn re-parks (or on removal for Once).
1451 sleep.state = SleepState::Woken;
1452 if sleep.arming == WakeArming::Latch {
1453 sleep.waiting_for = !sleep.waiting_for;
1454 }
1455 }
1456 }
1457 Err(err) => {
1458 warn!(
1459 "brink wake condition `{}` faulted for flow {:?}: {err} — \
1460 policy parked (Faulted); host must clear or replace it",
1461 candidate.condition, candidate.entity
1462 );
1463 sleep.state = SleepState::Faulted;
1464 }
1465 }
1466 }
1467
1468 // Restore the pre-evaluation change tick (see the phase comment above)
1469 // and hand the ledger the conservative bookkeeping touch that replaces
1470 // it. Both are no-ops when the marker has neither resource.
1471 if let Some(tick) = globals_tick
1472 && let Some(mut globals) = world.get_resource_mut::<BrinkGlobals<M>>()
1473 {
1474 globals.set_last_changed(tick);
1475 }
1476 if let Some(mut ledger) = world.get_resource_mut::<BrinkWorldDelta<M>>() {
1477 ledger.record_condition_evaluation();
1478 }
1479}
1480
1481#[cfg(test)]
1482mod tests;