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 = ((if force_trigger { 1 } else { 0 })..=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 count = ((if force_trigger && remaining_nonempty_keys == 0 {
502                1
503            } else {
504                0
505            })..=queue.len())
506                .generate(driver)
507                .unwrap();
508
509            let items: Vec<(K, V)> = queue.drain(0..count).map(|v| (key.clone(), v)).collect();
510            self.to_release.as_mut().unwrap().extend(items);
511
512            if count > 0 {
513                force_trigger = false;
514            }
515        }
516
517        !self.to_release.as_ref().unwrap().is_empty()
518    }
519}
520
521impl<K: Hash + Eq + Clone, V> RuntimeHook for KeyedStreamHook<K, V, NoOrder> {
522    fn has_pending_input(&self) -> bool {
523        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
524        !self.input.borrow().values().all(|q| q.is_empty())
525    }
526
527    fn only_one_possible_decision(&self) -> bool {
528        // One buffered element is still a real choice: release it or not.
529        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
530        self.input.borrow().values().all(|q| q.is_empty())
531    }
532
533    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
534        if let Some(to_release) = self.to_release.take() {
535            if let Some(log_writer) = log_writer {
536                let HookLocationMeta {
537                    location: batch_location,
538                    line,
539                    caret_indent,
540                } = self.batch_location;
541                let note_str = if to_release.is_empty() {
542                    "^ releasing no items".to_owned()
543                } else {
544                    format!(
545                        "^ releasing unordered items: {:?}",
546                        TruncatedVecDebug(
547                            RefCell::new(Some(to_release.iter())),
548                            8,
549                            self.format_item_debug
550                        )
551                    )
552                };
553
554                log_release(
555                    log_writer,
556                    batch_location,
557                    line,
558                    caret_indent,
559                    &note_str,
560                    colored::Color::Green,
561                );
562            }
563
564            for item in to_release {
565                self.output.try_send(item).unwrap();
566            }
567        } else {
568            panic!("No decision to release");
569        }
570    }
571
572    fn location_meta(&self) -> HookLocationMeta {
573        self.batch_location
574    }
575}
576
577impl<K: Hash + Eq + Clone, V> TickInputHook for KeyedStreamHook<K, V, NoOrder> {
578    fn can_trigger_tick(&self) -> bool {
579        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
580        !self.input.borrow().values().all(|q| q.is_empty())
581    }
582
583    fn autonomous_decision<'a>(
584        &mut self,
585        driver: &mut Borrowed<'a>,
586        mut force_trigger: bool,
587    ) -> bool {
588        let mut current_input = self.input.borrow_mut();
589        self.to_release = Some(vec![]);
590        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
591        let nonempty_key_count = current_input.values().filter(|q| !q.is_empty()).count();
592
593        let mut remaining_nonempty_keys = nonempty_key_count;
594        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
595        for (key, queue) in current_input.iter_mut() {
596            if queue.is_empty() {
597                continue;
598            }
599
600            remaining_nonempty_keys -= 1;
601
602            let mut min_index = 0;
603            while !queue.is_empty() {
604                let must_release = force_trigger && remaining_nonempty_keys == 0;
605                if !must_release && produce().generate(driver).unwrap() {
606                    break;
607                }
608
609                let idx = (min_index..queue.len()).generate(driver).unwrap();
610                let item = queue.remove(idx).unwrap();
611                self.to_release.as_mut().unwrap().push((key.clone(), item));
612                force_trigger = false;
613
614                min_index = idx;
615                // Next time, only consider items at or after this index. The reason this is safe is
616                // because batching a `NoOrder` stream results in batches with a `NoOrder` guarantee.
617                // Therefore, simulating different order of elements _within_ a batch is redundant.
618
619                if min_index == queue.len() {
620                    break;
621                }
622            }
623        }
624
625        !self.to_release.as_ref().unwrap().is_empty()
626    }
627}
628
629/// A scripted decision for a keyed batch hook over totally ordered values. Named values
630/// are matched against each key's buffered prefix in order.
631///
632/// Per-key payloads are `Vec`s rather than maps: the scripted entry order is preserved
633/// (map iteration would make the release and log order nondeterministic), and the
634/// handle rejects duplicate keys in [`Self::Prefixes`] when the decision is created, so
635/// key distinctness is an invariant rather than a map-enforced property.
636#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
637pub enum KeyedBatchDecision<K, V> {
638    /// Release the next `n` buffered values of each named key. Keys are distinct
639    /// (enforced by the handle when the decision is created).
640    Prefixes(Vec<(K, usize)>),
641    /// Release exactly these `(key, value)` entries.
642    Values(Vec<(K, V)>),
643    /// Release everything that has arrived by the time the tick fires.
644    All,
645}
646
647impl<K, V> ScriptDecision for KeyedBatchDecision<K, V>
648where
649    K: serde::Serialize + serde::de::DeserializeOwned,
650    V: serde::Serialize + serde::de::DeserializeOwned,
651{
652    fn describe(&self) -> String {
653        match self {
654            KeyedBatchDecision::Prefixes(counts) => {
655                format!("release({} per-key count(s))", counts.len())
656            }
657            KeyedBatchDecision::Values(values) => {
658                format!("release_values({} entr(ies))", values.len())
659            }
660            KeyedBatchDecision::All => "release_all()".to_owned(),
661        }
662    }
663}
664
665/// A scripted decision for a keyed batch hook over unordered values. Named values are
666/// matched per key as multisets.
667#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
668pub enum UnorderedKeyedBatchDecision<K, V> {
669    /// Release exactly these `(key, value)` entries.
670    Values(Vec<(K, V)>),
671    /// Release everything that has arrived by the time the tick fires.
672    All,
673}
674
675impl<K, V> ScriptDecision for UnorderedKeyedBatchDecision<K, V>
676where
677    K: serde::Serialize + serde::de::DeserializeOwned,
678    V: serde::Serialize + serde::de::DeserializeOwned,
679{
680    fn describe(&self) -> String {
681        match self {
682            UnorderedKeyedBatchDecision::Values(values) => {
683                format!("release_values({} entr(ies))", values.len())
684            }
685            UnorderedKeyedBatchDecision::All => "release_all()".to_owned(),
686        }
687    }
688}
689
690impl<K, V> ScriptableHook for KeyedStreamHook<K, V, TotalOrder>
691where
692    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
693    V: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
694{
695    type Decision = KeyedBatchDecision<K, V>;
696    type Status = BatchStatus;
697
698    fn is_honorable(&self, decision: &KeyedBatchDecision<K, V>) -> Result<bool, String> {
699        let input = self.input.borrow();
700        match decision {
701            KeyedBatchDecision::Prefixes(counts) => {
702                // Wait until every named key (keys are distinct by the decision's
703                // invariant) has buffered at least the requested number of values.
704                for (key, count) in counts {
705                    if input.get(key).is_none_or(|queue| queue.len() < *count) {
706                        return Ok(false);
707                    }
708                }
709                Ok(true)
710            }
711            KeyedBatchDecision::Values(values) => {
712                // Each key's scripted values must match that key's buffered prefix in
713                // order (cross-key interleaving in the script is irrelevant).
714                let mut consumed: FxHashMap<&K, usize> = FxHashMap::default();
715                for (position, (key, expected)) in values.iter().enumerate() {
716                    let offset = consumed.entry(key).or_insert(0);
717                    match input.get(key).and_then(|queue| queue.get(*offset)) {
718                        Some(buffered) if buffered == expected => {}
719                        Some(_) => {
720                            return Err(format!(
721                                "release_values: buffered item at per-key prefix position {} did not match the expected value (scripted entry {})",
722                                *offset, position
723                            ));
724                        }
725                        None => return Ok(false),
726                    }
727                    *offset += 1;
728                }
729                Ok(true)
730            }
731            KeyedBatchDecision::All => Ok(true),
732        }
733    }
734
735    fn apply(&mut self, decision: KeyedBatchDecision<K, V>) {
736        let mut input = self.input.borrow_mut();
737        let out: Vec<(K, V)> = match decision {
738            KeyedBatchDecision::Prefixes(counts) => {
739                let mut out = vec![];
740                for (key, count) in counts {
741                    let queue = input.get_mut(&key).unwrap();
742                    out.extend(queue.drain(0..count).map(|v| (key.clone(), v)));
743                }
744                out
745            }
746            KeyedBatchDecision::Values(values) => values
747                .into_iter()
748                .map(|(key, _expected)| {
749                    // `is_honorable` verified each key's scripted values match that
750                    // key's buffered prefix, so popping fronts in script order releases
751                    // exactly the named items.
752                    let item = input.get_mut(&key).unwrap().pop_front().unwrap();
753                    (key, item)
754                })
755                .collect(),
756            KeyedBatchDecision::All => {
757                let mut out = vec![];
758                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
759                for (key, queue) in input.iter_mut() {
760                    out.extend(queue.drain(..).map(|v| (key.clone(), v)));
761                }
762                out
763            }
764        };
765
766        self.to_release = Some(out);
767    }
768
769    fn implicit(&mut self) {
770        self.to_release = Some(vec![]);
771    }
772
773    fn status(&self) -> BatchStatus {
774        BatchStatus {
775            buffered: keyed_buffer_len(&self.input.borrow()),
776        }
777    }
778
779    fn describe_pending(&self) -> Option<String> {
780        describe_keyed_pending(&self.input.borrow())
781    }
782}
783
784impl<K, V> ScriptableTickInputHook for KeyedStreamHook<K, V, TotalOrder>
785where
786    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
787    V: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
788{
789    fn decision_triggers_tick(&self, decision: &KeyedBatchDecision<K, V>) -> bool {
790        match decision {
791            KeyedBatchDecision::Prefixes(counts) => counts.iter().any(|(_, count)| *count > 0),
792            KeyedBatchDecision::Values(values) => !values.is_empty(),
793            KeyedBatchDecision::All => keyed_buffer_len(&self.input.borrow()) > 0,
794        }
795    }
796}
797
798impl<K, V> ScriptableHook for KeyedStreamHook<K, V, NoOrder>
799where
800    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
801    V: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
802{
803    type Decision = UnorderedKeyedBatchDecision<K, V>;
804    type Status = BatchStatus;
805
806    fn is_honorable(&self, decision: &UnorderedKeyedBatchDecision<K, V>) -> Result<bool, String> {
807        Ok(match decision {
808            UnorderedKeyedBatchDecision::Values(values) => {
809                // Values are matched per key as multisets: duplicates request the
810                // corresponding number of equal buffered items under that key.
811                let input = self.input.borrow();
812                let mut unmatched: Vec<(&K, &V)> = values.iter().map(|(k, v)| (k, v)).collect();
813                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
814                for (key, queue) in input.iter() {
815                    for buffered in queue {
816                        if let Some(idx) = unmatched
817                            .iter()
818                            .position(|(k, v)| *k == key && *v == buffered)
819                        {
820                            unmatched.swap_remove(idx);
821                        }
822                    }
823                }
824                unmatched.is_empty()
825            }
826            UnorderedKeyedBatchDecision::All => true,
827        })
828    }
829
830    fn apply(&mut self, decision: UnorderedKeyedBatchDecision<K, V>) {
831        let mut input = self.input.borrow_mut();
832        let out: Vec<(K, V)> = match decision {
833            UnorderedKeyedBatchDecision::Values(values) => values
834                .into_iter()
835                .map(|(key, expected)| {
836                    let queue = input.get_mut(&key).unwrap();
837                    let idx = queue.iter().position(|item| *item == expected).unwrap();
838                    let item = queue.remove(idx).unwrap();
839                    (key, item)
840                })
841                .collect(),
842            UnorderedKeyedBatchDecision::All => {
843                let mut out = vec![];
844                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
845                for (key, queue) in input.iter_mut() {
846                    out.extend(queue.drain(..).map(|v| (key.clone(), v)));
847                }
848                out
849            }
850        };
851
852        self.to_release = Some(out);
853    }
854
855    fn implicit(&mut self) {
856        self.to_release = Some(vec![]);
857    }
858
859    fn status(&self) -> BatchStatus {
860        BatchStatus {
861            buffered: keyed_buffer_len(&self.input.borrow()),
862        }
863    }
864
865    fn describe_pending(&self) -> Option<String> {
866        describe_keyed_pending(&self.input.borrow())
867    }
868}
869
870impl<K, V> ScriptableTickInputHook for KeyedStreamHook<K, V, NoOrder>
871where
872    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
873    V: serde::Serialize + serde::de::DeserializeOwned + PartialEq,
874{
875    fn decision_triggers_tick(&self, decision: &UnorderedKeyedBatchDecision<K, V>) -> bool {
876        match decision {
877            UnorderedKeyedBatchDecision::Values(values) => !values.is_empty(),
878            UnorderedKeyedBatchDecision::All => keyed_buffer_len(&self.input.borrow()) > 0,
879        }
880    }
881}
882
883pub struct SingletonHook<T> {
884    input: Rc<RefCell<VecDeque<T>>>,
885    to_release: Option<(T, bool)>, // (data, is new)
886    last_released: Option<T>,
887    skipped_states: Vec<T>,
888    output: Sender<T>,
889    batch_location: HookLocationMeta,
890    format_item_debug: fn(&T) -> Option<String>,
891}
892
893impl<T: Clone> SingletonHook<T> {
894    pub fn new(
895        input: Rc<RefCell<VecDeque<T>>>,
896        output: Sender<T>,
897        batch_location: HookLocationMeta,
898        format_item_debug: fn(&T) -> Option<String>,
899    ) -> Self {
900        Self {
901            input,
902            to_release: None,
903            last_released: None,
904            skipped_states: vec![],
905            output,
906            batch_location,
907            format_item_debug,
908        }
909    }
910}
911
912impl<T: Clone> RuntimeHook for SingletonHook<T> {
913    fn has_pending_input(&self) -> bool {
914        !self.input.borrow().is_empty()
915    }
916
917    fn only_one_possible_decision(&self) -> bool {
918        // With no previously revealed value, a sole buffered version has exactly one
919        // resolution: reveal it (there is nothing to keep). Once a value has been
920        // revealed, any buffered version is a real choice (keep vs reveal); with two or
921        // more buffered versions the choice of which to reveal is real either way.
922        let input = self.input.borrow();
923        input.is_empty() || (input.len() == 1 && self.last_released.is_none())
924    }
925
926    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
927        if let Some((to_release, is_new)) = self.to_release.take() {
928            self.last_released = Some(to_release.clone());
929
930            if let Some(log_writer) = log_writer {
931                let HookLocationMeta {
932                    location: batch_location,
933                    line,
934                    caret_indent,
935                } = self.batch_location;
936                let note_str = if self.skipped_states.is_empty() {
937                    if is_new {
938                        format!(
939                            "^ releasing snapshot: {:?}",
940                            ManualDebug(&to_release, self.format_item_debug)
941                        )
942                    } else {
943                        format!(
944                            "^ releasing unchanged snapshot: {:?}",
945                            ManualDebug(&to_release, self.format_item_debug)
946                        )
947                    }
948                } else {
949                    format!(
950                        "^ releasing snapshot: {:?} (skipping earlier states: {:?})",
951                        ManualDebug(&to_release, self.format_item_debug),
952                        self.skipped_states
953                            .iter()
954                            .map(|s| ManualDebug(s, self.format_item_debug))
955                            .collect::<Vec<_>>()
956                    )
957                };
958
959                log_release(
960                    log_writer,
961                    batch_location,
962                    line,
963                    caret_indent,
964                    &note_str,
965                    colored::Color::Green,
966                );
967            }
968
969            self.output.try_send(to_release).unwrap();
970        } else {
971            panic!("No decision to release");
972        }
973    }
974
975    fn location_meta(&self) -> HookLocationMeta {
976        self.batch_location
977    }
978}
979
980impl<T: Clone> TickInputHook for SingletonHook<T> {
981    fn can_trigger_tick(&self) -> bool {
982        // TODO(mingwei): Singletons/Optionals will soon not trigger a tick, so then this will always return false.
983        !self.input.borrow().is_empty()
984    }
985
986    fn autonomous_decision<'a>(&mut self, driver: &mut Borrowed<'a>, force_trigger: bool) -> bool {
987        let mut current_input = self.input.borrow_mut();
988        if current_input.is_empty() {
989            if force_trigger {
990                panic!("Cannot make a triggering decision when there is no input");
991            }
992
993            if let Some(last) = &self.last_released {
994                // Re-release the last item
995                self.to_release = Some((last.clone(), false));
996                false
997            } else {
998                panic!("No input and no last released item to re-release");
999            }
1000        } else if !force_trigger
1001            && let Some(last) = &self.last_released
1002            && produce().generate(driver).unwrap()
1003        {
1004            // Re-release the last item
1005            self.to_release = Some((last.clone(), false));
1006            false
1007        } else {
1008            // Release a new item
1009            let idx_to_release = (0..current_input.len()).generate(driver).unwrap();
1010            self.skipped_states = current_input.drain(0..idx_to_release).collect(); // Drop earlier items
1011            let item = current_input.pop_front().unwrap();
1012            self.to_release = Some((item, true));
1013            true
1014        }
1015    }
1016}
1017
1018/// A hook for batching / snapshotting an [`Optional`](crate::live_collections::Optional) with
1019/// `InitNone` boundedness into a tick.
1020///
1021/// This is the [`SingletonHook`] analog for optionals whose *presence* is monotone (the
1022/// `InitNone` bound): the optional starts null and, once it becomes non-null, stays non-null.
1023/// It differs from [`SingletonHook`] in that its released value is optional. Before the first
1024/// non-null value it releases *null* (sending nothing into the tick's `source_stream`, so the
1025/// downstream optional is empty). Once it has released a value, presence is monotone, so it only
1026/// ever re-releases or advances to a newer value — never back to null.
1027pub struct OptionalInitNoneHook<T> {
1028    input: Rc<RefCell<VecDeque<T>>>,
1029    to_release: Option<(Option<T>, bool)>, // (value or null, is new)
1030    last_released: Option<T>,              // last non-null value released (None until the first)
1031    skipped_states: Vec<T>,
1032    output: Sender<T>,
1033    batch_location: HookLocationMeta,
1034    format_item_debug: fn(&T) -> Option<String>,
1035}
1036
1037impl<T: Clone> OptionalInitNoneHook<T> {
1038    pub fn new(
1039        input: Rc<RefCell<VecDeque<T>>>,
1040        output: Sender<T>,
1041        batch_location: HookLocationMeta,
1042        format_item_debug: fn(&T) -> Option<String>,
1043    ) -> Self {
1044        Self {
1045            input,
1046            to_release: None,
1047            last_released: None,
1048            skipped_states: vec![],
1049            output,
1050            batch_location,
1051            format_item_debug,
1052        }
1053    }
1054}
1055
1056impl<T: Clone> RuntimeHook for OptionalInitNoneHook<T> {
1057    fn has_pending_input(&self) -> bool {
1058        !self.input.borrow().is_empty()
1059    }
1060
1061    fn only_one_possible_decision(&self) -> bool {
1062        // If there is no input, the decision is trivial.
1063        // Even if the input is a single item, we can always either take it or leave it, and
1064        // remain at the existing value (which may be `None`).
1065        self.input.borrow().is_empty()
1066    }
1067
1068    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
1069        let Some((to_release, is_new)) = self.to_release.take() else {
1070            panic!("No decision to release");
1071        };
1072
1073        if let Some(value) = &to_release {
1074            self.last_released = Some(value.clone());
1075        }
1076
1077        if let Some(log_writer) = log_writer {
1078            let HookLocationMeta {
1079                location: batch_location,
1080                line,
1081                caret_indent,
1082            } = self.batch_location;
1083            let note_str = match (&to_release, is_new) {
1084                (None, _) => "^ releasing null snapshot".to_owned(),
1085                (Some(value), true) => {
1086                    if self.skipped_states.is_empty() {
1087                        format!(
1088                            "^ releasing snapshot: {:?}",
1089                            ManualDebug(value, self.format_item_debug)
1090                        )
1091                    } else {
1092                        format!(
1093                            "^ releasing snapshot: {:?} (skipping earlier states: {:?})",
1094                            ManualDebug(value, self.format_item_debug),
1095                            self.skipped_states
1096                                .iter()
1097                                .map(|s| ManualDebug(s, self.format_item_debug))
1098                                .collect::<Vec<_>>()
1099                        )
1100                    }
1101                }
1102                (Some(value), false) => format!(
1103                    "^ releasing unchanged snapshot: {:?}",
1104                    ManualDebug(value, self.format_item_debug)
1105                ),
1106            };
1107
1108            log_release(
1109                log_writer,
1110                batch_location,
1111                line,
1112                caret_indent,
1113                &note_str,
1114                colored::Color::Green,
1115            );
1116        }
1117
1118        if let Some(value) = to_release {
1119            self.output.try_send(value).unwrap();
1120        }
1121    }
1122
1123    fn location_meta(&self) -> HookLocationMeta {
1124        self.batch_location
1125    }
1126}
1127
1128impl<T: Clone> TickInputHook for OptionalInitNoneHook<T> {
1129    fn can_trigger_tick(&self) -> bool {
1130        // TODO(mingwei): Singletons/Optionals will soon not trigger a tick, so then this will always return false.
1131        // Only advancing to a new value is nontrivial; releasing null (or re-releasing the
1132        // latest value) does not, by itself, drive a tick.
1133        !self.input.borrow().is_empty()
1134    }
1135
1136    fn autonomous_decision<'a>(
1137        &mut self,
1138        driver: &mut Borrowed<'a>,
1139        force_nontrivial: bool,
1140    ) -> bool {
1141        let mut current_input = self.input.borrow_mut();
1142        if current_input.is_empty() {
1143            // Case 1 (trivial): No input.
1144            if force_nontrivial {
1145                panic!("Cannot make nontrivial decision when there is no input");
1146            }
1147
1148            if let Some(last) = &self.last_released {
1149                // Presence is monotone: once non-null, re-release the latest value.
1150                self.to_release = Some((Some(last.clone()), false));
1151            } else {
1152                // Still in the initial-null prefix.
1153                self.to_release = Some((None, false));
1154            }
1155            false
1156        } else if !force_nontrivial && produce().generate(driver).unwrap() {
1157            // Case 2 (trivial): Keep latest value (may be `Some` or `None`)
1158            if let Some(last) = &self.last_released {
1159                // Already non-null; re-release the latest value (models snapshot lag).
1160                self.to_release = Some((Some(last.clone()), false));
1161            } else {
1162                // Still in the initial-null prefix even though a value is buffered: models a
1163                // snapshot that does not yet include the first value.
1164                self.to_release = Some((None, false));
1165            }
1166            false
1167        } else {
1168            // Case 3 (non-trivial): Advance to new value.
1169            let idx_to_release = (0..current_input.len()).generate(driver).unwrap();
1170            self.skipped_states = current_input.drain(0..idx_to_release).collect(); // Drop earlier items
1171            let item = current_input.pop_front().unwrap();
1172            self.to_release = Some((Some(item), true));
1173            true
1174        }
1175    }
1176}
1177
1178/// A scripted decision for a snapshot hook: which buffered version of the state the next
1179/// tick execution observes.
1180#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1181pub enum SnapshotDecision<T> {
1182    /// Reveal the first buffered version equal to this value, skipping over earlier
1183    /// versions.
1184    Reveal(T),
1185    /// Advance to the next buffered version.
1186    RevealNext,
1187    /// Reveal the newest version that has arrived by the time the tick fires.
1188    RevealLatest,
1189    /// Observe the previously revealed version again.
1190    Keep,
1191}
1192
1193impl<T> ScriptDecision for SnapshotDecision<T>
1194where
1195    T: serde::Serialize + serde::de::DeserializeOwned,
1196{
1197    fn describe(&self) -> String {
1198        match self {
1199            SnapshotDecision::Reveal(_) => "reveal(..)".to_owned(),
1200            SnapshotDecision::RevealNext => "reveal_next()".to_owned(),
1201            SnapshotDecision::RevealLatest => "reveal_latest()".to_owned(),
1202            SnapshotDecision::Keep => "keep()".to_owned(),
1203        }
1204    }
1205}
1206
1207/// The pending-input view a snapshot hook reports to its test-side handle (see
1208/// [`ScriptableHook::status`]), used by `pause_until_*` predicates.
1209#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1210pub struct SnapshotStatus {
1211    /// The number of buffered versions newer than the last revealed one.
1212    pub newer_versions: usize,
1213}
1214
1215impl<T: Clone + PartialEq + serde::Serialize + serde::de::DeserializeOwned> ScriptableHook
1216    for SingletonHook<T>
1217{
1218    type Decision = SnapshotDecision<T>;
1219    type Status = SnapshotStatus;
1220
1221    fn is_honorable(&self, decision: &SnapshotDecision<T>) -> Result<bool, String> {
1222        let input = self.input.borrow();
1223        Ok(match decision {
1224            SnapshotDecision::Reveal(target) => input.iter().any(|version| version == target),
1225            SnapshotDecision::RevealNext => !input.is_empty(),
1226            SnapshotDecision::RevealLatest => !input.is_empty() || self.last_released.is_some(),
1227            SnapshotDecision::Keep => self.last_released.is_some(),
1228        })
1229    }
1230
1231    fn apply(&mut self, decision: SnapshotDecision<T>) {
1232        match decision {
1233            SnapshotDecision::Reveal(target) => {
1234                let mut input = self.input.borrow_mut();
1235                let idx = input.iter().position(|version| *version == target).unwrap();
1236                self.skipped_states = input.drain(0..idx).collect();
1237                let item = input.pop_front().unwrap();
1238                self.to_release = Some((item, true));
1239            }
1240            SnapshotDecision::RevealNext => {
1241                let mut input = self.input.borrow_mut();
1242                self.skipped_states = vec![];
1243                let item = input.pop_front().unwrap();
1244                self.to_release = Some((item, true));
1245            }
1246            SnapshotDecision::RevealLatest => {
1247                let mut input = self.input.borrow_mut();
1248                if input.is_empty() {
1249                    self.skipped_states = vec![];
1250                    self.to_release = Some((self.last_released.clone().unwrap(), false));
1251                } else {
1252                    let skip_count = input.len() - 1;
1253                    self.skipped_states = input.drain(0..skip_count).collect();
1254                    let item = input.pop_front().unwrap();
1255                    self.to_release = Some((item, true));
1256                }
1257            }
1258            SnapshotDecision::Keep => {
1259                self.skipped_states = vec![];
1260                self.to_release = Some((self.last_released.clone().unwrap(), false));
1261            }
1262        }
1263    }
1264
1265    fn implicit(&mut self) {
1266        if let Some(last) = &self.last_released {
1267            self.skipped_states = vec![];
1268            self.to_release = Some((last.clone(), false));
1269        } else {
1270            // `is_ready()` prevents the tick from running before the singleton has a
1271            // value, so this is unreachable.
1272            abort!("scripted snapshot hook asked for implicit behavior with no revealed value");
1273        }
1274    }
1275
1276    fn status(&self) -> SnapshotStatus {
1277        SnapshotStatus {
1278            newer_versions: self.input.borrow().len(),
1279        }
1280    }
1281
1282    fn describe_pending(&self) -> Option<String> {
1283        let input = self.input.borrow();
1284        (!input.is_empty()).then(|| {
1285            format!(
1286                "{} buffered version(s): {:?}",
1287                input.len(),
1288                TruncatedVecDebug(RefCell::new(Some(input.iter())), 8, self.format_item_debug)
1289            )
1290        })
1291    }
1292}
1293
1294impl<T: Clone + PartialEq + serde::Serialize + serde::de::DeserializeOwned> ScriptableTickInputHook
1295    for SingletonHook<T>
1296{
1297    fn decision_triggers_tick(&self, decision: &SnapshotDecision<T>) -> bool {
1298        match decision {
1299            SnapshotDecision::Reveal(_) | SnapshotDecision::RevealNext => true,
1300            SnapshotDecision::RevealLatest => !self.input.borrow().is_empty(),
1301            SnapshotDecision::Keep => false,
1302        }
1303    }
1304}
1305/// A passthrough singleton hook for fold outputs that are already controlled by a
1306/// `TopLevelFoldHook`. Always releases the latest value without any non-deterministic
1307/// decisions, since the fold hook already made the only meaningful choice (which subset
1308/// of inputs to process).
1309pub struct PassthroughSingletonHook<T> {
1310    input: Rc<RefCell<VecDeque<T>>>,
1311    to_release: Option<T>,
1312    output: Sender<T>,
1313    batch_location: HookLocationMeta,
1314    format_item_debug: fn(&T) -> Option<String>,
1315}
1316
1317impl<T> PassthroughSingletonHook<T> {
1318    pub fn new(
1319        input: Rc<RefCell<VecDeque<T>>>,
1320        output: Sender<T>,
1321        batch_location: HookLocationMeta,
1322        format_item_debug: fn(&T) -> Option<String>,
1323    ) -> Self {
1324        Self {
1325            input,
1326            to_release: None,
1327            output,
1328            batch_location,
1329            format_item_debug,
1330        }
1331    }
1332}
1333
1334impl<T> RuntimeHook for PassthroughSingletonHook<T> {
1335    fn has_pending_input(&self) -> bool {
1336        !self.input.borrow().is_empty()
1337    }
1338
1339    fn only_one_possible_decision(&self) -> bool {
1340        // Releasing the latest value is the only behavior this hook ever has: the
1341        // controlling `TopLevelFoldHook` already made every meaningful choice, so even
1342        // with buffered input there is nothing non-deterministic left to decide. (This
1343        // hook is why the choice question is separate from `can_trigger_tick`.)
1344        true
1345    }
1346
1347    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
1348        if let Some(to_release) = self.to_release.take() {
1349            if let Some(log_writer) = log_writer {
1350                let HookLocationMeta {
1351                    location: batch_location,
1352                    line,
1353                    caret_indent,
1354                } = self.batch_location;
1355                let note_str = format!(
1356                    "^ releasing snapshot: {:?}",
1357                    ManualDebug(&to_release, self.format_item_debug)
1358                );
1359
1360                log_release(
1361                    log_writer,
1362                    batch_location,
1363                    line,
1364                    caret_indent,
1365                    &note_str,
1366                    colored::Color::Green,
1367                );
1368            }
1369
1370            self.output.try_send(to_release).unwrap();
1371        } else {
1372            panic!("No decision to release");
1373        }
1374    }
1375
1376    fn location_meta(&self) -> HookLocationMeta {
1377        self.batch_location
1378    }
1379}
1380
1381impl<T> TickInputHook for PassthroughSingletonHook<T> {
1382    fn can_trigger_tick(&self) -> bool {
1383        !self.input.borrow().is_empty()
1384    }
1385
1386    fn autonomous_decision<'a>(
1387        &mut self,
1388        _driver: &mut Borrowed<'a>,
1389        _force_trigger: bool,
1390    ) -> bool {
1391        let mut current_input = self.input.borrow_mut();
1392        // Always take the last (most recent) value, discard intermediates.
1393        if let Some(item) = current_input.pop_back() {
1394            current_input.clear();
1395            self.to_release = Some(item);
1396            true
1397        } else {
1398            false
1399        }
1400    }
1401}
1402
1403pub struct KeyedSingletonHook<K: Hash + Eq + Clone, V: Clone> {
1404    input: Rc<RefCell<FxHashMap<K, VecDeque<V>>>>, // FxHasher is deterministic
1405    to_release: Option<Vec<(K, V, bool)>>,         // (key, data, is new)
1406    last_released: FxHashMap<K, V>,
1407    skipped_states: FxHashMap<K, Vec<V>>,
1408    output: Sender<(K, V)>,
1409    batch_location: HookLocationMeta,
1410    format_key_debug: fn(&K) -> Option<String>,
1411    format_value_debug: fn(&V) -> Option<String>,
1412}
1413
1414impl<K: Hash + Eq + Clone, V: Clone> KeyedSingletonHook<K, V> {
1415    pub fn new(
1416        input: Rc<RefCell<FxHashMap<K, VecDeque<V>>>>,
1417        output: Sender<(K, V)>,
1418        batch_location: HookLocationMeta,
1419        format_key_debug: fn(&K) -> Option<String>,
1420        format_value_debug: fn(&V) -> Option<String>,
1421    ) -> Self {
1422        Self {
1423            input,
1424            to_release: None,
1425            last_released: FxHashMap::default(),
1426            skipped_states: FxHashMap::default(),
1427            output,
1428            batch_location,
1429            format_key_debug,
1430            format_value_debug,
1431        }
1432    }
1433}
1434
1435impl<K: Hash + Eq + Clone, V: Clone> RuntimeHook for KeyedSingletonHook<K, V> {
1436    fn has_pending_input(&self) -> bool {
1437        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1438        !self.input.borrow().values().all(|q| q.is_empty())
1439    }
1440
1441    fn only_one_possible_decision(&self) -> bool {
1442        // Even a sole buffered version for a key admits two resolutions (a key not yet
1443        // in the snapshot may stay withheld; a key with a previous value may keep it).
1444        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1445        self.input.borrow().values().all(|q| q.is_empty())
1446    }
1447
1448    fn release_decision(&mut self, log_writer: Option<&mut dyn std::fmt::Write>) {
1449        if let Some(to_release) = self.to_release.take() {
1450            if let Some(log_writer) = log_writer {
1451                let HookLocationMeta {
1452                    location: batch_location,
1453                    line,
1454                    caret_indent,
1455                } = self.batch_location;
1456                let note_str = if to_release.is_empty() {
1457                    "^ releasing no items".to_owned()
1458                } else {
1459                    let mut mapping_text = String::new();
1460                    for (key, value, is_new) in &to_release {
1461                        let entry_text = if *is_new {
1462                            format!(
1463                                "{:?}: {:?}",
1464                                ManualDebug(key, self.format_key_debug),
1465                                ManualDebug(value, self.format_value_debug)
1466                            )
1467                        } else {
1468                            format!(
1469                                "{:?}: {:?} (unchanged)",
1470                                ManualDebug(key, self.format_key_debug),
1471                                ManualDebug(value, self.format_value_debug)
1472                            )
1473                        };
1474                        if !mapping_text.is_empty() {
1475                            mapping_text.push_str(", ");
1476                        }
1477                        mapping_text.push_str(&entry_text);
1478                    }
1479                    format!("^ releasing items: {{ {} }}", mapping_text)
1480                };
1481
1482                log_release(
1483                    log_writer,
1484                    batch_location,
1485                    line,
1486                    caret_indent,
1487                    &note_str,
1488                    colored::Color::Green,
1489                );
1490            }
1491
1492            for (key, value, _) in to_release {
1493                self.output.try_send((key, value)).unwrap();
1494            }
1495        } else {
1496            panic!("No decision to release");
1497        }
1498    }
1499
1500    fn location_meta(&self) -> HookLocationMeta {
1501        self.batch_location
1502    }
1503}
1504
1505impl<K: Hash + Eq + Clone, V: Clone> TickInputHook for KeyedSingletonHook<K, V> {
1506    fn can_trigger_tick(&self) -> bool {
1507        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1508        !self.input.borrow().values().all(|q| q.is_empty())
1509    }
1510
1511    fn autonomous_decision<'a>(
1512        &mut self,
1513        driver: &mut Borrowed<'a>,
1514        mut force_trigger: bool,
1515    ) -> bool {
1516        let mut current_input = self.input.borrow_mut();
1517        self.to_release = Some(vec![]);
1518        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1519        let nonempty_key_count = current_input.values().filter(|q| !q.is_empty()).count();
1520
1521        let mut remaining_nonempty_keys = nonempty_key_count;
1522        let mut any_triggered = false;
1523        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1524        for (key, queue) in current_input.iter_mut() {
1525            if queue.is_empty() {
1526                self.to_release.as_mut().unwrap().push((
1527                    key.clone(),
1528                    self.last_released.get(key).unwrap().clone(),
1529                    false,
1530                ));
1531
1532                continue;
1533            }
1534
1535            remaining_nonempty_keys -= 1;
1536
1537            let must_reveal = force_trigger && remaining_nonempty_keys == 0;
1538
1539            if !must_reveal
1540                && self.last_released.contains_key(key)
1541                && produce().generate(driver).unwrap()
1542            {
1543                // Re-release the last item for this key
1544                let last = self.last_released.get(key).unwrap().clone();
1545                self.to_release
1546                    .as_mut()
1547                    .unwrap()
1548                    .push((key.clone(), last, false));
1549            } else {
1550                let allow_null_release = !must_reveal && !self.last_released.contains_key(key);
1551                if allow_null_release && produce().generate(driver).unwrap() {
1552                    // Don't emit anything, this key is not yet added to the snapshot
1553                    continue;
1554                } else {
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
1572        any_triggered
1573    }
1574}
1575
1576/// A scripted decision for a keyed snapshot hook: which buffered version each key's next
1577/// tick execution observes.
1578///
1579/// The [`Self::Reveal`] payload is a `Vec` rather than a map: the scripted entry order is
1580/// preserved for the release log, and the handle rejects duplicate keys when the decision
1581/// is created, so key distinctness is an invariant rather than a map-enforced property.
1582#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1583pub enum KeyedSnapshotDecision<K, V> {
1584    /// For each named key, reveal the first buffered version equal to the named value,
1585    /// skipping over earlier versions. Keys are distinct (enforced by the handle when
1586    /// the decision is created; a key observes exactly one version per tick). Keys that
1587    /// are not named observe their previously revealed version again (or stay absent if
1588    /// they have never been revealed).
1589    Reveal(Vec<(K, V)>),
1590    /// For every key, reveal the newest version that has arrived by the time the tick
1591    /// fires (keys with nothing newer observe their previously revealed version again).
1592    RevealLatest,
1593    /// Every key observes its previously revealed version again.
1594    Keep,
1595}
1596
1597impl<K, V> ScriptDecision for KeyedSnapshotDecision<K, V>
1598where
1599    K: serde::Serialize + serde::de::DeserializeOwned,
1600    V: serde::Serialize + serde::de::DeserializeOwned,
1601{
1602    fn describe(&self) -> String {
1603        match self {
1604            KeyedSnapshotDecision::Reveal(_) => "reveal(..)".to_owned(),
1605            KeyedSnapshotDecision::RevealLatest => "reveal_latest()".to_owned(),
1606            KeyedSnapshotDecision::Keep => "keep()".to_owned(),
1607        }
1608    }
1609}
1610
1611/// The pending-input view a keyed snapshot hook reports to its test-side handle (see
1612/// [`ScriptableHook::status`]), used by `pause_until_*` predicates.
1613#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1614pub struct KeyedSnapshotStatus {
1615    /// The total number of buffered versions (across all keys) newer than the last
1616    /// revealed ones.
1617    pub newer_versions: usize,
1618    /// The number of keys with at least one newer buffered version.
1619    pub keys_with_newer_versions: usize,
1620}
1621
1622impl<K, V> ScriptableHook for KeyedSingletonHook<K, V>
1623where
1624    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
1625    V: Clone + PartialEq + serde::Serialize + serde::de::DeserializeOwned,
1626{
1627    type Decision = KeyedSnapshotDecision<K, V>;
1628    type Status = KeyedSnapshotStatus;
1629
1630    fn is_honorable(&self, decision: &KeyedSnapshotDecision<K, V>) -> Result<bool, String> {
1631        let input = self.input.borrow();
1632        Ok(match decision {
1633            KeyedSnapshotDecision::Reveal(entries) => entries.iter().all(|(key, target)| {
1634                input
1635                    .get(key)
1636                    .is_some_and(|queue| queue.iter().any(|version| version == target))
1637            }),
1638            KeyedSnapshotDecision::RevealLatest => {
1639                keyed_buffer_len(&input) > 0 || !self.last_released.is_empty()
1640            }
1641            KeyedSnapshotDecision::Keep => true,
1642        })
1643    }
1644
1645    fn apply(&mut self, decision: KeyedSnapshotDecision<K, V>) {
1646        let mut input = self.input.borrow_mut();
1647        match decision {
1648            KeyedSnapshotDecision::Reveal(entries) => {
1649                let mut to_release = vec![];
1650                let mut named: FxHashSet<K> = FxHashSet::default();
1651                for (key, target) in entries {
1652                    let queue = input.get_mut(&key).unwrap();
1653                    let idx = queue.iter().position(|version| *version == target).unwrap();
1654                    let skipped: Vec<V> = queue.drain(0..idx).collect();
1655                    let item = queue.pop_front().unwrap();
1656                    self.skipped_states.insert(key.clone(), skipped);
1657                    self.last_released.insert(key.clone(), item.clone());
1658                    to_release.push((key.clone(), item, true));
1659                    named.insert(key);
1660                }
1661                // Unnamed keys observe their previously revealed version again.
1662                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1663                for (key, last) in self.last_released.iter() {
1664                    if !named.contains(key) {
1665                        to_release.push((key.clone(), last.clone(), false));
1666                    }
1667                }
1668                self.to_release = Some(to_release);
1669            }
1670            KeyedSnapshotDecision::RevealLatest => {
1671                let mut to_release = vec![];
1672                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1673                for (key, queue) in input.iter_mut() {
1674                    if queue.is_empty() {
1675                        if let Some(last) = self.last_released.get(key) {
1676                            to_release.push((key.clone(), last.clone(), false));
1677                        }
1678                    } else {
1679                        let skip_count = queue.len() - 1;
1680                        let skipped: Vec<V> = queue.drain(0..skip_count).collect();
1681                        let item = queue.pop_front().unwrap();
1682                        self.skipped_states.insert(key.clone(), skipped);
1683                        self.last_released.insert(key.clone(), item.clone());
1684                        to_release.push((key.clone(), item, true));
1685                    }
1686                }
1687                self.to_release = Some(to_release);
1688            }
1689            KeyedSnapshotDecision::Keep => {
1690                #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1691                let to_release = self
1692                    .last_released
1693                    .iter()
1694                    .map(|(key, last)| (key.clone(), last.clone(), false))
1695                    .collect();
1696                self.to_release = Some(to_release);
1697            }
1698        }
1699    }
1700
1701    fn implicit(&mut self) {
1702        // "Nothing new": every previously revealed key observes its value again.
1703        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1704        let to_release = self
1705            .last_released
1706            .iter()
1707            .map(|(key, last)| (key.clone(), last.clone(), false))
1708            .collect();
1709        self.to_release = Some(to_release);
1710    }
1711
1712    fn status(&self) -> KeyedSnapshotStatus {
1713        let input = self.input.borrow();
1714        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1715        let keys_with_newer_versions = input.values().filter(|q| !q.is_empty()).count();
1716        KeyedSnapshotStatus {
1717            newer_versions: keyed_buffer_len(&input),
1718            keys_with_newer_versions,
1719        }
1720    }
1721
1722    fn describe_pending(&self) -> Option<String> {
1723        let input = self.input.borrow();
1724        let total = keyed_buffer_len(&input);
1725        #[expect(clippy::disallowed_methods, reason = "FxHasher is deterministic")]
1726        let keys = input.values().filter(|q| !q.is_empty()).count();
1727        (total > 0).then(|| format!("{} buffered version(s) across {} key(s)", total, keys))
1728    }
1729}
1730
1731impl<K, V> ScriptableTickInputHook for KeyedSingletonHook<K, V>
1732where
1733    K: Hash + Eq + Clone + serde::Serialize + serde::de::DeserializeOwned,
1734    V: Clone + PartialEq + serde::Serialize + serde::de::DeserializeOwned,
1735{
1736    fn decision_triggers_tick(&self, decision: &KeyedSnapshotDecision<K, V>) -> bool {
1737        match decision {
1738            KeyedSnapshotDecision::Reveal(entries) => !entries.is_empty(),
1739            KeyedSnapshotDecision::RevealLatest => keyed_buffer_len(&self.input.borrow()) > 0,
1740            KeyedSnapshotDecision::Keep => false,
1741        }
1742    }
1743}