Skip to main content

hydro_lang/sim/runtime/
tick_input.rs

1//! Tick-input hooks ([`TickInputHook`]): the per-kind hook types that buffer input for
2//! a tick across scheduling boundaries and decide, when the tick runs, what to release
3//! into that execution — batch hooks ([`StreamHook`], [`KeyedStreamHook`]) and snapshot
4//! hooks ([`SingletonHook`], [`KeyedSingletonHook`], and the choice-free
5//! [`PassthroughSingletonHook`]). Their scripted decision/status types and
6//! [`ScriptableHook`] impls live alongside them.
7
8use std::cell::RefCell;
9use std::collections::VecDeque;
10use std::hash::Hash;
11use std::rc::Rc;
12
13use bolero::generator::bolero_generator::driver::object::Borrowed;
14use bolero::{ValueGenerator, produce};
15use dfir_rs::rustc_hash::{FxHashMap, FxHashSet};
16use dfir_rs::util::unsync::mpsc::Sender;
17
18use super::{
19    HookLocationMeta, ManualDebug, RuntimeHook, ScriptDecision, ScriptableHook,
20    ScriptableTickInputHook, TickInputHook, TruncatedVecDebug, abort, describe_keyed_pending,
21    keyed_buffer_len, log_release,
22};
23use crate::live_collections::stream::{NoOrder, Ordering, TotalOrder};
24
25pub struct StreamHook<T, Order: Ordering> {
26    pub input: Rc<RefCell<VecDeque<T>>>,
27    pub to_release: Option<Vec<T>>,
28    pub output: Sender<T>,
29    pub batch_location: HookLocationMeta,
30    pub format_item_debug: fn(&T) -> Option<String>,
31    pub _order: std::marker::PhantomData<Order>,
32}
33
34impl<T> RuntimeHook for StreamHook<T, TotalOrder> {
35    fn has_pending_input(&self) -> bool {
36        !self.input.borrow().is_empty()
37    }
38
39    fn only_one_possible_decision(&self) -> bool {
40        // One buffered element is still a real choice: release it or not.
41        self.input.borrow().is_empty()
42    }
43
44    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
45        if let Some(to_release) = self.to_release.take() {
46            if let Some(log_writer) = log_writer {
47                let HookLocationMeta {
48                    location: batch_location,
49                    line,
50                    caret_indent,
51                } = self.batch_location;
52                let note_str = if to_release.is_empty() {
53                    "^ releasing no items".to_owned()
54                } else {
55                    format!(
56                        "^ releasing items: {:?}",
57                        TruncatedVecDebug(
58                            RefCell::new(Some(to_release.iter())),
59                            8,
60                            self.format_item_debug
61                        )
62                    )
63                };
64
65                log_release(
66                    log_writer,
67                    batch_location,
68                    line,
69                    caret_indent,
70                    &note_str,
71                    colored::Color::Green,
72                );
73            }
74
75            for item in to_release {
76                self.output.try_send(item).unwrap();
77            }
78        } else {
79            panic!("No decision to release");
80        }
81    }
82
83    fn location_meta(&self) -> HookLocationMeta {
84        self.batch_location
85    }
86}
87
88impl<T> TickInputHook for StreamHook<T, TotalOrder> {
89    fn can_trigger_tick(&self) -> bool {
90        !self.input.borrow().is_empty()
91    }
92
93    fn autonomous_decision<'a>(&mut self, driver: &mut Borrowed<'a>, force_trigger: bool) -> bool {
94        let mut current_input = self.input.borrow_mut();
95        let count = (usize::from(force_trigger)..=current_input.len())
96            .generate(driver)
97            .unwrap();
98
99        self.to_release = Some(current_input.drain(0..count).collect());
100        count > 0
101    }
102}
103
104impl<T> RuntimeHook for StreamHook<T, NoOrder> {
105    fn has_pending_input(&self) -> bool {
106        !self.input.borrow().is_empty()
107    }
108
109    fn only_one_possible_decision(&self) -> bool {
110        // One buffered element is still a real choice: release it or not.
111        self.input.borrow().is_empty()
112    }
113
114    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
115        if let Some(to_release) = self.to_release.take() {
116            if let Some(log_writer) = log_writer {
117                let HookLocationMeta {
118                    location: batch_location,
119                    line,
120                    caret_indent,
121                } = self.batch_location;
122                let note_str = if to_release.is_empty() {
123                    "^ releasing no items".to_owned()
124                } else {
125                    format!(
126                        "^ releasing unordered items: {:?}",
127                        TruncatedVecDebug(
128                            RefCell::new(Some(to_release.iter())),
129                            8,
130                            self.format_item_debug
131                        )
132                    )
133                };
134
135                log_release(
136                    log_writer,
137                    batch_location,
138                    line,
139                    caret_indent,
140                    &note_str,
141                    colored::Color::Green,
142                );
143            }
144
145            for item in to_release {
146                self.output.try_send(item).unwrap();
147            }
148        } else {
149            panic!("No decision to release");
150        }
151    }
152
153    fn location_meta(&self) -> HookLocationMeta {
154        self.batch_location
155    }
156}
157
158impl<T> TickInputHook for StreamHook<T, NoOrder> {
159    fn can_trigger_tick(&self) -> bool {
160        !self.input.borrow().is_empty()
161    }
162
163    fn autonomous_decision<'a>(&mut self, driver: &mut Borrowed<'a>, force_trigger: bool) -> bool {
164        let mut current_input = self.input.borrow_mut();
165        let mut out = vec![];
166        let mut min_index = 0;
167        while !current_input.is_empty() {
168            let must_release = force_trigger && out.is_empty();
169            if !must_release && produce().generate(driver).unwrap() {
170                break;
171            }
172
173            let idx = (min_index..current_input.len()).generate(driver).unwrap();
174            let item = current_input.remove(idx).unwrap();
175            out.push(item);
176
177            min_index = idx;
178            // Next time, only consider items at or after this index. The reason this is safe is
179            // because batching a `NoOrder` streams results in batches with a `NoOrder` guarantee.
180            // Therefore, simulating different order of elements _within_ a batch is redundant.
181
182            if min_index == current_input.len() {
183                break;
184            }
185        }
186
187        let triggered = !out.is_empty();
188        self.to_release = Some(out);
189        triggered
190    }
191}
192
193/// A scripted decision for a totally ordered batch hook.
194#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
195pub enum BatchDecision<T> {
196    /// Release the next `n` buffered elements.
197    Prefix(usize),
198    /// Release this exact sequence of values from the front of the buffer.
199    Values(Vec<T>),
200    /// Release everything that has arrived by the time the tick fires.
201    All,
202}
203
204impl<T> ScriptDecision for BatchDecision<T>
205where
206    T: serde::Serialize + serde::de::DeserializeOwned,
207{
208    fn describe(&self) -> String {
209        match self {
210            BatchDecision::Prefix(n) => format!("release({})", n),
211            BatchDecision::Values(values) => {
212                format!("release_values({} value(s))", values.len())
213            }
214            BatchDecision::All => "release_all()".to_owned(),
215        }
216    }
217}
218
219/// A scripted decision for an unordered batch hook.
220#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
221pub enum UnorderedBatchDecision<T> {
222    /// Release this multiset of values. Duplicate values name duplicate buffered items.
223    Values(Vec<T>),
224    /// Release everything that has arrived by the time the tick fires.
225    All,
226}
227
228impl<T> ScriptDecision for UnorderedBatchDecision<T>
229where
230    T: serde::Serialize + serde::de::DeserializeOwned,
231{
232    fn describe(&self) -> String {
233        match self {
234            UnorderedBatchDecision::Values(values) => {
235                format!("release_values({} value(s))", values.len())
236            }
237            UnorderedBatchDecision::All => "release_all()".to_owned(),
238        }
239    }
240}
241
242/// The pending-input view a batch hook reports to its test-side handle (see
243/// [`ScriptableHook::status`]), used by `pause_until_*` predicates.
244#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
245pub struct BatchStatus {
246    /// The number of buffered elements.
247    pub buffered: usize,
248}
249
250impl<T> ScriptableHook for StreamHook<T, TotalOrder>
251where
252    T: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
253{
254    type Decision = BatchDecision<T>;
255    type Status = BatchStatus;
256
257    fn is_honorable(&self, decision: &BatchDecision<T>) -> Result<bool, String> {
258        let input = self.input.borrow();
259        match decision {
260            BatchDecision::Prefix(n) => Ok(input.len() >= *n),
261            BatchDecision::Values(values) => {
262                if let Some(idx) = input
263                    .iter()
264                    .zip(values)
265                    .position(|(buffered, expected)| buffered != expected)
266                {
267                    Err(format!(
268                        "release_values: buffered item at prefix position {} did not match the expected value",
269                        idx
270                    ))
271                } else {
272                    Ok(input.len() >= values.len())
273                }
274            }
275            BatchDecision::All => Ok(true),
276        }
277    }
278
279    fn apply(&mut self, decision: BatchDecision<T>) {
280        let mut input = self.input.borrow_mut();
281        let out: Vec<T> = match decision {
282            BatchDecision::Prefix(n) => input.drain(0..n).collect(),
283            BatchDecision::Values(values) => input.drain(0..values.len()).collect(),
284            BatchDecision::All => input.drain(..).collect(),
285        };
286
287        self.to_release = Some(out);
288    }
289
290    fn implicit(&mut self) {
291        self.to_release = Some(vec![]);
292    }
293
294    fn status(&self) -> BatchStatus {
295        BatchStatus {
296            buffered: self.input.borrow().len(),
297        }
298    }
299
300    fn describe_pending(&self) -> Option<String> {
301        let input = self.input.borrow();
302        (!input.is_empty()).then(|| {
303            format!(
304                "{} buffered item(s): {:?}",
305                input.len(),
306                TruncatedVecDebug(RefCell::new(Some(input.iter())), 8, self.format_item_debug)
307            )
308        })
309    }
310}
311
312impl<T> ScriptableTickInputHook for StreamHook<T, TotalOrder>
313where
314    T: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
315{
316    fn decision_triggers_tick(&self, decision: &BatchDecision<T>) -> bool {
317        match decision {
318            BatchDecision::Prefix(n) => *n > 0,
319            BatchDecision::Values(values) => !values.is_empty(),
320            BatchDecision::All => !self.input.borrow().is_empty(),
321        }
322    }
323}
324
325impl<T> ScriptableHook for StreamHook<T, NoOrder>
326where
327    T: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
328{
329    type Decision = UnorderedBatchDecision<T>;
330    type Status = BatchStatus;
331
332    fn is_honorable(&self, decision: &UnorderedBatchDecision<T>) -> Result<bool, String> {
333        Ok(match decision {
334            UnorderedBatchDecision::Values(values) => {
335                let mut unmatched: Vec<&T> = values.iter().collect();
336                for buffered in self.input.borrow().iter() {
337                    if let Some(idx) = unmatched
338                        .iter()
339                        .position(|requested| *requested == buffered)
340                    {
341                        unmatched.swap_remove(idx);
342                    }
343                }
344                unmatched.is_empty()
345            }
346            UnorderedBatchDecision::All => true,
347        })
348    }
349
350    fn apply(&mut self, decision: UnorderedBatchDecision<T>) {
351        let mut input = self.input.borrow_mut();
352        let out: Vec<T> = match decision {
353            UnorderedBatchDecision::Values(mut values) => {
354                let (selected, remaining): (Vec<_>, Vec<_>) =
355                    input.drain(..).partition(|buffered| {
356                        values
357                            .iter()
358                            .position(|requested| requested == buffered)
359                            .is_some_and(|idx| {
360                                values.swap_remove(idx);
361                                true
362                            })
363                    });
364                assert!(
365                    values.is_empty(),
366                    "scripted unordered batch decision was not honorable"
367                );
368                *input = remaining.into();
369                selected
370            }
371            UnorderedBatchDecision::All => input.drain(..).collect(),
372        };
373
374        self.to_release = Some(out);
375    }
376
377    fn implicit(&mut self) {
378        self.to_release = Some(vec![]);
379    }
380
381    fn status(&self) -> BatchStatus {
382        BatchStatus {
383            buffered: self.input.borrow().len(),
384        }
385    }
386
387    fn describe_pending(&self) -> Option<String> {
388        let input = self.input.borrow();
389        (!input.is_empty()).then(|| {
390            format!(
391                "{} buffered item(s): {:?}",
392                input.len(),
393                TruncatedVecDebug(RefCell::new(Some(input.iter())), 8, self.format_item_debug)
394            )
395        })
396    }
397}
398
399impl<T> ScriptableTickInputHook for StreamHook<T, NoOrder>
400where
401    T: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
402{
403    fn decision_triggers_tick(&self, decision: &UnorderedBatchDecision<T>) -> bool {
404        match decision {
405            UnorderedBatchDecision::Values(values) => !values.is_empty(),
406            UnorderedBatchDecision::All => !self.input.borrow().is_empty(),
407        }
408    }
409}
410
411pub struct KeyedStreamHook<K: Hash + Eq + Clone, V, Order: Ordering> {
412    pub input: Rc<RefCell<FxHashMap<K, VecDeque<V>>>>, // FxHasher is deterministic
413    pub to_release: Option<Vec<(K, V)>>,
414    pub output: Sender<(K, V)>,
415    pub batch_location: HookLocationMeta,
416    pub format_item_debug: fn(&(K, V)) -> Option<String>,
417    pub _order: std::marker::PhantomData<Order>,
418}
419
420impl<K: Hash + Eq + Clone, V> RuntimeHook for KeyedStreamHook<K, V, TotalOrder> {
421    fn has_pending_input(&self) -> bool {
422        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
423        !self.input.borrow().values().all(|q| q.is_empty())
424    }
425
426    fn only_one_possible_decision(&self) -> bool {
427        // One buffered element is still a real choice: release it or not.
428        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
429        self.input.borrow().values().all(|q| q.is_empty())
430    }
431
432    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
433        if let Some(to_release) = self.to_release.take() {
434            if let Some(log_writer) = log_writer {
435                let HookLocationMeta {
436                    location: batch_location,
437                    line,
438                    caret_indent,
439                } = self.batch_location;
440                let note_str = if to_release.is_empty() {
441                    "^ releasing no items".to_owned()
442                } else {
443                    format!(
444                        "^ releasing items: {:?}",
445                        TruncatedVecDebug(
446                            RefCell::new(Some(to_release.iter())),
447                            8,
448                            self.format_item_debug
449                        )
450                    )
451                };
452
453                log_release(
454                    log_writer,
455                    batch_location,
456                    line,
457                    caret_indent,
458                    &note_str,
459                    colored::Color::Green,
460                );
461            }
462
463            for item in to_release {
464                self.output.try_send(item).unwrap();
465            }
466        } else {
467            panic!("No decision to release");
468        }
469    }
470
471    fn location_meta(&self) -> HookLocationMeta {
472        self.batch_location
473    }
474}
475
476impl<K: Hash + Eq + Clone, V> TickInputHook for KeyedStreamHook<K, V, TotalOrder> {
477    fn can_trigger_tick(&self) -> bool {
478        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
479        !self.input.borrow().values().all(|q| q.is_empty())
480    }
481
482    fn autonomous_decision<'a>(
483        &mut self,
484        driver: &mut Borrowed<'a>,
485        mut force_trigger: bool,
486    ) -> bool {
487        let mut current_input = self.input.borrow_mut();
488        self.to_release = Some(vec![]);
489        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
490        let nonempty_key_count = current_input.values().filter(|q| !q.is_empty()).count();
491
492        let mut remaining_nonempty_keys = nonempty_key_count;
493        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
494        for (key, queue) in current_input.iter_mut() {
495            if queue.is_empty() {
496                continue;
497            }
498
499            remaining_nonempty_keys -= 1;
500
501            let must_reveal = force_trigger && remaining_nonempty_keys == 0;
502            let count = (usize::from(must_reveal)..=queue.len())
503                .generate(driver)
504                .unwrap();
505
506            let items: Vec<(K, V)> = queue.drain(0..count).map(|v| (key.clone(), v)).collect();
507            self.to_release.as_mut().unwrap().extend(items);
508
509            if count > 0 {
510                force_trigger = false;
511            }
512        }
513
514        !self.to_release.as_ref().unwrap().is_empty()
515    }
516}
517
518impl<K: Hash + Eq + Clone, V> RuntimeHook for KeyedStreamHook<K, V, NoOrder> {
519    fn has_pending_input(&self) -> bool {
520        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
521        !self.input.borrow().values().all(|q| q.is_empty())
522    }
523
524    fn only_one_possible_decision(&self) -> bool {
525        // One buffered element is still a real choice: release it or not.
526        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
527        self.input.borrow().values().all(|q| q.is_empty())
528    }
529
530    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
531        if let Some(to_release) = self.to_release.take() {
532            if let Some(log_writer) = log_writer {
533                let HookLocationMeta {
534                    location: batch_location,
535                    line,
536                    caret_indent,
537                } = self.batch_location;
538                let note_str = if to_release.is_empty() {
539                    "^ releasing no items".to_owned()
540                } else {
541                    format!(
542                        "^ releasing unordered items: {:?}",
543                        TruncatedVecDebug(
544                            RefCell::new(Some(to_release.iter())),
545                            8,
546                            self.format_item_debug
547                        )
548                    )
549                };
550
551                log_release(
552                    log_writer,
553                    batch_location,
554                    line,
555                    caret_indent,
556                    &note_str,
557                    colored::Color::Green,
558                );
559            }
560
561            for item in to_release {
562                self.output.try_send(item).unwrap();
563            }
564        } else {
565            panic!("No decision to release");
566        }
567    }
568
569    fn location_meta(&self) -> HookLocationMeta {
570        self.batch_location
571    }
572}
573
574impl<K: Hash + Eq + Clone, V> TickInputHook for KeyedStreamHook<K, V, NoOrder> {
575    fn can_trigger_tick(&self) -> bool {
576        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
577        !self.input.borrow().values().all(|q| q.is_empty())
578    }
579
580    fn autonomous_decision<'a>(
581        &mut self,
582        driver: &mut Borrowed<'a>,
583        mut force_trigger: bool,
584    ) -> bool {
585        let mut current_input = self.input.borrow_mut();
586        self.to_release = Some(vec![]);
587        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
588        let nonempty_key_count = current_input.values().filter(|q| !q.is_empty()).count();
589
590        let mut remaining_nonempty_keys = nonempty_key_count;
591        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
592        for (key, queue) in current_input.iter_mut() {
593            if queue.is_empty() {
594                continue;
595            }
596
597            remaining_nonempty_keys -= 1;
598
599            let mut min_index = 0;
600            while !queue.is_empty() {
601                let must_release = force_trigger && remaining_nonempty_keys == 0;
602                if !must_release && produce().generate(driver).unwrap() {
603                    break;
604                }
605
606                let idx = (min_index..queue.len()).generate(driver).unwrap();
607                let item = queue.remove(idx).unwrap();
608                self.to_release.as_mut().unwrap().push((key.clone(), item));
609                force_trigger = false;
610
611                min_index = idx;
612                // Next time, only consider items at or after this index. The reason this is safe is
613                // because batching a `NoOrder` stream results in batches with a `NoOrder` guarantee.
614                // Therefore, simulating different order of elements _within_ a batch is redundant.
615
616                if min_index == queue.len() {
617                    break;
618                }
619            }
620        }
621
622        !self.to_release.as_ref().unwrap().is_empty()
623    }
624}
625
626/// A scripted decision for a keyed batch hook over totally ordered values. Named values
627/// are matched against each key's buffered prefix in order.
628///
629/// Per-key payloads are `Vec`s rather than maps: the scripted entry order is preserved
630/// (map iteration would make the release and log order nondeterministic), and the
631/// handle rejects duplicate keys in [`Self::Prefixes`] when the decision is created, so
632/// key distinctness is an invariant rather than a map-enforced property.
633#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
634pub enum KeyedBatchDecision<K, V> {
635    /// Release the next `n` buffered values of each named key. Keys are distinct
636    /// (enforced by the handle when the decision is created).
637    Prefixes(Vec<(K, usize)>),
638    /// Release exactly these `(key, value)` entries.
639    Values(Vec<(K, V)>),
640    /// Release everything that has arrived by the time the tick fires.
641    All,
642}
643
644impl<K, V> ScriptDecision for KeyedBatchDecision<K, V>
645where
646    K: serde::Serialize + serde::de::DeserializeOwned,
647    V: serde::Serialize + serde::de::DeserializeOwned,
648{
649    fn describe(&self) -> String {
650        match self {
651            KeyedBatchDecision::Prefixes(counts) => {
652                format!("release({} per-key count(s))", counts.len())
653            }
654            KeyedBatchDecision::Values(values) => {
655                format!("release_values({} entr(ies))", values.len())
656            }
657            KeyedBatchDecision::All => "release_all()".to_owned(),
658        }
659    }
660}
661
662/// A scripted decision for a keyed batch hook over unordered values. Named values are
663/// matched per key as multisets.
664#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
665pub enum UnorderedKeyedBatchDecision<K, V> {
666    /// Release exactly these `(key, value)` entries.
667    Values(Vec<(K, V)>),
668    /// Release everything that has arrived by the time the tick fires.
669    All,
670}
671
672impl<K, V> ScriptDecision for UnorderedKeyedBatchDecision<K, V>
673where
674    K: serde::Serialize + serde::de::DeserializeOwned,
675    V: serde::Serialize + serde::de::DeserializeOwned,
676{
677    fn describe(&self) -> String {
678        match self {
679            UnorderedKeyedBatchDecision::Values(values) => {
680                format!("release_values({} entr(ies))", values.len())
681            }
682            UnorderedKeyedBatchDecision::All => "release_all()".to_owned(),
683        }
684    }
685}
686
687impl<K, V> ScriptableHook for KeyedStreamHook<K, V, TotalOrder>
688where
689    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
690    V: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
691{
692    type Decision = KeyedBatchDecision<K, V>;
693    type Status = BatchStatus;
694
695    fn is_honorable(&self, decision: &KeyedBatchDecision<K, V>) -> Result<bool, String> {
696        let input = self.input.borrow();
697        match decision {
698            KeyedBatchDecision::Prefixes(counts) => {
699                // Wait until every named key (keys are distinct by the decision's
700                // invariant) has buffered at least the requested number of values.
701                for (key, count) in counts {
702                    if input.get(key).is_none_or(|queue| queue.len() < *count) {
703                        return Ok(false);
704                    }
705                }
706                Ok(true)
707            }
708            KeyedBatchDecision::Values(values) => {
709                // Each key's scripted values must match that key's buffered prefix in
710                // order (cross-key interleaving in the script is irrelevant).
711                let mut consumed: FxHashMap<&K, usize> = FxHashMap::default();
712                for (position, (key, expected)) in values.iter().enumerate() {
713                    let offset = consumed.entry(key).or_insert(0);
714                    match input.get(key).and_then(|queue| queue.get(*offset)) {
715                        Some(buffered) if buffered == expected => {}
716                        Some(_) => {
717                            return Err(format!(
718                                "release_values: buffered item at per-key prefix position {} did not match the expected value (scripted entry {})",
719                                *offset, position
720                            ));
721                        }
722                        None => return Ok(false),
723                    }
724                    *offset += 1;
725                }
726                Ok(true)
727            }
728            KeyedBatchDecision::All => Ok(true),
729        }
730    }
731
732    fn apply(&mut self, decision: KeyedBatchDecision<K, V>) {
733        let mut input = self.input.borrow_mut();
734        let out: Vec<(K, V)> = match decision {
735            KeyedBatchDecision::Prefixes(counts) => {
736                let mut out = vec![];
737                for (key, count) in counts {
738                    let queue = input.get_mut(&key).unwrap();
739                    out.extend(queue.drain(0..count).map(|v| (key.clone(), v)));
740                }
741                out
742            }
743            KeyedBatchDecision::Values(values) => values
744                .into_iter()
745                .map(|(key, _expected)| {
746                    // `is_honorable` verified each key's scripted values match that
747                    // key's buffered prefix, so popping fronts in script order releases
748                    // exactly the named items.
749                    let item = input.get_mut(&key).unwrap().pop_front().unwrap();
750                    (key, item)
751                })
752                .collect(),
753            KeyedBatchDecision::All => {
754                let mut out = vec![];
755                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
756                for (key, queue) in input.iter_mut() {
757                    out.extend(queue.drain(..).map(|v| (key.clone(), v)));
758                }
759                out
760            }
761        };
762
763        self.to_release = Some(out);
764    }
765
766    fn implicit(&mut self) {
767        self.to_release = Some(vec![]);
768    }
769
770    fn status(&self) -> BatchStatus {
771        BatchStatus {
772            buffered: keyed_buffer_len(&self.input.borrow()),
773        }
774    }
775
776    fn describe_pending(&self) -> Option<String> {
777        describe_keyed_pending(&self.input.borrow())
778    }
779}
780
781impl<K, V> ScriptableTickInputHook for KeyedStreamHook<K, V, TotalOrder>
782where
783    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
784    V: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
785{
786    fn decision_triggers_tick(&self, decision: &KeyedBatchDecision<K, V>) -> bool {
787        match decision {
788            KeyedBatchDecision::Prefixes(counts) => counts.iter().any(|(_, count)| *count > 0),
789            KeyedBatchDecision::Values(values) => !values.is_empty(),
790            KeyedBatchDecision::All => keyed_buffer_len(&self.input.borrow()) > 0,
791        }
792    }
793}
794
795impl<K, V> ScriptableHook for KeyedStreamHook<K, V, NoOrder>
796where
797    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
798    V: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
799{
800    type Decision = UnorderedKeyedBatchDecision<K, V>;
801    type Status = BatchStatus;
802
803    fn is_honorable(&self, decision: &UnorderedKeyedBatchDecision<K, V>) -> Result<bool, String> {
804        Ok(match decision {
805            UnorderedKeyedBatchDecision::Values(values) => {
806                // Values are matched per key as multisets: duplicates request the
807                // corresponding number of equal buffered items under that key.
808                let input = self.input.borrow();
809                let mut unmatched: Vec<(&K, &V)> = values.iter().map(|(k, v)| (k, v)).collect();
810                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
811                for (key, queue) in input.iter() {
812                    for buffered in queue {
813                        if let Some(idx) = unmatched
814                            .iter()
815                            .position(|(k, v)| *k == key && *v == buffered)
816                        {
817                            unmatched.swap_remove(idx);
818                        }
819                    }
820                }
821                unmatched.is_empty()
822            }
823            UnorderedKeyedBatchDecision::All => true,
824        })
825    }
826
827    fn apply(&mut self, decision: UnorderedKeyedBatchDecision<K, V>) {
828        let mut input = self.input.borrow_mut();
829        let out: Vec<(K, V)> = match decision {
830            UnorderedKeyedBatchDecision::Values(values) => values
831                .into_iter()
832                .map(|(key, expected)| {
833                    let queue = input.get_mut(&key).unwrap();
834                    let idx = queue.iter().position(|item| *item == expected).unwrap();
835                    let item = queue.remove(idx).unwrap();
836                    (key, item)
837                })
838                .collect(),
839            UnorderedKeyedBatchDecision::All => {
840                let mut out = vec![];
841                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
842                for (key, queue) in input.iter_mut() {
843                    out.extend(queue.drain(..).map(|v| (key.clone(), v)));
844                }
845                out
846            }
847        };
848
849        self.to_release = Some(out);
850    }
851
852    fn implicit(&mut self) {
853        self.to_release = Some(vec![]);
854    }
855
856    fn status(&self) -> BatchStatus {
857        BatchStatus {
858            buffered: keyed_buffer_len(&self.input.borrow()),
859        }
860    }
861
862    fn describe_pending(&self) -> Option<String> {
863        describe_keyed_pending(&self.input.borrow())
864    }
865}
866
867impl<K, V> ScriptableTickInputHook for KeyedStreamHook<K, V, NoOrder>
868where
869    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
870    V: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
871{
872    fn decision_triggers_tick(&self, decision: &UnorderedKeyedBatchDecision<K, V>) -> bool {
873        match decision {
874            UnorderedKeyedBatchDecision::Values(values) => !values.is_empty(),
875            UnorderedKeyedBatchDecision::All => keyed_buffer_len(&self.input.borrow()) > 0,
876        }
877    }
878}
879
880pub struct SingletonHook<T> {
881    input: Rc<RefCell<VecDeque<T>>>,
882    to_release: Option<(T, bool)>, // (data, is new)
883    last_released: Option<T>,
884    skipped_states: Vec<T>,
885    output: Sender<T>,
886    batch_location: HookLocationMeta,
887    format_item_debug: fn(&T) -> Option<String>,
888}
889
890impl<T: Clone> SingletonHook<T> {
891    pub fn new(
892        input: Rc<RefCell<VecDeque<T>>>,
893        output: Sender<T>,
894        batch_location: HookLocationMeta,
895        format_item_debug: fn(&T) -> Option<String>,
896    ) -> Self {
897        Self {
898            input,
899            to_release: None,
900            last_released: None,
901            skipped_states: vec![],
902            output,
903            batch_location,
904            format_item_debug,
905        }
906    }
907}
908
909impl<T: Clone> RuntimeHook for SingletonHook<T> {
910    fn has_pending_input(&self) -> bool {
911        !self.input.borrow().is_empty()
912    }
913
914    fn only_one_possible_decision(&self) -> bool {
915        // With no previously revealed value, a sole buffered version has exactly one
916        // resolution: reveal it (there is nothing to keep). Once a value has been
917        // revealed, any buffered version is a real choice (keep vs reveal); with two or
918        // more buffered versions the choice of which to reveal is real either way.
919        let input = self.input.borrow();
920        input.is_empty() || (input.len() == 1 && self.last_released.is_none())
921    }
922
923    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
924        if let Some((to_release, is_new)) = self.to_release.take() {
925            self.last_released = Some(to_release.clone());
926
927            if let Some(log_writer) = log_writer {
928                let HookLocationMeta {
929                    location: batch_location,
930                    line,
931                    caret_indent,
932                } = self.batch_location;
933                let note_str = if self.skipped_states.is_empty() {
934                    if is_new {
935                        format!(
936                            "^ releasing snapshot: {:?}",
937                            ManualDebug(&to_release, self.format_item_debug)
938                        )
939                    } else {
940                        format!(
941                            "^ releasing unchanged snapshot: {:?}",
942                            ManualDebug(&to_release, self.format_item_debug)
943                        )
944                    }
945                } else {
946                    format!(
947                        "^ releasing snapshot: {:?} (skipping earlier states: {:?})",
948                        ManualDebug(&to_release, self.format_item_debug),
949                        self.skipped_states
950                            .iter()
951                            .map(|s| ManualDebug(s, self.format_item_debug))
952                            .collect::<Vec<_>>()
953                    )
954                };
955
956                log_release(
957                    log_writer,
958                    batch_location,
959                    line,
960                    caret_indent,
961                    &note_str,
962                    colored::Color::Green,
963                );
964            }
965
966            self.output.try_send(to_release).unwrap();
967        } else {
968            panic!("No decision to release");
969        }
970    }
971
972    fn location_meta(&self) -> HookLocationMeta {
973        self.batch_location
974    }
975}
976
977impl<T: Clone> TickInputHook for SingletonHook<T> {
978    fn can_trigger_tick(&self) -> bool {
979        // TODO(mingwei): Singletons/Optionals will soon not trigger a tick, so then this will always return false.
980        !self.input.borrow().is_empty()
981    }
982
983    fn autonomous_decision<'a>(&mut self, driver: &mut Borrowed<'a>, force_trigger: bool) -> bool {
984        let mut current_input = self.input.borrow_mut();
985        if current_input.is_empty() {
986            assert!(
987                !force_trigger,
988                "Cannot make a triggering decision when there is no input"
989            );
990
991            if let Some(last) = &self.last_released {
992                // Re-release the last item
993                self.to_release = Some((last.clone(), false));
994                false
995            } else {
996                panic!("No input and no last released item to re-release");
997            }
998        } else if !force_trigger
999            && let Some(last) = &self.last_released
1000            && produce().generate(driver).unwrap()
1001        {
1002            // Re-release the last item
1003            self.to_release = Some((last.clone(), false));
1004            false
1005        } else {
1006            // Release a new item
1007            let idx_to_release = (0..current_input.len()).generate(driver).unwrap();
1008            self.skipped_states = current_input.drain(0..idx_to_release).collect(); // Drop earlier items
1009            let item = current_input.pop_front().unwrap();
1010            self.to_release = Some((item, true));
1011            true
1012        }
1013    }
1014}
1015
1016/// A hook for batching / snapshotting an [`Optional`](crate::live_collections::Optional) with
1017/// `InitNone` boundedness into a tick.
1018///
1019/// This is the [`SingletonHook`] analog for optionals whose *presence* is monotone (the
1020/// `InitNone` bound): the optional starts null and, once it becomes non-null, stays non-null.
1021/// It differs from [`SingletonHook`] in that its released value is optional. Before the first
1022/// non-null value it releases *null* (sending nothing into the tick's `source_stream`, so the
1023/// downstream optional is empty). Once it has released a value, presence is monotone, so it only
1024/// ever re-releases or advances to a newer value — never back to null.
1025pub struct OptionalInitNoneHook<T> {
1026    input: Rc<RefCell<VecDeque<T>>>,
1027    to_release: Option<(Option<T>, bool)>, // (value or null, is new)
1028    last_released: Option<T>,              // last non-null value released (None until the first)
1029    skipped_states: Vec<T>,
1030    output: Sender<T>,
1031    batch_location: HookLocationMeta,
1032    format_item_debug: fn(&T) -> Option<String>,
1033}
1034
1035impl<T: Clone> OptionalInitNoneHook<T> {
1036    pub fn new(
1037        input: Rc<RefCell<VecDeque<T>>>,
1038        output: Sender<T>,
1039        batch_location: HookLocationMeta,
1040        format_item_debug: fn(&T) -> Option<String>,
1041    ) -> Self {
1042        Self {
1043            input,
1044            to_release: None,
1045            last_released: None,
1046            skipped_states: vec![],
1047            output,
1048            batch_location,
1049            format_item_debug,
1050        }
1051    }
1052}
1053
1054impl<T: Clone> RuntimeHook for OptionalInitNoneHook<T> {
1055    fn has_pending_input(&self) -> bool {
1056        !self.input.borrow().is_empty()
1057    }
1058
1059    fn only_one_possible_decision(&self) -> bool {
1060        // If there is no input, the decision is trivial.
1061        // Even if the input is a single item, we can always either take it or leave it, and
1062        // remain at the existing value (which may be `None`).
1063        self.input.borrow().is_empty()
1064    }
1065
1066    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
1067        let Some((to_release, is_new)) = self.to_release.take() else {
1068            panic!("No decision to release");
1069        };
1070
1071        if let Some(value) = &to_release {
1072            self.last_released = Some(value.clone());
1073        }
1074
1075        if let Some(log_writer) = log_writer {
1076            let HookLocationMeta {
1077                location: batch_location,
1078                line,
1079                caret_indent,
1080            } = self.batch_location;
1081            let note_str = match (&to_release, is_new) {
1082                (None, _) => "^ releasing null snapshot".to_owned(),
1083                (Some(value), true) => {
1084                    if self.skipped_states.is_empty() {
1085                        format!(
1086                            "^ releasing snapshot: {:?}",
1087                            ManualDebug(value, self.format_item_debug)
1088                        )
1089                    } else {
1090                        format!(
1091                            "^ releasing snapshot: {:?} (skipping earlier states: {:?})",
1092                            ManualDebug(value, self.format_item_debug),
1093                            self.skipped_states
1094                                .iter()
1095                                .map(|s| ManualDebug(s, self.format_item_debug))
1096                                .collect::<Vec<_>>()
1097                        )
1098                    }
1099                }
1100                (Some(value), false) => format!(
1101                    "^ releasing unchanged snapshot: {:?}",
1102                    ManualDebug(value, self.format_item_debug)
1103                ),
1104            };
1105
1106            log_release(
1107                log_writer,
1108                batch_location,
1109                line,
1110                caret_indent,
1111                &note_str,
1112                colored::Color::Green,
1113            );
1114        }
1115
1116        if let Some(value) = to_release {
1117            self.output.try_send(value).unwrap();
1118        }
1119    }
1120
1121    fn location_meta(&self) -> HookLocationMeta {
1122        self.batch_location
1123    }
1124}
1125
1126impl<T: Clone> TickInputHook for OptionalInitNoneHook<T> {
1127    fn can_trigger_tick(&self) -> bool {
1128        // TODO(mingwei): Singletons/Optionals will soon not trigger a tick, so then this will always return false.
1129        // Only advancing to a new value is nontrivial; releasing null (or re-releasing the
1130        // latest value) does not, by itself, drive a tick.
1131        !self.input.borrow().is_empty()
1132    }
1133
1134    fn autonomous_decision<'a>(
1135        &mut self,
1136        driver: &mut Borrowed<'a>,
1137        force_nontrivial: bool,
1138    ) -> bool {
1139        let mut current_input = self.input.borrow_mut();
1140        if current_input.is_empty() {
1141            // Case 1 (trivial): No input.
1142            assert!(
1143                !force_nontrivial,
1144                "Cannot make nontrivial decision when there is no input"
1145            );
1146
1147            if let Some(last) = &self.last_released {
1148                // Presence is monotone: once non-null, re-release the latest value.
1149                self.to_release = Some((Some(last.clone()), false));
1150            } else {
1151                // Still in the initial-null prefix.
1152                self.to_release = Some((None, false));
1153            }
1154            false
1155        } else if !force_nontrivial && produce().generate(driver).unwrap() {
1156            // Case 2 (trivial): Keep latest value (may be `Some` or `None`)
1157            if let Some(last) = &self.last_released {
1158                // Already non-null; re-release the latest value (models snapshot lag).
1159                self.to_release = Some((Some(last.clone()), false));
1160            } else {
1161                // Still in the initial-null prefix even though a value is buffered: models a
1162                // snapshot that does not yet include the first value.
1163                self.to_release = Some((None, false));
1164            }
1165            false
1166        } else {
1167            // Case 3 (non-trivial): Advance to new value.
1168            let idx_to_release = (0..current_input.len()).generate(driver).unwrap();
1169            self.skipped_states = current_input.drain(0..idx_to_release).collect(); // Drop earlier items
1170            let item = current_input.pop_front().unwrap();
1171            self.to_release = Some((Some(item), true));
1172            true
1173        }
1174    }
1175}
1176
1177/// A scripted decision for a snapshot hook: which buffered version of the state the next
1178/// tick execution observes.
1179#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1180pub enum SnapshotDecision<T> {
1181    /// Reveal the first buffered version equal to this value, skipping over earlier
1182    /// versions.
1183    Reveal(T),
1184    /// Advance to the next buffered version.
1185    RevealNext,
1186    /// Reveal the newest version that has arrived by the time the tick fires.
1187    RevealLatest,
1188    /// Observe the previously revealed version again.
1189    Keep,
1190}
1191
1192impl<T> ScriptDecision for SnapshotDecision<T>
1193where
1194    T: serde::Serialize + serde::de::DeserializeOwned,
1195{
1196    fn describe(&self) -> String {
1197        match self {
1198            SnapshotDecision::Reveal(_) => "reveal(..)".to_owned(),
1199            SnapshotDecision::RevealNext => "reveal_next()".to_owned(),
1200            SnapshotDecision::RevealLatest => "reveal_latest()".to_owned(),
1201            SnapshotDecision::Keep => "keep()".to_owned(),
1202        }
1203    }
1204}
1205
1206/// The pending-input view a snapshot hook reports to its test-side handle (see
1207/// [`ScriptableHook::status`]), used by `pause_until_*` predicates.
1208#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1209pub struct SnapshotStatus {
1210    /// The number of buffered versions newer than the last revealed one.
1211    pub newer_versions: usize,
1212}
1213
1214impl<T: Clone + PartialEq + serde::Serialize + serde::de::DeserializeOwned> ScriptableHook
1215    for SingletonHook<T>
1216{
1217    type Decision = SnapshotDecision<T>;
1218    type Status = SnapshotStatus;
1219
1220    fn is_honorable(&self, decision: &SnapshotDecision<T>) -> Result<bool, String> {
1221        let input = self.input.borrow();
1222        Ok(match decision {
1223            SnapshotDecision::Reveal(target) => input.iter().any(|version| version == target),
1224            SnapshotDecision::RevealNext => !input.is_empty(),
1225            SnapshotDecision::RevealLatest => !input.is_empty() || self.last_released.is_some(),
1226            SnapshotDecision::Keep => self.last_released.is_some(),
1227        })
1228    }
1229
1230    fn apply(&mut self, decision: SnapshotDecision<T>) {
1231        match decision {
1232            SnapshotDecision::Reveal(target) => {
1233                let mut input = self.input.borrow_mut();
1234                let idx = input.iter().position(|version| *version == target).unwrap();
1235                self.skipped_states = input.drain(0..idx).collect();
1236                let item = input.pop_front().unwrap();
1237                self.to_release = Some((item, true));
1238            }
1239            SnapshotDecision::RevealNext => {
1240                let mut input = self.input.borrow_mut();
1241                self.skipped_states = vec![];
1242                let item = input.pop_front().unwrap();
1243                self.to_release = Some((item, true));
1244            }
1245            SnapshotDecision::RevealLatest => {
1246                let mut input = self.input.borrow_mut();
1247                if input.is_empty() {
1248                    self.skipped_states = vec![];
1249                    self.to_release = Some((self.last_released.clone().unwrap(), false));
1250                } else {
1251                    let skip_count = input.len() - 1;
1252                    self.skipped_states = input.drain(0..skip_count).collect();
1253                    let item = input.pop_front().unwrap();
1254                    self.to_release = Some((item, true));
1255                }
1256            }
1257            SnapshotDecision::Keep => {
1258                self.skipped_states = vec![];
1259                self.to_release = Some((self.last_released.clone().unwrap(), false));
1260            }
1261        }
1262    }
1263
1264    fn implicit(&mut self) {
1265        if let Some(last) = &self.last_released {
1266            self.skipped_states = vec![];
1267            self.to_release = Some((last.clone(), false));
1268        } else {
1269            // `is_ready()` prevents the tick from running before the singleton has a
1270            // value, so this is unreachable.
1271            abort!("scripted snapshot hook asked for implicit behavior with no revealed value");
1272        }
1273    }
1274
1275    fn status(&self) -> SnapshotStatus {
1276        SnapshotStatus {
1277            newer_versions: self.input.borrow().len(),
1278        }
1279    }
1280
1281    fn describe_pending(&self) -> Option<String> {
1282        let input = self.input.borrow();
1283        (!input.is_empty()).then(|| {
1284            format!(
1285                "{} buffered version(s): {:?}",
1286                input.len(),
1287                TruncatedVecDebug(RefCell::new(Some(input.iter())), 8, self.format_item_debug)
1288            )
1289        })
1290    }
1291}
1292
1293impl<T: Clone + PartialEq + serde::Serialize + serde::de::DeserializeOwned> ScriptableTickInputHook
1294    for SingletonHook<T>
1295{
1296    fn decision_triggers_tick(&self, decision: &SnapshotDecision<T>) -> bool {
1297        match decision {
1298            SnapshotDecision::Reveal(_) | SnapshotDecision::RevealNext => true,
1299            SnapshotDecision::RevealLatest => !self.input.borrow().is_empty(),
1300            SnapshotDecision::Keep => false,
1301        }
1302    }
1303}
1304/// A passthrough singleton hook for fold outputs that are already controlled by a
1305/// `TopLevelFoldHook`. Always releases the latest value without any non-deterministic
1306/// decisions, since the fold hook already made the only meaningful choice (which subset
1307/// of inputs to process).
1308pub struct PassthroughSingletonHook<T> {
1309    input: Rc<RefCell<VecDeque<T>>>,
1310    to_release: Option<T>,
1311    output: Sender<T>,
1312    batch_location: HookLocationMeta,
1313    format_item_debug: fn(&T) -> Option<String>,
1314}
1315
1316impl<T> PassthroughSingletonHook<T> {
1317    pub fn new(
1318        input: Rc<RefCell<VecDeque<T>>>,
1319        output: Sender<T>,
1320        batch_location: HookLocationMeta,
1321        format_item_debug: fn(&T) -> Option<String>,
1322    ) -> Self {
1323        Self {
1324            input,
1325            to_release: None,
1326            output,
1327            batch_location,
1328            format_item_debug,
1329        }
1330    }
1331}
1332
1333impl<T> RuntimeHook for PassthroughSingletonHook<T> {
1334    fn has_pending_input(&self) -> bool {
1335        !self.input.borrow().is_empty()
1336    }
1337
1338    fn only_one_possible_decision(&self) -> bool {
1339        // Releasing the latest value is the only behavior this hook ever has: the
1340        // controlling `TopLevelFoldHook` already made every meaningful choice, so even
1341        // with buffered input there is nothing non-deterministic left to decide. (This
1342        // hook is why the choice question is separate from `can_trigger_tick`.)
1343        true
1344    }
1345
1346    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
1347        if let Some(to_release) = self.to_release.take() {
1348            if let Some(log_writer) = log_writer {
1349                let HookLocationMeta {
1350                    location: batch_location,
1351                    line,
1352                    caret_indent,
1353                } = self.batch_location;
1354                let note_str = format!(
1355                    "^ releasing snapshot: {:?}",
1356                    ManualDebug(&to_release, self.format_item_debug)
1357                );
1358
1359                log_release(
1360                    log_writer,
1361                    batch_location,
1362                    line,
1363                    caret_indent,
1364                    &note_str,
1365                    colored::Color::Green,
1366                );
1367            }
1368
1369            self.output.try_send(to_release).unwrap();
1370        } else {
1371            panic!("No decision to release");
1372        }
1373    }
1374
1375    fn location_meta(&self) -> HookLocationMeta {
1376        self.batch_location
1377    }
1378}
1379
1380impl<T> TickInputHook for PassthroughSingletonHook<T> {
1381    fn can_trigger_tick(&self) -> bool {
1382        !self.input.borrow().is_empty()
1383    }
1384
1385    fn autonomous_decision<'a>(
1386        &mut self,
1387        _driver: &mut Borrowed<'a>,
1388        _force_trigger: bool,
1389    ) -> bool {
1390        let mut current_input = self.input.borrow_mut();
1391        // Always take the last (most recent) value, discard intermediates.
1392        if let Some(item) = current_input.pop_back() {
1393            current_input.clear();
1394            self.to_release = Some(item);
1395            true
1396        } else {
1397            false
1398        }
1399    }
1400}
1401
1402pub struct KeyedSingletonHook<K: Hash + Eq + Clone, V: Clone> {
1403    input: Rc<RefCell<FxHashMap<K, VecDeque<V>>>>, // FxHasher is deterministic
1404    to_release: Option<Vec<(K, V, bool)>>,         // (key, data, is new)
1405    last_released: FxHashMap<K, V>,
1406    skipped_states: FxHashMap<K, Vec<V>>,
1407    output: Sender<(K, V)>,
1408    batch_location: HookLocationMeta,
1409    format_key_debug: fn(&K) -> Option<String>,
1410    format_value_debug: fn(&V) -> Option<String>,
1411}
1412
1413impl<K: Hash + Eq + Clone, V: Clone> KeyedSingletonHook<K, V> {
1414    pub fn new(
1415        input: Rc<RefCell<FxHashMap<K, VecDeque<V>>>>,
1416        output: Sender<(K, V)>,
1417        batch_location: HookLocationMeta,
1418        format_key_debug: fn(&K) -> Option<String>,
1419        format_value_debug: fn(&V) -> Option<String>,
1420    ) -> Self {
1421        Self {
1422            input,
1423            to_release: None,
1424            last_released: FxHashMap::default(),
1425            skipped_states: FxHashMap::default(),
1426            output,
1427            batch_location,
1428            format_key_debug,
1429            format_value_debug,
1430        }
1431    }
1432}
1433
1434impl<K: Hash + Eq + Clone, V: Clone> RuntimeHook for KeyedSingletonHook<K, V> {
1435    fn has_pending_input(&self) -> bool {
1436        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1437        !self.input.borrow().values().all(|q| q.is_empty())
1438    }
1439
1440    fn only_one_possible_decision(&self) -> bool {
1441        // Even a sole buffered version for a key admits two resolutions (a key not yet
1442        // in the snapshot may stay withheld; a key with a previous value may keep it).
1443        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1444        self.input.borrow().values().all(|q| q.is_empty())
1445    }
1446
1447    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
1448        if let Some(to_release) = self.to_release.take() {
1449            if let Some(log_writer) = log_writer {
1450                let HookLocationMeta {
1451                    location: batch_location,
1452                    line,
1453                    caret_indent,
1454                } = self.batch_location;
1455                let note_str = if to_release.is_empty() {
1456                    "^ releasing no items".to_owned()
1457                } else {
1458                    let mut mapping_text = String::new();
1459                    for (key, value, is_new) in &to_release {
1460                        let entry_text = if *is_new {
1461                            format!(
1462                                "{:?}: {:?}",
1463                                ManualDebug(key, self.format_key_debug),
1464                                ManualDebug(value, self.format_value_debug)
1465                            )
1466                        } else {
1467                            format!(
1468                                "{:?}: {:?} (unchanged)",
1469                                ManualDebug(key, self.format_key_debug),
1470                                ManualDebug(value, self.format_value_debug)
1471                            )
1472                        };
1473                        if !mapping_text.is_empty() {
1474                            mapping_text.push_str(", ");
1475                        }
1476                        mapping_text.push_str(&entry_text);
1477                    }
1478                    format!("^ releasing items: {{ {} }}", mapping_text)
1479                };
1480
1481                log_release(
1482                    log_writer,
1483                    batch_location,
1484                    line,
1485                    caret_indent,
1486                    &note_str,
1487                    colored::Color::Green,
1488                );
1489            }
1490
1491            for (key, value, _) in to_release {
1492                self.output.try_send((key, value)).unwrap();
1493            }
1494        } else {
1495            panic!("No decision to release");
1496        }
1497    }
1498
1499    fn location_meta(&self) -> HookLocationMeta {
1500        self.batch_location
1501    }
1502}
1503
1504impl<K: Hash + Eq + Clone, V: Clone> TickInputHook for KeyedSingletonHook<K, V> {
1505    fn can_trigger_tick(&self) -> bool {
1506        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1507        !self.input.borrow().values().all(|q| q.is_empty())
1508    }
1509
1510    fn autonomous_decision<'a>(
1511        &mut self,
1512        driver: &mut Borrowed<'a>,
1513        mut force_trigger: bool,
1514    ) -> bool {
1515        let mut current_input = self.input.borrow_mut();
1516        self.to_release = Some(vec![]);
1517        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1518        let nonempty_key_count = current_input.values().filter(|q| !q.is_empty()).count();
1519
1520        let mut remaining_nonempty_keys = nonempty_key_count;
1521        let mut any_triggered = false;
1522        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1523        for (key, queue) in current_input.iter_mut() {
1524            if queue.is_empty() {
1525                self.to_release.as_mut().unwrap().push((
1526                    key.clone(),
1527                    self.last_released.get(key).unwrap().clone(),
1528                    false,
1529                ));
1530
1531                continue;
1532            }
1533
1534            remaining_nonempty_keys -= 1;
1535
1536            let must_reveal = force_trigger && remaining_nonempty_keys == 0;
1537
1538            if !must_reveal
1539                && self.last_released.contains_key(key)
1540                && produce().generate(driver).unwrap()
1541            {
1542                // Re-release the last item for this key
1543                let last = self.last_released.get(key).unwrap().clone();
1544                self.to_release
1545                    .as_mut()
1546                    .unwrap()
1547                    .push((key.clone(), last, false));
1548            } else {
1549                let allow_null_release = !must_reveal && !self.last_released.contains_key(key);
1550                if allow_null_release && produce().generate(driver).unwrap() {
1551                    // Don't emit anything, this key is not yet added to the snapshot
1552                    continue;
1553                }
1554
1555                // Release a new item for this key
1556                let idx_to_release = (0..queue.len()).generate(driver).unwrap();
1557                let skipped: Vec<V> = queue.drain(0..idx_to_release).collect();
1558                let item = queue.pop_front().unwrap();
1559                self.skipped_states.insert(key.clone(), skipped);
1560                self.to_release
1561                    .as_mut()
1562                    .unwrap()
1563                    .push((key.clone(), item.clone(), true));
1564                self.last_released.insert(key.clone(), item);
1565
1566                any_triggered |= true;
1567                force_trigger = false;
1568            }
1569        }
1570
1571        any_triggered
1572    }
1573}
1574
1575/// A scripted decision for a keyed snapshot hook: which buffered version each key's next
1576/// tick execution observes.
1577///
1578/// The [`Self::Reveal`] payload is a `Vec` rather than a map: the scripted entry order is
1579/// preserved for the release log, and the handle rejects duplicate keys when the decision
1580/// is created, so key distinctness is an invariant rather than a map-enforced property.
1581#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1582pub enum KeyedSnapshotDecision<K, V> {
1583    /// For each named key, reveal the first buffered version equal to the named value,
1584    /// skipping over earlier versions. Keys are distinct (enforced by the handle when
1585    /// the decision is created; a key observes exactly one version per tick). Keys that
1586    /// are not named observe their previously revealed version again (or stay absent if
1587    /// they have never been revealed).
1588    Reveal(Vec<(K, V)>),
1589    /// For every key, reveal the newest version that has arrived by the time the tick
1590    /// fires (keys with nothing newer observe their previously revealed version again).
1591    RevealLatest,
1592    /// Every key observes its previously revealed version again.
1593    Keep,
1594}
1595
1596impl<K, V> ScriptDecision for KeyedSnapshotDecision<K, V>
1597where
1598    K: serde::Serialize + serde::de::DeserializeOwned,
1599    V: serde::Serialize + serde::de::DeserializeOwned,
1600{
1601    fn describe(&self) -> String {
1602        match self {
1603            KeyedSnapshotDecision::Reveal(_) => "reveal(..)".to_owned(),
1604            KeyedSnapshotDecision::RevealLatest => "reveal_latest()".to_owned(),
1605            KeyedSnapshotDecision::Keep => "keep()".to_owned(),
1606        }
1607    }
1608}
1609
1610/// The pending-input view a keyed snapshot hook reports to its test-side handle (see
1611/// [`ScriptableHook::status`]), used by `pause_until_*` predicates.
1612#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1613pub struct KeyedSnapshotStatus {
1614    /// The total number of buffered versions (across all keys) newer than the last
1615    /// revealed ones.
1616    pub newer_versions: usize,
1617    /// The number of keys with at least one newer buffered version.
1618    pub keys_with_newer_versions: usize,
1619}
1620
1621impl<K, V> ScriptableHook for KeyedSingletonHook<K, V>
1622where
1623    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
1624    V: Clone + PartialEq + serde::Serialize + serde::de::DeserializeOwned,
1625{
1626    type Decision = KeyedSnapshotDecision<K, V>;
1627    type Status = KeyedSnapshotStatus;
1628
1629    fn is_honorable(&self, decision: &KeyedSnapshotDecision<K, V>) -> Result<bool, String> {
1630        let input = self.input.borrow();
1631        Ok(match decision {
1632            KeyedSnapshotDecision::Reveal(entries) => entries.iter().all(|(key, target)| {
1633                input
1634                    .get(key)
1635                    .is_some_and(|queue| queue.iter().any(|version| version == target))
1636            }),
1637            KeyedSnapshotDecision::RevealLatest => {
1638                keyed_buffer_len(&input) > 0 || !self.last_released.is_empty()
1639            }
1640            KeyedSnapshotDecision::Keep => true,
1641        })
1642    }
1643
1644    fn apply(&mut self, decision: KeyedSnapshotDecision<K, V>) {
1645        let mut input = self.input.borrow_mut();
1646        match decision {
1647            KeyedSnapshotDecision::Reveal(entries) => {
1648                let mut to_release = vec![];
1649                let mut named: FxHashSet<K> = FxHashSet::default();
1650                for (key, target) in entries {
1651                    let queue = input.get_mut(&key).unwrap();
1652                    let idx = queue.iter().position(|version| *version == target).unwrap();
1653                    let skipped: Vec<V> = queue.drain(0..idx).collect();
1654                    let item = queue.pop_front().unwrap();
1655                    self.skipped_states.insert(key.clone(), skipped);
1656                    self.last_released.insert(key.clone(), item.clone());
1657                    to_release.push((key.clone(), item, true));
1658                    named.insert(key);
1659                }
1660                // Unnamed keys observe their previously revealed version again.
1661                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1662                for (key, last) in self.last_released.iter() {
1663                    if !named.contains(key) {
1664                        to_release.push((key.clone(), last.clone(), false));
1665                    }
1666                }
1667                self.to_release = Some(to_release);
1668            }
1669            KeyedSnapshotDecision::RevealLatest => {
1670                let mut to_release = vec![];
1671                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1672                for (key, queue) in input.iter_mut() {
1673                    if queue.is_empty() {
1674                        if let Some(last) = self.last_released.get(key) {
1675                            to_release.push((key.clone(), last.clone(), false));
1676                        }
1677                    } else {
1678                        let skip_count = queue.len() - 1;
1679                        let skipped: Vec<V> = queue.drain(0..skip_count).collect();
1680                        let item = queue.pop_front().unwrap();
1681                        self.skipped_states.insert(key.clone(), skipped);
1682                        self.last_released.insert(key.clone(), item.clone());
1683                        to_release.push((key.clone(), item, true));
1684                    }
1685                }
1686                self.to_release = Some(to_release);
1687            }
1688            KeyedSnapshotDecision::Keep => {
1689                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1690                let to_release = self
1691                    .last_released
1692                    .iter()
1693                    .map(|(key, last)| (key.clone(), last.clone(), false))
1694                    .collect();
1695                self.to_release = Some(to_release);
1696            }
1697        }
1698    }
1699
1700    fn implicit(&mut self) {
1701        // "Nothing new": every previously revealed key observes its value again.
1702        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1703        let to_release = self
1704            .last_released
1705            .iter()
1706            .map(|(key, last)| (key.clone(), last.clone(), false))
1707            .collect();
1708        self.to_release = Some(to_release);
1709    }
1710
1711    fn status(&self) -> KeyedSnapshotStatus {
1712        let input = self.input.borrow();
1713        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1714        let keys_with_newer_versions = input.values().filter(|q| !q.is_empty()).count();
1715        KeyedSnapshotStatus {
1716            newer_versions: keyed_buffer_len(&input),
1717            keys_with_newer_versions,
1718        }
1719    }
1720
1721    fn describe_pending(&self) -> Option<String> {
1722        let input = self.input.borrow();
1723        let total = keyed_buffer_len(&input);
1724        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1725        let keys = input.values().filter(|q| !q.is_empty()).count();
1726        (total > 0).then(|| format!("{} buffered version(s) across {} key(s)", total, keys))
1727    }
1728}
1729
1730impl<K, V> ScriptableTickInputHook for KeyedSingletonHook<K, V>
1731where
1732    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
1733    V: Clone + PartialEq + serde::Serialize + serde::de::DeserializeOwned,
1734{
1735    fn decision_triggers_tick(&self, decision: &KeyedSnapshotDecision<K, V>) -> bool {
1736        match decision {
1737            KeyedSnapshotDecision::Reveal(entries) => !entries.is_empty(),
1738            KeyedSnapshotDecision::RevealLatest => keyed_buffer_len(&self.input.borrow()) > 0,
1739            KeyedSnapshotDecision::Keep => false,
1740        }
1741    }
1742}