Skip to main content

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, &registry, &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;