Skip to main content

hydro_lang/sim/
hooks.rs

1//! Test-side API for **simulator hooks**: scripting the decisions of unsafe operators.
2//!
3//! A hook handle (see [`crate::sim_hooks`]) is created from the
4//! [`FlowBuilder`](crate::compile::builder::FlowBuilder) via
5//! [`FlowBuilder::sim_hook`](crate::compile::builder::FlowBuilder::sim_hook) and attached
6//! to one specific unsafe operator with `nondet!(/** reason */ hook = handle)`. Inside a
7//! simulation test body (under [`SimFlow::deterministic`](crate::sim::flow::SimFlow::deterministic),
8//! [`fuzz`](crate::sim::flow::SimFlow::fuzz), or
9//! [`exhaustive`](crate::sim::flow::SimFlow::exhaustive)), the handle scripts the
10//! operator's decisions.
11//!
12//! # The script is a schedule
13//!
14//! Decision calls are `async`, and the sequence of calls in the test body is a
15//! **schedule**, read in program order:
16//!
17//! - Consecutive decisions that target *different hooks of the same tick* form a
18//!   **group**: one execution of that tick will consume all of them together.
19//! - A decision that targets a different tick — or the *next execution* of the same tick
20//!   (scripting a hook that already has a decision in the current group) — starts a new
21//!   group. The `.await` on the first decision of a new group suspends the test until the
22//!   previous group's tick execution has actually happened, so the test body advances in
23//!   lockstep with the execution it describes. Decisions whose turn has already come
24//!   return immediately without suspending.
25//! - Output awaits are group barriers: an output await completes only after every
26//!   decision scripted so far has been consumed.
27//!
28//! A decision may be scripted before its data exists (`release(3)` immediately after
29//! `send_many([1, 2, 3])`): the tick simply fires at the first moment the decision can be
30//! honored in full. A decision that can *never* be honored is reported when the
31//! simulation runs out of other work, attributed to the test line that is suspended
32//! waiting on it.
33//!
34//! # Holding data on purpose
35//!
36//! A hook with buffered data and no decision is an error the simulator reports at every
37//! scheduling boundary. When buffering *is* the scenario, declare it with the `pause`
38//! family ([`BatchHook::pause`], [`BatchHook::pause_while`],
39//! [`BatchHook::pause_until_count`], [`BatchHook::auto_pause`], and the snapshot
40//! equivalents).
41//!
42//! # Hooks on clusters
43//!
44//! A handle bound to an operator running on a cluster controls one independent hook
45//! instance per member; its [`OnCluster`] scope requires selecting the instance to
46//! script with `.on(member_id)` (e.g. `hook.on(0).release(2).await`). Decisions for
47//! different members never share a group — each member's execution takes its own place
48//! in the schedule — and every member's instance is independently subject to the
49//! missing-decision error.
50
51use std::future::Future;
52use std::marker::PhantomData;
53use std::pin::Pin;
54use std::task::{Context, Poll};
55
56use serde::Serialize;
57use serde::de::DeserializeOwned;
58
59use crate::live_collections::boundedness::{Bounded, Unbounded};
60use crate::live_collections::stream::{
61    AtLeastOnce, ExactlyOnce, NoOrder, Ordering, Retries, TotalOrder,
62};
63use crate::sim::compiled::{
64    ScheduleDecision, script_ctx, script_stuck_error, script_unconsumed_description,
65};
66use crate::sim::runtime::{
67    AtLeastOnceOrderingDecision, BatchDecision, InlineOrderingDecision, InlineRetriesDecision,
68    KeyedBatchDecision, KeyedSnapshotDecision, MergeDecision, NoOrderRetriesDecision,
69    OrderedRetriesDecision, ScriptDecision, SnapshotDecision, TopLevelOrderingDecision,
70    UnorderedBatchDecision, UnorderedKeyedBatchDecision,
71};
72pub use crate::sim::runtime::{
73    BatchStatus, KeyedSnapshotStatus, MergeStatus, OrderingStatus, SnapshotStatus,
74};
75pub use crate::sim_hooks::{
76    BatchHook, BindableHookScope, KeyedBatchHook, KeyedMergeOrderedHook, KeyedOrderingHook,
77    KeyedSnapshotHook, MergeOrderedHook, OnCluster, OnMember, OnProcess, OrderingHook,
78    PartialOrderingHook, RetriesHook, ScriptableHookScope, SimHook, SnapshotHook,
79};
80
81/// A scripted decision that has been issued but not yet installed into the schedule.
82///
83/// Awaiting it suspends the test until every previously scripted tick execution the
84/// decision must come after has actually happened (see the module docs); it resolves once
85/// the decision is installed for its tick's next execution. Panics (at the `.await`'s
86/// location) if the decision can never take its place in the schedule.
87#[must_use = "a scripted decision does nothing until awaited"]
88pub struct DecisionFuture {
89    hook_id: usize,
90    /// The cluster member whose hook instance the decision targets (from `.on(..)` on
91    /// the handle); `None` for hooks on processes.
92    member: Option<u32>,
93    /// The decision, MessagePack-serialized (the handle and the hook it is bound to
94    /// statically know the same decision type). `None` once installed.
95    blob: Option<Vec<u8>>,
96}
97
98impl DecisionFuture {
99    fn new(hook_id: usize, member: Option<u32>, decision: &impl ScriptDecision) -> Self {
100        DecisionFuture {
101            hook_id,
102            member,
103            blob: Some(rmp_serde::to_vec_named(decision).unwrap()),
104        }
105    }
106}
107
108/// Panics (at the scripting call site) when a per-key decision names the same key more
109/// than once, establishing the no-duplicate-keys invariant of the keyed decisions before
110/// they are installed.
111#[track_caller]
112fn assert_distinct_keys<'a, K: std::hash::Hash + Eq + 'a>(
113    keys: impl Iterator<Item = &'a K>,
114    method: &str,
115) {
116    let mut seen: dfir_rs::rustc_hash::FxHashSet<&K> = Default::default();
117    for (position, key) in keys.enumerate() {
118        assert!(
119            seen.insert(key),
120            "{}: the same key appears more than once in a single decision (duplicate at entry {}); a key takes exactly one decision per tick",
121            method,
122            position
123        );
124    }
125}
126
127/// Panics (at the scripting call site) when a retries decision requests zero copies:
128/// releasing an element zero times would be a *loss*, which `AtLeastOnce` does not
129/// permit.
130#[track_caller]
131fn assert_copies_at_least_one(copies: usize) {
132    assert!(
133        copies >= 1,
134        "the number of copies must be at least 1 (at-least-once permits duplicates, not losses)"
135    );
136}
137
138impl Future for DecisionFuture {
139    type Output = ();
140
141    #[track_caller]
142    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
143        let this = self.get_mut();
144        let Some(blob) = this.blob.take() else {
145            return Poll::Ready(());
146        };
147
148        let ctx = script_ctx();
149        match ctx.try_schedule_decision(this.hook_id, this.member, blob) {
150            Ok(ScheduleDecision::Installed) => Poll::Ready(()),
151            Ok(ScheduleDecision::Wait(blob)) => {
152                this.blob = Some(blob);
153                // While the script waits, the rest of the simulation does not: the
154                // scheduler keeps freely choosing which *other* ticks run. The test body
155                // is re-polled after every scheduler step; the waker is only needed for
156                // the parked (quiescent) case.
157                ctx.push_park_waker(cx.waker());
158                Poll::Pending
159            }
160            Err(message) => panic!("{}", message),
161        }
162    }
163}
164
165/// A `pause_until` wait: resolves once the hook's pending-input status satisfies the
166/// predicate, un-pausing the hook. The status is read from the hook **on demand** at every
167/// poll (the test body is polled between every pair of scheduler steps), so the wait
168/// resolves at the first scheduling point where the predicate holds. Panics (at the
169/// `.await`'s location) if the simulation can no longer satisfy it.
170#[must_use = "the pause is only released once this future is awaited"]
171pub struct PauseUntilFuture<S, F> {
172    hook_id: usize,
173    /// The cluster member whose hook instance is paused; `None` for hooks on processes.
174    member: Option<u32>,
175    /// What the wait is called in error messages (e.g. `pause_until_count(3)`).
176    label: String,
177    predicate: F,
178    _status: PhantomData<fn(S)>,
179}
180
181impl<S: DeserializeOwned, F: Fn(&S) -> bool + Unpin> Future for PauseUntilFuture<S, F> {
182    type Output = ();
183
184    #[track_caller]
185    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
186        let this = self.get_mut();
187        let ctx = script_ctx();
188
189        // A `pause_until` wait is a script barrier, like an output await: it must not
190        // resolve (dropping the hold) while an earlier decision group is unconsumed,
191        // or the next decision would spuriously overlap the outstanding group and the
192        // boundary scan would see the exposed hook mid-group. And if that group is
193        // stuck, it is the root cause — report it instead of blaming the predicate.
194        if let Some(stuck) = script_unconsumed_description() {
195            assert!(!ctx.is_quiescent(), "{}", script_stuck_error(&stuck));
196            ctx.push_park_waker(cx.waker());
197            return Poll::Pending;
198        }
199
200        let hook = ctx.control(this.hook_id, this.member);
201
202        let status: S = rmp_serde::from_slice(&hook.borrow().status_blob())
203            .expect("internal error: hook status blob did not match the handle's status type");
204
205        if (this.predicate)(&status) {
206            // The wait is satisfied; the hook is unpaused (the ordinary missing-decision
207            // error applies from here on).
208            hook.borrow_mut().release_hold();
209            Poll::Ready(())
210        } else if ctx.is_quiescent() {
211            let hook = hook.borrow();
212            let loc = hook.location_meta().location;
213            let member = this
214                .member
215                .map(|m| format!(" (cluster member {m})"))
216                .unwrap_or_default();
217            panic!(
218                "{} can never be satisfied: the hook at {}{} has {} and the simulation has no more work it can do",
219                this.label,
220                loc,
221                member,
222                hook.describe_pending()
223                    .unwrap_or_else(|| "no pending input".to_owned()),
224            );
225        } else {
226            ctx.push_park_waker(cx.waker());
227            Poll::Pending
228        }
229    }
230}
231
232/// RAII guard for [`BatchHook::pause_while`] / [`SnapshotHook::pause_while`]: ends the
233/// hold when dropped (even on panic), leaving a standing `auto_pause` hold in place.
234struct PauseGuard {
235    hook_id: usize,
236    member: Option<u32>,
237}
238
239impl Drop for PauseGuard {
240    fn drop(&mut self) {
241        let ctx = script_ctx();
242        let hook = ctx.control(self.hook_id, self.member);
243        hook.borrow_mut().release_hold();
244    }
245}
246
247macro_rules! pause_family {
248    ($status:ty) => {
249        /// Declares that buffering at this operator is intended: while paused, the hook is
250        /// exempt from the missing-decision error, never causes its tick to run, and — if
251        /// its tick runs anyway because *other* hooks feed it — contributes its "nothing
252        /// new" behavior each time. Scripting any decision implicitly resumes the hook.
253        ///
254        /// A pause takes its place in the script like everything else: requested while a
255        /// decision is still pending, the hold begins once that decision has been
256        /// consumed.
257        pub fn pause(&self) {
258            let ctx = script_ctx();
259            ctx.control(self.id, self.member)
260                .borrow_mut()
261                .set_hold(true);
262        }
263
264        /// Ends a [`Self::pause`] (and clears [`Self::auto_pause`] mode).
265        pub fn resume(&self) {
266            let ctx = script_ctx();
267            let hook = ctx.control(self.id, self.member);
268            let mut hook = hook.borrow_mut();
269            hook.set_auto_pause(false);
270            hook.set_hold(false);
271        }
272
273        /// Sets a standing mode where this hook only ever acts when scripted: it holds
274        /// immediately, and every scripted decision leaves a fresh hold in place behind
275        /// it.
276        ///
277        /// This deliberately opts out of the forgotten-hook protection: if the test
278        /// forgets a step, the operator silently holds its data instead of failing. The
279        /// one `auto_pause()` line at the top of a test is the reviewer-visible marker
280        /// that this hook's timing is entirely script-driven, missed steps and all.
281        pub fn auto_pause(&self) {
282            let ctx = script_ctx();
283            let hook = ctx.control(self.id, self.member);
284            let mut hook = hook.borrow_mut();
285            hook.set_auto_pause(true);
286            hook.set_hold(true);
287        }
288
289        /// Pauses the hook exactly for the duration of `body` (resuming even on panic), so
290        /// a bracketed buffering phase cannot leak a paused hook.
291        pub async fn pause_while<Fut: Future>(&self, body: Fut) -> Fut::Output {
292            self.pause();
293            let _guard = PauseGuard {
294                hook_id: self.id,
295                member: self.member,
296            };
297            body.await
298        }
299
300        /// Pauses the hook and returns a future that resolves once the hook's
301        /// pending-input status satisfies `predicate` — a synchronization point for
302        /// scripts where the right decision is not knowable upfront. The status is read
303        /// on demand at every scheduling point. After the future resolves, the hook is
304        /// unpaused; the ordinary missing-decision error applies from there on.
305        pub fn pause_until(
306            &self,
307            predicate: impl Fn(&$status) -> bool + Unpin,
308        ) -> PauseUntilFuture<$status, impl Fn(&$status) -> bool + Unpin> {
309            self.pause_until_labeled("pause_until(..)".to_owned(), predicate)
310        }
311
312        fn pause_until_labeled<F: Fn(&$status) -> bool + Unpin>(
313            &self,
314            label: String,
315            predicate: F,
316        ) -> PauseUntilFuture<$status, F> {
317            let ctx = script_ctx();
318            ctx.control(self.id, self.member)
319                .borrow_mut()
320                .set_hold(true);
321            PauseUntilFuture {
322                hook_id: self.id,
323                member: self.member,
324                label,
325                predicate,
326                _status: PhantomData,
327            }
328        }
329    };
330}
331
332impl<T, O: Ordering, R: Retries, Scope: ScriptableHookScope> BatchHook<T, O, R, Scope> {
333    pause_family!(BatchStatus);
334
335    /// Pauses the hook and returns a future that resolves once at least `n` elements are
336    /// buffered; see [`Self::pause_until`].
337    pub fn pause_until_count(
338        &self,
339        n: usize,
340    ) -> PauseUntilFuture<BatchStatus, impl Fn(&BatchStatus) -> bool + Unpin> {
341        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
342            status.buffered >= n
343        })
344    }
345}
346
347impl<T, R: Retries, Scope: ScriptableHookScope> BatchHook<T, TotalOrder, R, Scope>
348where
349    T: Serialize + DeserializeOwned + PartialEq,
350{
351    /// Scripts the next batch to be exactly the next `n` buffered elements. The tick
352    /// fires at the first moment the decision can be honored in full.
353    pub fn release(&self, n: usize) -> DecisionFuture {
354        DecisionFuture::new(self.id, self.member, &BatchDecision::<T>::Prefix(n))
355    }
356
357    /// Scripts the next batch to be exactly this sequence of values. Values must match the
358    /// buffered prefix in order: a mismatching available value panics immediately, while a
359    /// matching but incomplete prefix waits for the remaining values to arrive.
360    pub fn release_values(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
361        DecisionFuture::new(
362            self.id,
363            self.member,
364            &BatchDecision::Values(values.into_iter().collect()),
365        )
366    }
367
368    /// Scripts the next batch to be everything that has arrived by the time the tick
369    /// fires. Under fuzzing, the released contents co-vary with the schedule being
370    /// explored; use [`Self::release`] to name them exactly.
371    pub fn release_all(&self) -> DecisionFuture {
372        DecisionFuture::new(self.id, self.member, &BatchDecision::<T>::All)
373    }
374
375    /// Scripts the next batch to be empty, holding everything buffered. Shorthand for
376    /// [`Self::release`]`(0)`.
377    pub fn release_empty(&self) -> DecisionFuture {
378        self.release(0)
379    }
380}
381
382impl<T, R: Retries, Scope: ScriptableHookScope> BatchHook<T, NoOrder, R, Scope>
383where
384    T: Serialize + DeserializeOwned + PartialEq,
385{
386    /// Scripts the next batch to contain exactly this multiset of buffered values. Values
387    /// are matched independently of arrival order; duplicates request the corresponding
388    /// number of equal buffered items. The tick fires once every requested value exists.
389    pub fn release_values(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
390        DecisionFuture::new(
391            self.id,
392            self.member,
393            &UnorderedBatchDecision::Values(values.into_iter().collect()),
394        )
395    }
396
397    /// Scripts the next batch to be everything that has arrived by the time the tick
398    /// fires. Under fuzzing, the released contents co-vary with the schedule being
399    /// explored; use [`Self::release_values`] to name them exactly.
400    pub fn release_all(&self) -> DecisionFuture {
401        DecisionFuture::new(self.id, self.member, &UnorderedBatchDecision::<T>::All)
402    }
403
404    /// Scripts the next batch to be empty, holding everything buffered. Shorthand for
405    /// [`Self::release_values`] with no values.
406    pub fn release_empty(&self) -> DecisionFuture {
407        self.release_values([])
408    }
409}
410
411impl<T, Scope: ScriptableHookScope> OrderingHook<T, Unbounded, ExactlyOnce, Scope>
412where
413    T: Serialize + DeserializeOwned,
414{
415    /// Scripts a top-level `assume_ordering` action to release the buffered element equal
416    /// to `value`. Exactly one element is released, preserving opportunities for ticks and
417    /// feedback to interleave with the remaining buffered input.
418    pub fn next(&self, value: T) -> DecisionFuture {
419        DecisionFuture::new(self.id, self.member, &TopLevelOrderingDecision::Next(value))
420    }
421
422    pause_family!(OrderingStatus);
423
424    /// Pauses a top-level ordering hook until at least `n` elements are buffered; see
425    /// [`Self::pause_until`].
426    pub fn pause_until_count(
427        &self,
428        n: usize,
429    ) -> PauseUntilFuture<OrderingStatus, impl Fn(&OrderingStatus) -> bool + Unpin> {
430        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
431            status.buffered >= n
432        })
433    }
434}
435
436impl<T, Scope: ScriptableHookScope> OrderingHook<T, Bounded, ExactlyOnce, Scope>
437where
438    T: Serialize + DeserializeOwned,
439{
440    /// Scripts an in-tick `assume_ordering` observation to consume its complete input in
441    /// exactly this order. The supplied values must be a permutation of all values received
442    /// by the operator during that tick.
443    pub fn order(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
444        DecisionFuture::new(
445            self.id,
446            self.member,
447            &InlineOrderingDecision::Order(values.into_iter().collect()),
448        )
449    }
450}
451
452impl<T, Scope: ScriptableHookScope> OrderingHook<T, Unbounded, AtLeastOnce, Scope>
453where
454    T: Serialize + DeserializeOwned,
455{
456    /// Scripts a top-level `assume_ordering` action on an at-least-once stream to release
457    /// one **slot** for the value `value`, *keeping* it buffered: later decisions may
458    /// emit it again (a delayed redelivery, still `AtLeastOnce`), after other elements
459    /// have taken slots in between.
460    ///
461    /// The value remains pending input, so the script must eventually retire it with
462    /// [`Self::emit_final`] (or declare the buffering with the pause family). The
463    /// immediately-following decision may name the same value again only when two or
464    /// more buffered elements share it (the adjacent slots then belong to different
465    /// elements): a sole element in adjacent slots would be a redundant duplicate —
466    /// adjacent duplicate slots denote the same at-least-once collection, and per-slot
467    /// *cardinality* belongs to the downstream `assume_retries` observation.
468    pub fn emit(&self, value: T) -> DecisionFuture {
469        DecisionFuture::new(
470            self.id,
471            self.member,
472            &AtLeastOnceOrderingDecision::Emit(value),
473        )
474    }
475
476    /// Scripts a top-level `assume_ordering` action on an at-least-once stream to release
477    /// the *last* slot for the value `value`, consuming **every** buffered element equal
478    /// to it (the script names values, not individual elements, so retirement is
479    /// value-level). Because each buffered element must occupy at least one slot,
480    /// retiring a value that several buffered elements share requires enough prior
481    /// [`Self::emit`]s: `k` equal buffered elements need at least `k` slots in total. A
482    /// value that was never emitted before simply occupies a single slot.
483    pub fn emit_final(&self, value: T) -> DecisionFuture {
484        DecisionFuture::new(
485            self.id,
486            self.member,
487            &AtLeastOnceOrderingDecision::EmitFinal(value),
488        )
489    }
490
491    pause_family!(OrderingStatus);
492
493    /// Pauses a top-level ordering hook until at least `n` elements are buffered; see
494    /// [`Self::pause_until`].
495    pub fn pause_until_count(
496        &self,
497        n: usize,
498    ) -> PauseUntilFuture<OrderingStatus, impl Fn(&OrderingStatus) -> bool + Unpin> {
499        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
500            status.buffered >= n
501        })
502    }
503}
504
505impl<T, Scope: ScriptableHookScope> OrderingHook<T, Bounded, AtLeastOnce, Scope>
506where
507    T: Serialize + DeserializeOwned,
508{
509    /// Scripts an in-tick `assume_ordering` observation on an at-least-once stream to
510    /// consume its complete input in exactly this slot sequence. Every value received by
511    /// the operator during that tick must be emitted into at least one slot, and a value
512    /// may occupy several **non-adjacent** slots — each extra slot is a delayed
513    /// redelivery, still `AtLeastOnce`. Only received values may appear, and adjacent
514    /// equal slots are accepted only when the tick received at least two equal instances
515    /// of that value (a sole instance in adjacent slots would be a redundant duplicate —
516    /// per-slot cardinality belongs to the downstream `assume_retries`).
517    pub fn order(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
518        DecisionFuture::new(
519            self.id,
520            self.member,
521            &InlineOrderingDecision::Order(values.into_iter().collect()),
522        )
523    }
524}
525
526impl<T, O: Ordering, Scope: ScriptableHookScope> RetriesHook<T, O, Unbounded, Scope> {
527    pause_family!(OrderingStatus);
528
529    /// Pauses a top-level retries hook until at least `n` elements are buffered; see
530    /// [`Self::pause_until`].
531    pub fn pause_until_count(
532        &self,
533        n: usize,
534    ) -> PauseUntilFuture<OrderingStatus, impl Fn(&OrderingStatus) -> bool + Unpin> {
535        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
536            status.buffered >= n
537        })
538    }
539}
540
541impl<T, Scope: ScriptableHookScope> RetriesHook<T, NoOrder, Unbounded, Scope>
542where
543    T: Serialize + DeserializeOwned,
544{
545    /// Scripts a top-level `assume_retries` action to release `copies` copies of the
546    /// buffered element equal to `value`, consuming it. This is where the simulator
547    /// injects the duplicates the `AtLeastOnce` input type says downstream must
548    /// tolerate; `copies` is the *total* number of deliveries, so `release(v, 1)`
549    /// observes `v` exactly once, with no duplicate.
550    ///
551    /// Any buffered element may be named — the copies' placement is unobservable on a
552    /// `NoOrder` output, so the cardinality is the entire decision.
553    ///
554    /// # Panics
555    /// Panics immediately if `copies` is zero: releasing an element zero times would be
556    /// a *loss*, which `AtLeastOnce` does not permit.
557    #[track_caller]
558    pub fn release(&self, value: T, copies: usize) -> DecisionFuture {
559        assert_copies_at_least_one(copies);
560        DecisionFuture::new(
561            self.id,
562            self.member,
563            &NoOrderRetriesDecision::Release(value, copies),
564        )
565    }
566}
567
568impl<T, Scope: ScriptableHookScope> RetriesHook<T, TotalOrder, Unbounded, Scope>
569where
570    T: Serialize + DeserializeOwned,
571{
572    /// Scripts a top-level `assume_retries` action to release these values, which must
573    /// match a prefix of the buffered queue in arrival order, where a value may be
574    /// repeated back-to-back to simulate retries. This is where the simulator injects
575    /// the duplicates the `AtLeastOnce` input type says downstream must tolerate. The
576    /// decision fires only once it can be honored in full; matched elements before the
577    /// last value are consumed (a later value can only start once the earlier one's
578    /// copies ended).
579    ///
580    /// This is the **non-final** form: the trailing value's element stays buffered with
581    /// its copies left open, so later decisions may release more copies of it —
582    /// scripting `release` never over-commits to an element being done. The element
583    /// remains pending input until a [`Self::release_final`] consumes it (or the
584    /// buffering is declared with the pause family). Elements left un-released by a
585    /// partial decision likewise stay pending, and downstream tick-input hooks batch
586    /// independently of how releases are grouped into decisions.
587    ///
588    /// # Panics
589    /// Panics immediately if `values` is empty: a decision must release something (use
590    /// the pause family to hold data on purpose).
591    #[track_caller]
592    pub fn release(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
593        let values: Vec<T> = values.into_iter().collect();
594        assert!(
595            !values.is_empty(),
596            "release: at least one value must be released (declare intentional buffering with the pause family instead)"
597        );
598        DecisionFuture::new(
599            self.id,
600            self.member,
601            &OrderedRetriesDecision::Release(values),
602        )
603    }
604
605    /// Like [`Self::release`], but **final**: the trailing value's element is consumed
606    /// along with the rest of the matched prefix, declaring that no more copies of it
607    /// will be observed. With no values, finalizes a front element whose copies were
608    /// all released by earlier `release` decisions, delivering nothing more.
609    pub fn release_final(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
610        DecisionFuture::new(
611            self.id,
612            self.member,
613            &OrderedRetriesDecision::ReleaseFinal(values.into_iter().collect()),
614        )
615    }
616
617    /// Scripts a top-level `assume_retries` action to release `copies` copies of the
618    /// front of the buffered queue, whatever it is, consuming it (waiting for an
619    /// element to arrive if the queue is empty). Unlike [`Self::release_final`], this
620    /// does not assert the released value; prefer the value-naming forms, which fail
621    /// loudly when the script falls out of sync with the program.
622    ///
623    /// # Panics
624    /// Panics immediately if `copies` is zero: releasing an element zero times would be
625    /// a *loss*, which `AtLeastOnce` does not permit.
626    #[track_caller]
627    pub fn release_next(&self, copies: usize) -> DecisionFuture {
628        assert_copies_at_least_one(copies);
629        DecisionFuture::new(
630            self.id,
631            self.member,
632            &OrderedRetriesDecision::<T>::ReleaseNext(copies),
633        )
634    }
635}
636
637impl<T, O: Ordering, Scope: ScriptableHookScope> RetriesHook<T, O, Bounded, Scope>
638where
639    T: Serialize + DeserializeOwned,
640{
641    /// Scripts an in-tick `assume_retries` observation to consume its complete input and
642    /// release exactly these values. Every value received by the operator during that
643    /// tick must be released at least once, and only received values may appear.
644    ///
645    /// On a `TotalOrder` stream the scripted sequence must follow the input order, where
646    /// a value may repeat only back-to-back (a repeat cannot come after a different
647    /// value). On a `NoOrder` stream only the multiset of scripted values matters.
648    pub fn release(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
649        DecisionFuture::new(
650            self.id,
651            self.member,
652            &InlineRetriesDecision::Release(values.into_iter().collect()),
653        )
654    }
655}
656
657impl<T, Scope: ScriptableHookScope> SnapshotHook<T, Scope> {
658    /// Scripts the next tick execution to observe the buffered version equal to `value`:
659    /// scans forward from the currently-revealed version through the buffered ones and
660    /// releases the first equal version, skipping over earlier versions.
661    ///
662    /// This is a combined assertion and release, and the recommended way to script
663    /// snapshots: a script written with positional decisions breaks silently when the
664    /// program changes how often the state updates, while `reveal(value)` names the state
665    /// it means and any mis-synchronization fails loudly at the reveal.
666    pub fn reveal(&self, value: T) -> DecisionFuture
667    where
668        T: Serialize + DeserializeOwned,
669    {
670        DecisionFuture::new(self.id, self.member, &SnapshotDecision::Reveal(value))
671    }
672
673    /// Scripts the next tick execution to observe the next buffered version.
674    pub fn reveal_next(&self) -> DecisionFuture
675    where
676        T: Serialize + DeserializeOwned,
677    {
678        DecisionFuture::new(self.id, self.member, &SnapshotDecision::<T>::RevealNext)
679    }
680
681    /// Scripts the next tick execution to observe the newest version that has arrived by
682    /// the time the tick fires. Under fuzzing, which version is newest co-varies with the
683    /// schedule being explored; use [`Self::reveal`] to name it exactly.
684    pub fn reveal_latest(&self) -> DecisionFuture
685    where
686        T: Serialize + DeserializeOwned,
687    {
688        DecisionFuture::new(self.id, self.member, &SnapshotDecision::<T>::RevealLatest)
689    }
690
691    /// Scripts the next tick execution to observe the previously revealed version again.
692    pub fn keep(&self) -> DecisionFuture
693    where
694        T: Serialize + DeserializeOwned,
695    {
696        DecisionFuture::new(self.id, self.member, &SnapshotDecision::<T>::Keep)
697    }
698
699    pause_family!(SnapshotStatus);
700
701    /// Pauses the hook and returns a future that resolves once at least `n` newer
702    /// versions are buffered; see [`Self::pause_until`].
703    pub fn pause_until_versions(
704        &self,
705        n: usize,
706    ) -> PauseUntilFuture<SnapshotStatus, impl Fn(&SnapshotStatus) -> bool + Unpin> {
707        self.pause_until_labeled(format!("pause_until_versions({})", n), move |status| {
708            status.newer_versions >= n
709        })
710    }
711}
712
713impl<K, V, O: Ordering, R: Retries, Scope: ScriptableHookScope> KeyedBatchHook<K, V, O, R, Scope> {
714    pause_family!(BatchStatus);
715
716    /// Pauses the hook and returns a future that resolves once at least `n` entries are
717    /// buffered (in total, across all keys); see [`Self::pause_until`].
718    pub fn pause_until_count(
719        &self,
720        n: usize,
721    ) -> PauseUntilFuture<BatchStatus, impl Fn(&BatchStatus) -> bool + Unpin> {
722        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
723            status.buffered >= n
724        })
725    }
726}
727
728impl<K, V, R: Retries, Scope: ScriptableHookScope> KeyedBatchHook<K, V, TotalOrder, R, Scope>
729where
730    K: Serialize + DeserializeOwned + PartialEq,
731    V: Serialize + DeserializeOwned + PartialEq,
732{
733    /// Scripts the next batch to be exactly the next `count` buffered values of each
734    /// named key. The tick fires at the first moment the decision can be honored in
735    /// full.
736    ///
737    /// # Panics
738    /// Panics immediately if `counts` names the same key more than once.
739    #[track_caller]
740    pub fn release(&self, counts: impl IntoIterator<Item = (K, usize)>) -> DecisionFuture
741    where
742        K: std::hash::Hash + Eq,
743    {
744        let counts: Vec<(K, usize)> = counts.into_iter().collect();
745        assert_distinct_keys(counts.iter().map(|(key, _)| key), "release");
746        DecisionFuture::new(
747            self.id,
748            self.member,
749            &KeyedBatchDecision::<K, V>::Prefixes(counts),
750        )
751    }
752
753    /// Scripts the next batch to be exactly these `(key, value)` entries. Each key's
754    /// values must match that key's buffered prefix in order (the interleaving of
755    /// *different* keys in the scripted sequence is irrelevant): a mismatching available
756    /// value panics immediately, while a matching but incomplete prefix waits for the
757    /// remaining values to arrive.
758    pub fn release_values(&self, entries: impl IntoIterator<Item = (K, V)>) -> DecisionFuture {
759        DecisionFuture::new(
760            self.id,
761            self.member,
762            &KeyedBatchDecision::Values(entries.into_iter().collect()),
763        )
764    }
765
766    /// Scripts the next batch to be everything that has arrived by the time the tick
767    /// fires. Under fuzzing, the released contents co-vary with the schedule being
768    /// explored; use [`Self::release_values`] to name them exactly.
769    pub fn release_all(&self) -> DecisionFuture {
770        DecisionFuture::new(self.id, self.member, &KeyedBatchDecision::<K, V>::All)
771    }
772
773    /// Scripts the next batch to be empty, holding everything buffered. Shorthand for
774    /// [`Self::release_values`] with no entries.
775    pub fn release_empty(&self) -> DecisionFuture {
776        self.release_values([])
777    }
778}
779
780impl<K, V, R: Retries, Scope: ScriptableHookScope> KeyedBatchHook<K, V, NoOrder, R, Scope>
781where
782    K: Serialize + DeserializeOwned + PartialEq,
783    V: Serialize + DeserializeOwned + PartialEq,
784{
785    /// Scripts the next batch to contain exactly these `(key, value)` entries. Values are
786    /// matched per key as multisets (independently of arrival order); duplicates request
787    /// the corresponding number of equal buffered items. The tick fires once every
788    /// requested entry exists.
789    pub fn release_values(&self, entries: impl IntoIterator<Item = (K, V)>) -> DecisionFuture {
790        DecisionFuture::new(
791            self.id,
792            self.member,
793            &UnorderedKeyedBatchDecision::Values(entries.into_iter().collect()),
794        )
795    }
796
797    /// Scripts the next batch to be everything that has arrived by the time the tick
798    /// fires. Under fuzzing, the released contents co-vary with the schedule being
799    /// explored; use [`Self::release_values`] to name them exactly.
800    pub fn release_all(&self) -> DecisionFuture {
801        DecisionFuture::new(
802            self.id,
803            self.member,
804            &UnorderedKeyedBatchDecision::<K, V>::All,
805        )
806    }
807
808    /// Scripts the next batch to be empty, holding everything buffered. Shorthand for
809    /// [`Self::release_values`] with no entries.
810    pub fn release_empty(&self) -> DecisionFuture {
811        self.release_values([])
812    }
813}
814
815impl<K, V, Scope: ScriptableHookScope> KeyedSnapshotHook<K, V, Scope> {
816    /// Scripts the next tick execution to observe, for each named key, the buffered
817    /// version equal to the named value: scans forward from that key's currently-revealed
818    /// version through the buffered ones and releases the first equal version, skipping
819    /// over earlier versions. Keys that are not named observe their previously revealed
820    /// version again (or stay absent if they have never been revealed).
821    ///
822    /// This is a combined assertion and release, and the recommended way to script keyed
823    /// snapshots: naming the state each key means makes any mis-synchronization fail
824    /// loudly at the reveal.
825    ///
826    /// # Panics
827    /// Panics immediately if `entries` names the same key more than once: a key observes
828    /// exactly one version per tick execution.
829    #[track_caller]
830    pub fn reveal(&self, entries: impl IntoIterator<Item = (K, V)>) -> DecisionFuture
831    where
832        K: Serialize + DeserializeOwned + std::hash::Hash + Eq,
833        V: Serialize + DeserializeOwned,
834    {
835        let entries: Vec<(K, V)> = entries.into_iter().collect();
836        assert_distinct_keys(entries.iter().map(|(key, _)| key), "reveal");
837        DecisionFuture::new(
838            self.id,
839            self.member,
840            &KeyedSnapshotDecision::Reveal(entries),
841        )
842    }
843
844    /// Scripts the next tick execution to observe, for every key, the newest version that
845    /// has arrived by the time the tick fires (keys with nothing newer observe their
846    /// previously revealed version again). Under fuzzing, which versions are newest
847    /// co-varies with the schedule being explored; use [`Self::reveal`] to name them
848    /// exactly.
849    pub fn reveal_latest(&self) -> DecisionFuture
850    where
851        K: Serialize + DeserializeOwned,
852        V: Serialize + DeserializeOwned,
853    {
854        DecisionFuture::new(
855            self.id,
856            self.member,
857            &KeyedSnapshotDecision::<K, V>::RevealLatest,
858        )
859    }
860
861    /// Scripts the next tick execution to observe every key's previously revealed version
862    /// again.
863    pub fn keep(&self) -> DecisionFuture
864    where
865        K: Serialize + DeserializeOwned,
866        V: Serialize + DeserializeOwned,
867    {
868        DecisionFuture::new(self.id, self.member, &KeyedSnapshotDecision::<K, V>::Keep)
869    }
870
871    pause_family!(KeyedSnapshotStatus);
872
873    /// Pauses the hook and returns a future that resolves once at least `n` newer
874    /// versions are buffered (in total, across all keys); see [`Self::pause_until`].
875    pub fn pause_until_versions(
876        &self,
877        n: usize,
878    ) -> PauseUntilFuture<KeyedSnapshotStatus, impl Fn(&KeyedSnapshotStatus) -> bool + Unpin> {
879        self.pause_until_labeled(format!("pause_until_versions({})", n), move |status| {
880            status.newer_versions >= n
881        })
882    }
883}
884
885impl<K, V, Scope: ScriptableHookScope> KeyedOrderingHook<K, V, Unbounded, Scope>
886where
887    K: Serialize + DeserializeOwned,
888    V: Serialize + DeserializeOwned,
889{
890    /// Scripts a top-level keyed `assume_ordering` action to release the buffered entry
891    /// under `key` equal to `value`. Exactly one entry is released, preserving
892    /// opportunities for ticks and feedback to interleave with the remaining buffered
893    /// input.
894    pub fn next(&self, key: K, value: V) -> DecisionFuture {
895        DecisionFuture::new(
896            self.id,
897            self.member,
898            &TopLevelOrderingDecision::Next((key, value)),
899        )
900    }
901
902    pause_family!(OrderingStatus);
903
904    /// Pauses a top-level keyed ordering hook until at least `n` entries are buffered (in
905    /// total, across all keys); see [`Self::pause_until`].
906    pub fn pause_until_count(
907        &self,
908        n: usize,
909    ) -> PauseUntilFuture<OrderingStatus, impl Fn(&OrderingStatus) -> bool + Unpin> {
910        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
911            status.buffered >= n
912        })
913    }
914}
915
916impl<K, V, Scope: ScriptableHookScope> KeyedOrderingHook<K, V, Bounded, Scope>
917where
918    K: Serialize + DeserializeOwned,
919    V: Serialize + DeserializeOwned,
920{
921    /// Scripts an in-tick keyed `assume_ordering` observation to consume its complete
922    /// input with each key's values in exactly the scripted per-key order. The supplied
923    /// entries must contain exactly all `(key, value)` entries received by the operator
924    /// during that tick; the relative order of *different* keys in the scripted sequence
925    /// is irrelevant (a keyed stream carries no cross-key ordering).
926    pub fn order(&self, entries: impl IntoIterator<Item = (K, V)>) -> DecisionFuture {
927        DecisionFuture::new(
928            self.id,
929            self.member,
930            &InlineOrderingDecision::Order(entries.into_iter().collect()),
931        )
932    }
933}
934
935impl<K, V, Scope: ScriptableHookScope> PartialOrderingHook<K, V, Unbounded, Scope>
936where
937    K: Serialize + DeserializeOwned,
938    V: Serialize + DeserializeOwned,
939{
940    /// Scripts a top-level `entries_partially_ordered` action to release the front entry
941    /// of `key`'s buffer, which must equal `value` (within-key order is preserved, so a
942    /// mismatch panics). Exactly one entry is released, preserving opportunities for
943    /// ticks and feedback to interleave with the remaining buffered input.
944    pub fn next(&self, key: K, value: V) -> DecisionFuture {
945        DecisionFuture::new(
946            self.id,
947            self.member,
948            &TopLevelOrderingDecision::Next((key, value)),
949        )
950    }
951
952    pause_family!(OrderingStatus);
953
954    /// Pauses a top-level partially-ordered hook until at least `n` entries are buffered
955    /// (in total, across all keys); see [`Self::pause_until`].
956    pub fn pause_until_count(
957        &self,
958        n: usize,
959    ) -> PauseUntilFuture<OrderingStatus, impl Fn(&OrderingStatus) -> bool + Unpin> {
960        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
961            status.buffered >= n
962        })
963    }
964}
965
966impl<K, V, Scope: ScriptableHookScope> PartialOrderingHook<K, V, Bounded, Scope>
967where
968    K: Serialize + DeserializeOwned,
969    V: Serialize + DeserializeOwned,
970{
971    /// Scripts an in-tick `entries_partially_ordered` observation to consume its complete
972    /// input in exactly this interleaving. The supplied entries must be a permutation of
973    /// all `(key, value)` entries received by the operator during that tick that preserves
974    /// each key's within-key order.
975    pub fn order(&self, entries: impl IntoIterator<Item = (K, V)>) -> DecisionFuture {
976        DecisionFuture::new(
977            self.id,
978            self.member,
979            &InlineOrderingDecision::Order(entries.into_iter().collect()),
980        )
981    }
982}
983
984impl<T, Scope: ScriptableHookScope> MergeOrderedHook<T, Unbounded, Scope>
985where
986    T: Serialize + DeserializeOwned,
987{
988    /// Scripts a top-level `merge_ordered` action to release the front element of the
989    /// *first* input's buffer, which must equal `value` (per-input order is preserved, so
990    /// a mismatch panics). Exactly one element is released, preserving opportunities for
991    /// ticks and feedback to interleave with the remaining buffered input.
992    pub fn next_first(&self, value: T) -> DecisionFuture {
993        DecisionFuture::new(self.id, self.member, &MergeDecision::<T>::First(value))
994    }
995
996    /// Scripts a top-level `merge_ordered` action to release the front element of the
997    /// *second* input's buffer, which must equal `value`; see [`Self::next_first`].
998    pub fn next_second(&self, value: T) -> DecisionFuture {
999        DecisionFuture::new(self.id, self.member, &MergeDecision::<T>::Second(value))
1000    }
1001
1002    /// Scripts a top-level `merge_ordered` action to release the front element of the
1003    /// *first* input's buffer, whatever it is (waiting for one to arrive if that input is
1004    /// empty). Unlike [`Self::next_first`], this does not assert the released value; use
1005    /// `next_first(value)` to name it exactly and fail loudly on mis-synchronization.
1006    pub fn advance_first(&self) -> DecisionFuture {
1007        DecisionFuture::new(self.id, self.member, &MergeDecision::<T>::FirstNext(()))
1008    }
1009
1010    /// Scripts a top-level `merge_ordered` action to release the front element of the
1011    /// *second* input's buffer, whatever it is; see [`Self::advance_first`].
1012    pub fn advance_second(&self) -> DecisionFuture {
1013        DecisionFuture::new(self.id, self.member, &MergeDecision::<T>::SecondNext(()))
1014    }
1015
1016    pause_family!(MergeStatus);
1017
1018    /// Pauses a top-level merge hook until at least `n` elements are buffered (in total,
1019    /// across both inputs); see [`Self::pause_until`].
1020    pub fn pause_until_count(
1021        &self,
1022        n: usize,
1023    ) -> PauseUntilFuture<MergeStatus, impl Fn(&MergeStatus) -> bool + Unpin> {
1024        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
1025            status.first_buffered + status.second_buffered >= n
1026        })
1027    }
1028}
1029
1030impl<T, Scope: ScriptableHookScope> MergeOrderedHook<T, Bounded, Scope>
1031where
1032    T: Serialize + DeserializeOwned,
1033{
1034    /// Scripts an in-tick `merge_ordered` observation to consume its complete input in
1035    /// exactly this interleaving, with each value labeled by the input it is drawn from
1036    /// (`false` = first/left, `true` = second/right). Each input's labeled values must be
1037    /// exactly that input's tick-local batch, in order.
1038    pub fn order(&self, values: impl IntoIterator<Item = (bool, T)>) -> DecisionFuture {
1039        DecisionFuture::new(
1040            self.id,
1041            self.member,
1042            &InlineOrderingDecision::Order(values.into_iter().collect()),
1043        )
1044    }
1045}
1046
1047impl<K, V, Scope: ScriptableHookScope> KeyedMergeOrderedHook<K, V, Unbounded, Scope>
1048where
1049    K: Serialize + DeserializeOwned,
1050    V: Serialize + DeserializeOwned,
1051{
1052    /// Scripts a top-level keyed `merge_ordered` action to release the front entry of
1053    /// `key`'s buffer in the *first* input, which must equal `value` (per-input
1054    /// within-key order is preserved, so a mismatch panics). Exactly one entry is
1055    /// released, preserving opportunities for ticks and feedback to interleave with the
1056    /// remaining buffered input.
1057    pub fn next_first(&self, key: K, value: V) -> DecisionFuture {
1058        DecisionFuture::new(
1059            self.id,
1060            self.member,
1061            &MergeDecision::<(K, V), K>::First((key, value)),
1062        )
1063    }
1064
1065    /// Scripts a top-level keyed `merge_ordered` action to release the front entry of
1066    /// `key`'s buffer in the *second* input, which must equal `value`; see
1067    /// [`Self::next_first`].
1068    pub fn next_second(&self, key: K, value: V) -> DecisionFuture {
1069        DecisionFuture::new(
1070            self.id,
1071            self.member,
1072            &MergeDecision::<(K, V), K>::Second((key, value)),
1073        )
1074    }
1075
1076    /// Scripts a top-level keyed `merge_ordered` action to release the front entry of
1077    /// `key`'s buffer in the *first* input, whatever its value (waiting for one to arrive
1078    /// if that key's buffer is empty). Unlike [`Self::next_first`], this does not assert
1079    /// the released value; use `next_first(key, value)` to name it exactly and fail
1080    /// loudly on mis-synchronization.
1081    pub fn advance_first(&self, key: K) -> DecisionFuture {
1082        DecisionFuture::new(
1083            self.id,
1084            self.member,
1085            &MergeDecision::<(K, V), K>::FirstNext(key),
1086        )
1087    }
1088
1089    /// Scripts a top-level keyed `merge_ordered` action to release the front entry of
1090    /// `key`'s buffer in the *second* input, whatever its value; see
1091    /// [`Self::advance_first`].
1092    pub fn advance_second(&self, key: K) -> DecisionFuture {
1093        DecisionFuture::new(
1094            self.id,
1095            self.member,
1096            &MergeDecision::<(K, V), K>::SecondNext(key),
1097        )
1098    }
1099
1100    pause_family!(MergeStatus);
1101
1102    /// Pauses a top-level keyed merge hook until at least `n` entries are buffered (in
1103    /// total, across both inputs and all keys); see [`Self::pause_until`].
1104    pub fn pause_until_count(
1105        &self,
1106        n: usize,
1107    ) -> PauseUntilFuture<MergeStatus, impl Fn(&MergeStatus) -> bool + Unpin> {
1108        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
1109            status.first_buffered + status.second_buffered >= n
1110        })
1111    }
1112}
1113
1114impl<K, V, Scope: ScriptableHookScope> KeyedMergeOrderedHook<K, V, Bounded, Scope>
1115where
1116    K: Serialize + DeserializeOwned,
1117    V: Serialize + DeserializeOwned,
1118{
1119    /// Scripts an in-tick keyed `merge_ordered` observation to consume its complete input
1120    /// in exactly this interleaving, with each `(key, value)` entry labeled by the input
1121    /// it is drawn from (`false` = first/left, `true` = second/right). Each input's
1122    /// labeled entries must be exactly that input's tick-local batch, with every key's
1123    /// values in order; the relative order of *different* keys is irrelevant (a keyed
1124    /// stream carries no cross-key ordering).
1125    pub fn order(&self, entries: impl IntoIterator<Item = (bool, K, V)>) -> DecisionFuture {
1126        DecisionFuture::new(
1127            self.id,
1128            self.member,
1129            &InlineOrderingDecision::Order(
1130                entries
1131                    .into_iter()
1132                    .map(|(from_second, key, value)| (from_second, (key, value)))
1133                    .collect(),
1134            ),
1135        )
1136    }
1137}