Skip to main content

risingwave_stream/executor/match_recognize/
incremental.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Incremental driver over the row-pattern [`Nfa`].
16//!
17//! The batch matcher [`Nfa::find_matches_dynamic`] rescans the whole buffer from position 0 on every
18//! call. Under append-only input (rows arriving in `ORDER BY` order) most of that work is redundant:
19//! `AFTER MATCH SKIP` makes the matches before a committed skip-resume point immutable, because no
20//! row appended *after* them can change a match that already terminated *before* them. This wrapper
21//! keeps that skip-resume point as a scan cursor and, on each [`IncrementalMatcher::advance`], reruns
22//! `find_matches_dynamic` **only over the suffix that can still change** — never reimplementing the
23//! NFA traversal (and so never bypassing its greedy/reluctant preference, fresh-visited-scope, or
24//! `WITHIN` invariants).
25//!
26//! Matches are anchored by row *seq* rather than buffer *position* so they stay stable when earlier
27//! rows are evicted and finalized (see [`IncrementalMatcher::finalize_evicted_prefix`]); positions are an
28//! internal detail of the current buffer.
29//!
30//! Freezing rule (see [`IncrementalMatcher::advance`]): a match — and the scan region behind it up
31//! to its skip-resume position — freezes only once *every* position in that region is dead at the
32//! current boundary per [`Nfa::reaches_boundary_alive`] (the same liveness predicate row eviction
33//! uses). A dead position's scan outcome can never change under appended rows, because no path from
34//! it can consume past the old boundary; so the whole region's scan behavior — matches found, gaps
35//! skipped, and the resume point — is final. Any live position (a still-open trailing match, or a
36//! gap where a longer, higher-preference alternative is still in flight) keeps the region
37//! provisional and re-attempted on the next advance.
38//!
39//! A late (out-of-order) row that sorts *before* rows already fed is handled by
40//! `IncrementalMatcher::truncate_from_seq`: it rolls state back to a scan-resume point at or before
41//! the insertion, re-verifying the freezing gate against the truncation boundary (freezing is only
42//! sound against the boundary it was checked at), after which the caller re-feeds the corrected
43//! sorted suffix through `advance`.
44//!
45//! **Scaffolding note:** under the `EVENT_TIME` plan the upstream `EowcSort` makes out-of-order
46//! feeds unreachable, so `truncate_from_seq` and the provisional-changelog helpers
47//! (`diff_provisional`, `plan_provisional_rows`, `fed_seqs`) have no production caller and are
48//! compiled `#[cfg(test)]`. They are kept — proven by the randomized differential oracle in this
49//! module — as the invalidation machinery a future input mode that revises already-fed rows (e.g.
50//! an emit-on-update or arrival-order mode) would need, rather than shipped as live surface.
51
52#[cfg(test)]
53use std::cmp::Ordering;
54#[cfg(test)]
55use std::collections::HashMap;
56
57#[cfg(test)]
58use risingwave_common::array::Op;
59#[cfg(test)]
60use risingwave_common::row::OwnedRow;
61
62use crate::executor::error::StreamExecutorResult;
63use crate::executor::match_recognize::nfa::{
64    CandidateMatcher, LabeledMatch, MatchScan, Nfa, ScanBudget, SkipMode,
65};
66
67/// A row's stable sequence number: the buffer-table PK tiebreaker minted at ingest, unique for the
68/// buffer's lifetime and stable across eviction. A newtype (not a bare `i64`) so a seq can never be
69/// confused with a buffer *position* (a bare `usize`) at the incremental-matcher seams — the two are
70/// different integer spaces. The raw `i64` is unwrapped (`.0`) only at the two real boundaries: the
71/// state-table storage (de)serialization, and where the `_match_id` output datum is built.
72#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
73pub struct Seq(pub i64);
74
75/// A match anchored by row seqs (stable across eviction), not buffer positions. `start_seq` is the
76/// seq of the match's first row; `end_seq` is one past the seq of its last row (so `end_seq -
77/// start_seq` equals the row count only while seqs are contiguous). `labels[i]` is the pattern
78/// variable bound to the match's `i`-th row.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct SeqMatch {
81    pub start_seq: Seq,
82    pub end_seq: Seq,
83    pub labels: Vec<String>,
84}
85
86/// Outcome of [`IncrementalMatcher::finalize_evicted_prefix`]: whether the matcher could finalize the
87/// evicted prefix in place (staying reusable) or the eviction shape forces the caller to drop and
88/// rebuild it.
89#[derive(Debug)]
90#[must_use = "a dropped MustRebuild leaves a silently stale matcher — match on the result"]
91pub enum Finalized {
92    /// The evicted prefix was finalized and the matcher rebased onto the surviving buffer; keep it.
93    /// The finalized matches themselves are not carried: the executor prunes its diff base by
94    /// surviving start seqs, uniformly across this and the drop-and-rebuild path, so a payload here
95    /// would be an allocation nobody reads.
96    Rebased,
97    /// The eviction cannot be rebased across — a boundary never fed, past the frozen prefix, or a
98    /// frozen match straddling the boundary strictly *inside* the frozen prefix (`final_pos <
99    /// next_pos`, reachable only via a direct API call, never the executor's eviction) — so the
100    /// matcher was left untouched and the caller must drop it and let the next visit rebuild from
101    /// the surviving buffer.
102    MustRebuild,
103}
104
105/// Diff two provisional match sets — each a `(match, output row)` list, in **any order** — into the
106/// changelog ops that turn `old` into `new`. Match identity is `start_seq` (the emitted
107/// `_match_id`):
108///
109/// * a match in `old` but not `new` (its start vanished) → `Delete` of its old row;
110/// * a match in `new` but not `old` (a brand-new start) → `Insert` of its new row;
111/// * a match present in both whose extent (`end_seq`), labels, or output row changed →
112///   `Delete(old)` then `Insert(new)`;
113/// * an unchanged match → no ops.
114///
115/// Ops come out start-seq ascending, `Delete` before `Insert` for a revised identity — the retract
116/// encoding of an update to a `_match_id` key.
117///
118/// The sort-by-identity the merge needs is enforced *here*, not assumed of the caller: the executor
119/// hands over [`IncrementalMatcher::provisional`]-derived lists, which are in the matcher's scan
120/// (buffer-position) order — and under out-of-order arrival that is **not** start-seq order, because
121/// seqs are minted at arrival, so a late row that sorts earlier carries a *higher* seq at an
122/// *earlier* position. A merge over such inputs would pair unrelated identities (net-deleting a
123/// match that is still in `new`).
124/// Test-only on this executor: the emit-on-update mode that drives it is not part of this
125/// operator; the differential oracle exercises it so the contract stays proven.
126#[cfg(test)]
127pub fn diff_provisional(
128    old: &[(SeqMatch, OwnedRow)],
129    new: &[(SeqMatch, OwnedRow)],
130) -> Vec<(Op, OwnedRow)> {
131    // Start seqs are unique within one provisional set (matches start at distinct rows), so this is
132    // a total order on each side and the linear merge below is well-defined.
133    let mut old_sorted: Vec<&(SeqMatch, OwnedRow)> = old.iter().collect();
134    old_sorted.sort_by_key(|(m, _)| m.start_seq);
135    let mut new_sorted: Vec<&(SeqMatch, OwnedRow)> = new.iter().collect();
136    new_sorted.sort_by_key(|(m, _)| m.start_seq);
137
138    let mut ops = Vec::new();
139    let (mut i, mut j) = (0usize, 0usize);
140    while i < old_sorted.len() && j < new_sorted.len() {
141        let (om, orow) = old_sorted[i];
142        let (nm, nrow) = new_sorted[j];
143        match om.start_seq.cmp(&nm.start_seq) {
144            // `old`'s start sorts first and has no `new` counterpart: it vanished.
145            Ordering::Less => {
146                ops.push((Op::Delete, orow.clone()));
147                i += 1;
148            }
149            // `new`'s start sorts first and has no `old` counterpart: it is brand new.
150            Ordering::Greater => {
151                ops.push((Op::Insert, nrow.clone()));
152                j += 1;
153            }
154            // Same identity: emit a Delete+Insert pair when the extent, labels, or output row
155            // changed; nothing when the match is byte-for-byte unchanged.
156            Ordering::Equal => {
157                if om.end_seq != nm.end_seq || om.labels != nm.labels || orow != nrow {
158                    ops.push((Op::Delete, orow.clone()));
159                    ops.push((Op::Insert, nrow.clone()));
160                }
161                i += 1;
162                j += 1;
163            }
164        }
165    }
166    // Only one side can have leftovers; their start_seqs all exceed everything emitted so far, so
167    // appending them (deletes for `old`, inserts for `new`) keeps the run start-seq ascending.
168    for (_, orow) in &old_sorted[i..] {
169        ops.push((Op::Delete, orow.clone()));
170    }
171    for (_, nrow) in &new_sorted[j..] {
172        ops.push((Op::Insert, nrow.clone()));
173    }
174    ops
175}
176
177/// The row-independent half of the emit-on-update diff: decide, per new provisional match, whether
178/// its output row can be reused from the base or must be (re)built — *before* building any row.
179///
180/// Returned positionally over `new`: `Some(i)` means base entry `old[i]` holds a byte-identical
181/// output row to reuse; `None` means a fresh row must be built. Reuse is sound exactly when identity
182/// (`start_seq`) and content (`end_seq`, `labels`) all match: the output row is a pure function of the
183/// append-only matched rows plus their labels, so an identical `(start_seq, end_seq, labels)` triple
184/// pins byte-identical rows. (A late row landing *inside* the span would make the contiguous match
185/// cover one more position, lengthening `labels`; a row landing *outside* the span shifts positions
186/// but leaves the covered rows' seqs and values untouched — append-only rows never change.)
187///
188/// This lets the executor call the expensive `build_match_row` only for `None` entries: in the steady
189/// state a partition's provisional set is unchanged barrier-to-barrier, so every entry reuses and
190/// nothing is rebuilt. The changelog is still assembled by [`diff_provisional`] in the same order;
191/// because a reused row equals its base row and a changed match differs in `end_seq`/`labels`,
192/// `diff_provisional`'s row-inequality tie-break never independently fires on the executor path (it
193/// remains live only for synthetic-row callers such as the unit tests).
194/// Test-only on this executor: the emit-on-update mode that drives it is not part of this
195/// operator; the differential oracle exercises it so the contract stays proven.
196#[cfg(test)]
197pub fn plan_provisional_rows(old: &[(SeqMatch, OwnedRow)], new: &[SeqMatch]) -> Vec<Option<usize>> {
198    let mut by_seq: HashMap<Seq, usize> = HashMap::with_capacity(old.len());
199    for (i, (m, _)) in old.iter().enumerate() {
200        by_seq.insert(m.start_seq, i);
201    }
202    new.iter()
203        .map(|m| {
204            by_seq
205                .get(&m.start_seq)
206                .copied()
207                .filter(|&i| old[i].0.end_seq == m.end_seq && old[i].0.labels == m.labels)
208        })
209        .collect()
210}
211
212/// Incremental wrapper around [`Nfa::find_matches_dynamic`] for append-only input.
213///
214/// Rows are fed in `ORDER BY` order via [`IncrementalMatcher::advance`]; their buffer position is
215/// implied by feed order and mapped back to a stable seq through `seq_index`. Everything before
216/// `next_pos` (the skip-resume point after the last frozen match) is immutable and never rescanned.
217pub struct IncrementalMatcher {
218    /// Shared compiled pattern: one matcher per partition (and a fresh one per full consumption),
219    /// so holding the automaton by `Arc` instead of by value avoids a deep clone per instance —
220    /// while still keeping this struct free of a lifetime the executor would have to thread
221    /// through.
222    nfa: std::sync::Arc<Nfa>,
223    /// `AFTER MATCH SKIP` strategy, shared with the batch path.
224    skip: SkipMode,
225    /// All matches over the rows fed so far. `matched[..frozen_count]` are frozen (immutable under
226    /// future appends); the rest is the provisional tail recomputed on every advance.
227    matched: Vec<SeqMatch>,
228    /// Number of leading entries of `matched` that are frozen.
229    frozen_count: usize,
230    /// Buffer position where the next rescan begins: the skip-resume point after the last frozen
231    /// match (0 while nothing is frozen). The suffix `[next_pos, n_rows)` is the only mutable region.
232    next_pos: usize,
233    /// `seq_index[pos]` is the seq of the row fed at buffer position `pos`. Its length is the number
234    /// of rows fed so far (the batch `n_rows`).
235    seq_index: Vec<Seq>,
236    /// Whether the last rescan stopped on a spent budget, leaving `matched`'s provisional tail a
237    /// (possibly empty) leftmost-PREFIX of the true match list rather than the full list. While
238    /// set, absence of a match from `provisional()` is NOT evidence of absence: the executor must
239    /// re-derive (fresh budget) before any decision that treats missing matches as decided — the
240    /// WITHIN-deadline prune in particular would otherwise delete rows carrying a match the
241    /// truncated scan never reached. Survives [`IncrementalMatcher::finalize_evicted_prefix`]
242    /// together with the found prefix and the scan cursor, which the rebase shifts rather than
243    /// resets: the unscanned suffix is untouched by an eviction, so the flag still describes it.
244    incomplete: bool,
245    /// Absolute buffer position where a budget-truncated match scan will resume. Unlike
246    /// `matchless_upto`, this may follow successful matches: the corresponding leftmost-prefix of
247    /// matches remains in `matched`, and the next refresh appends matches found from this cursor.
248    /// Reset whenever rows are appended or invalidated, because those changes can alter previously
249    /// provisional matches and their skip-resume positions.
250    scan_cursor: usize,
251    /// Positions `[next_pos, dead_upto)` proven dead at the boundary by the freeze walks of this
252    /// and earlier visits (`next_pos <= matchless_upto <= dead_upto` always). Deadness is monotone
253    /// under appends — a walk reads only rows at or before its position (there is no forward
254    /// navigation: `NEXT` inside `DEFINE` is rejected at bind and decode time), so a position no
255    /// path can carry to the boundary stays that way as rows arrive — which lets a freeze that ran
256    /// out of budget resume where it stopped instead of restarting at `next_pos`. Without this,
257    /// freezing a match of `L` rows costs Θ(L²) steps in ONE visit (each of its `L` positions
258    /// walks up to `L` rows), and once that exceeds the per-visit budget the region never freezes:
259    /// the permanent, non-self-healing shape a long chain pattern (`a{600}`) otherwise degrades
260    /// into. Reset to `next_pos` whenever the rows a verdict was computed over can change
261    /// (truncation); an eviction rebase only shifts it, since a verdict about a surviving start was
262    /// computed over surviving rows (see [`IncrementalMatcher::finalize_evicted_prefix`]).
263    dead_upto: usize,
264    /// Starts `[next_pos, matchless_upto)` proven MATCHLESS FOREVER by the finder: their walks found
265    /// no accept and never reached the boundary, so they died entirely on immutable rows (see
266    /// [`MatchScan::matchless_upto`]). The next rescan begins past them. This is the finder's
267    /// counterpart of `dead_upto` — and feeds it, since such a start is dead too: without it, a
268    /// long run broken by one non-matching row costs Θ(r²) on EVERY rescan (each start walks to
269    /// the break and dies), past the budget from `r ≈ 840`, and a partition in that state never
270    /// completes a rescan again. Same resets as `dead_upto`.
271    matchless_upto: usize,
272    /// Whether the last freeze loop stopped on a spent budget with its region unfinished. The
273    /// executor refreshes on this like on `incomplete`, so a truncated freeze resumes on the next
274    /// watermark visit rather than only on the next arrival.
275    freeze_truncated: bool,
276}
277
278/// Adapts a [`CandidateMatcher`] so that a scan over the suffix `[offset, ..)` sees suffix-relative
279/// positions `0, 1, ...` while the underlying matcher still resolves absolute buffer positions. This
280/// lets us drive [`Nfa::find_matches_dynamic`] over just the mutable suffix using the *same* matcher
281/// the batch path uses, without adding a start-offset parameter to the NFA.
282struct OffsetMatcher<'a, M> {
283    inner: &'a M,
284    offset: usize,
285}
286
287impl<M: CandidateMatcher + Sync> CandidateMatcher for OffsetMatcher<'_, M> {
288    fn matches(
289        &self,
290        var: &str,
291        pos: usize,
292        labels: &[String],
293    ) -> impl std::future::Future<Output = StreamExecutorResult<bool>> + Send {
294        self.inner.matches(var, pos + self.offset, labels)
295    }
296}
297
298impl IncrementalMatcher {
299    pub fn new(nfa: std::sync::Arc<Nfa>, skip: SkipMode) -> Self {
300        Self {
301            nfa,
302            skip,
303            matched: Vec::new(),
304            frozen_count: 0,
305            next_pos: 0,
306            seq_index: Vec::new(),
307            incomplete: false,
308            scan_cursor: 0,
309            dead_upto: 0,
310            matchless_upto: 0,
311            freeze_truncated: false,
312        }
313    }
314
315    /// Reset to the freshly-constructed state, keeping the (shared) automaton and skip mode and
316    /// reusing the collections' allocations. Equivalent to a new matcher: used when a consumed
317    /// prefix swallows the whole buffer, where reconstructing would re-clone the skip mode for
318    /// nothing.
319    pub fn reset(&mut self) {
320        self.matched.clear();
321        self.frozen_count = 0;
322        self.next_pos = 0;
323        self.seq_index.clear();
324        self.incomplete = false;
325        self.scan_cursor = 0;
326        self.dead_upto = 0;
327        self.matchless_upto = 0;
328        self.freeze_truncated = false;
329    }
330
331    /// Whether the last rescan was truncated by a spent budget — see the field doc. The flag and
332    /// the found prefix both survive an eviction rebase. While true, `provisional()` is a
333    /// leftmost-prefix under-approximation.
334    pub fn is_incomplete(&self) -> bool {
335        self.incomplete
336    }
337
338    /// Whether a visit's rescan should be re-run with a fresh budget before deciding anything:
339    /// the provisional tail is incomplete ([`IncrementalMatcher::is_incomplete`]), or the freeze
340    /// stopped on a spent budget and has proven-dead progress to resume from.
341    pub fn needs_refresh(&self) -> bool {
342        self.incomplete || self.freeze_truncated
343    }
344
345    /// Re-derive the provisional tail with a fresh budget, without feeding rows: the executor's
346    /// recovery valve for [`IncrementalMatcher::is_incomplete`]. Delegates to the same rescan
347    /// `advance` performs.
348    pub async fn refresh(
349        &mut self,
350        matcher: &(impl CandidateMatcher + Sync),
351        budget: &mut ScanBudget,
352        memoize: bool,
353    ) -> StreamExecutorResult<()> {
354        self.rescan(matcher, budget, memoize).await
355    }
356
357    /// The skip-resume position after the last frozen match — the left edge of the still-revisable
358    /// region, in fed-position (= the executor's buffer-index) terms. The executor's emission gate
359    /// re-checks gap liveness from here: a position in `[resume_pos, match start)` that is still
360    /// alive at the boundary can produce an earlier, leftmost-preferred match and must hold the
361    /// emission.
362    pub fn resume_pos(&self) -> usize {
363        self.next_pos
364    }
365
366    /// End of the prefix of fed positions proven dead at the boundary — every position below it
367    /// can never again start a match (see the `dead_upto` field). The executor's own liveness
368    /// walks (the dead-prefix prune, the emission gate's gap check) skip these positions instead
369    /// of re-deriving a verdict the freeze already paid for.
370    pub fn dead_prefix_end(&self) -> usize {
371        self.dead_upto
372    }
373
374    /// Feed rows appended in `ORDER BY` order. `new_row_seqs` are the seqs of the newly appended rows;
375    /// their buffer positions are the next positions after the rows fed so far. An empty call is a
376    /// no-op.
377    ///
378    /// Rescans only the mutable suffix `[next_pos, n_rows)` via the budgeted, memoized pull scan
379    /// ([`Nfa::next_match`] through an [`OffsetMatcher`]), replacing the provisional tail of
380    /// `matched`. It then freezes the leading
381    /// run of suffix matches whose entire scan region `[cursor, skip-resume)` is dead at the boundary
382    /// per [`Nfa::reaches_boundary_alive`], advancing `next_pos` to the last frozen match's resume
383    /// position. Checking the *whole region* — not just the match's start — matters: a gap position
384    /// before the match can be alive (a longer, higher-preference alternative still in flight) and a
385    /// future row could then produce a match there that consumes past this one, so nothing behind
386    /// that gap may freeze. Liveness is checked with the raw `matcher` at absolute positions (only
387    /// the finder needs the offset adapter, because it always scans from 0).
388    pub async fn advance(
389        &mut self,
390        new_row_seqs: &[Seq],
391        matcher: &(impl CandidateMatcher + Sync),
392        budget: &mut ScanBudget,
393        memoize: bool,
394    ) -> StreamExecutorResult<()> {
395        if new_row_seqs.is_empty() {
396            return Ok(());
397        }
398        // `advance` only ever appends genuinely new rows. Re-feeding an already-fed seq (a late row
399        // landing before fed rows, or an over-feed narrowing back) must instead go through
400        // `truncate_from_seq` / `refresh_matcher`, which roll `seq_index` back first; appending a
401        // duplicate here would corrupt the seq→position mapping.
402        debug_assert!(
403            new_row_seqs.iter().all(|s| !self.seq_index.contains(s)),
404            "re-feeds must go through refresh_matcher/truncate, not advance"
405        );
406        // Appended rows can extend a greedy match or make a formerly boundary-blocked start match,
407        // so a saved scan prefix is no longer reusable. Matchless-forever starts remain valid.
408        self.incomplete = false;
409        self.scan_cursor = self.matchless_upto;
410        self.freeze_truncated = false;
411        self.seq_index.extend_from_slice(new_row_seqs);
412        self.rescan(matcher, budget, memoize).await
413    }
414
415    /// Re-derive the provisional tail over the already-fed mutable suffix `[next_pos, n_rows)`
416    /// without feeding new rows — the rescan half of [`IncrementalMatcher::advance`], exposed for
417    /// the one caller shape where a rescan must happen with nothing to feed: an **over-feed
418    /// rollback**. When rows beyond the caller's current window were fed (e.g. an emit-on-update
419    /// whole-buffer feed followed by a watermark visit over just the safe prefix),
420    /// `IncrementalMatcher::truncate_from_seq` at the first over-fed row rolls the tail back but
421    /// also drops every provisional match over the *retained* fed suffix; those rows are still fed,
422    /// so there is nothing to `advance` (re-feeding them would double-enter `seq_index`) and the
423    /// dropped matches must be re-derived in place. After this call `provisional()` equals a
424    /// from-scratch batch scan of the fed rows.
425    async fn rescan(
426        &mut self,
427        matcher: &(impl CandidateMatcher + Sync),
428        budget: &mut ScanBudget,
429        memoize: bool,
430    ) -> StreamExecutorResult<()> {
431        let n_rows = self.seq_index.len();
432
433        // The cursor invariant every mutation site maintains: `next_pos <= matchless_upto <=
434        // dead_upto`, and the resumable cursors inside the fed rows. Checked at runtime, not only in
435        // debug builds: production has no overflow checks, so a breached invariant would wrap the
436        // suffix arithmetic below into a scan window past the buffer, and the rescan would silently
437        // find nothing — permanent match loss with no signal. Two shapes, two answers:
438        // - a frozen prefix longer than the buffer references rows that do not exist; nothing in
439        //   place can recover that, so fail the actor and let recovery rebuild from state (the
440        //   `MustRebuild` answer of the eviction rebase);
441        // - cursors outside `[next_pos, n_rows]` are forgotten verdicts: drop them all, as
442        //   truncation does, and rescan from the frozen boundary. Re-walking starts is
443        //   conservative; skipping them is not. The truncation flags go with the cursors — a kept
444        //   `incomplete` would re-attach the retained provisional prefix in front of a scan that
445        //   finds it again.
446        if self.next_pos > n_rows {
447            crate::consistency::consistency_panic!(
448                next_pos = self.next_pos,
449                n_rows,
450                "MATCH_RECOGNIZE frozen prefix is longer than the fed buffer",
451            );
452            return Err(anyhow::anyhow!(
453                "MATCH_RECOGNIZE matcher state references rows past the buffer (next_pos {} > \
454                 n_rows {}); restarting to rebuild from state",
455                self.next_pos,
456                n_rows
457            )
458            .into());
459        }
460        let cursors_in_range = |m: &Self| {
461            m.next_pos <= m.matchless_upto
462                && m.matchless_upto <= m.dead_upto
463                && m.dead_upto <= n_rows
464                && m.next_pos <= m.scan_cursor
465                && m.scan_cursor <= n_rows
466        };
467        if !cursors_in_range(self) {
468            crate::consistency::consistency_panic!(
469                next_pos = self.next_pos,
470                matchless_upto = self.matchless_upto,
471                dead_upto = self.dead_upto,
472                scan_cursor = self.scan_cursor,
473                n_rows,
474                "MATCH_RECOGNIZE scan cursors left the buffer; rescanning from the frozen boundary",
475            );
476            self.matchless_upto = self.next_pos;
477            self.dead_upto = self.next_pos;
478            self.scan_cursor = self.next_pos;
479            self.incomplete = false;
480            self.freeze_truncated = false;
481        }
482
483        // Rescan the mutable suffix only. The offset matcher maps suffix-relative positions produced
484        // by the scan back onto absolute buffer positions the real matcher understands.
485        let offset = self.next_pos;
486        let offset_matcher = OffsetMatcher {
487            inner: matcher,
488            offset,
489        };
490        // The finder is the exponential walk the scan budget and the failure memo exist for —
491        // drive the pull-based scan directly so both actually meter it. On a spent budget the
492        // loop stops early and the tail is INCOMPLETE: the freeze loop below holds (it treats
493        // `budget.hit` as "alive"), the executor's emission gate holds, and the next visit
494        // rescans with a fresh budget — degraded latency, never a wrong or lost match.
495        let mut continuing_scan = self.incomplete;
496        let mut resume_freeze = !continuing_scan && self.freeze_truncated;
497        let mut tail_abs: Vec<LabeledMatch> = if continuing_scan || resume_freeze {
498            // Preserve the already-scanned leftmost prefix. Matches are seq-anchored in storage, so
499            // recover their current buffer positions before appending the resumed scan's suffix.
500            let mut search_from = self.next_pos;
501            self.matched[self.frozen_count..]
502                .iter()
503                .map(|m| {
504                    let start = search_from
505                        + self.seq_index[search_from..]
506                            .iter()
507                            .position(|&seq| seq == m.start_seq)
508                            .expect("provisional match start seq must still be fed");
509                    search_from = start + 1;
510                    LabeledMatch {
511                        start,
512                        end: start + m.labels.len(),
513                        labels: m.labels.clone(),
514                    }
515                })
516                .collect()
517        } else {
518            Vec::new()
519        };
520        // A resumed scan continues AFTER the retained provisional prefix: on the healthy path
521        // `scan_cursor` is where the truncated pull stopped, at or past the last retained match's
522        // resume position. A cursor inside the buffer but before that point passed the range
523        // check above and would re-find the retained matches and append them a second time —
524        // so it is treated like any other stray cursor: forget the verdicts and the truncation
525        // flags, drop the retained prefix, and rescan from the frozen boundary.
526        if continuing_scan && let Some(last) = tail_abs.last() {
527            let (resume, _) = self.skip.next_pos(last.start, last.end, &last.labels);
528            if self.scan_cursor < resume {
529                crate::consistency::consistency_panic!(
530                    scan_cursor = self.scan_cursor,
531                    last_retained_resume = resume,
532                    "MATCH_RECOGNIZE resumed scan would re-find its retained prefix; rescanning \
533                     from the frozen boundary",
534                );
535                self.matchless_upto = self.next_pos;
536                self.dead_upto = self.next_pos;
537                self.scan_cursor = self.next_pos;
538                self.incomplete = false;
539                self.freeze_truncated = false;
540                tail_abs.clear();
541                continuing_scan = false;
542                resume_freeze = false;
543            }
544        }
545        let mut scan_truncated = false;
546        if !resume_freeze {
547            // A truncated refresh resumes after the successful matches already retained above.
548            // A fresh scan starts after the starts proven matchless forever.
549            let scan_start = if continuing_scan {
550                self.scan_cursor
551            } else {
552                self.matchless_upto
553            };
554            // Both cursors were checked against `[next_pos, n_rows]` above, so the suffix
555            // arithmetic cannot wrap.
556            debug_assert!(self.next_pos <= scan_start && scan_start <= n_rows);
557            let mut scan = MatchScan::starting_at(scan_start - offset);
558            while let Some(m) = self
559                .nfa
560                .next_match(
561                    &mut scan,
562                    n_rows - offset,
563                    &offset_matcher,
564                    &self.skip,
565                    budget,
566                    memoize,
567                )
568                .await?
569            {
570                tail_abs.push(LabeledMatch {
571                    start: m.start + offset,
572                    end: m.end + offset,
573                    labels: m.labels,
574                });
575            }
576            self.scan_cursor = offset + scan.next_start();
577            // Extend the matchless prefix only when this pull began exactly at its end. Once a
578            // successful match creates a gap, the later cursor is resumable but not matchless.
579            if scan_start == self.matchless_upto {
580                self.matchless_upto = offset + scan.matchless_upto();
581            }
582            self.dead_upto = self.dead_upto.max(self.matchless_upto);
583            // Capture this before freeze walks spend more budget. A completed scan followed by a
584            // truncated freeze keeps its complete tail and resumes only the freeze next visit.
585            scan_truncated = budget.hit;
586        }
587
588        // Freeze the leading run of matches whose scan region `[cursor, resume)` is entirely dead at
589        // the boundary. A dead position's scan outcome is final — no path from it can consume past
590        // `n_rows - 1`, so appended rows can never be reached from it and the greedy attempt there
591        // returns the same result over any future buffer. Matches freeze strictly in order (there is
592        // a single cursor), so stop at the first region containing a live position. A match ending
593        // at the boundary is covered without a special case: its own accepting path reaches the
594        // boundary, so its start is alive and the region check fails.
595        let mut newly_frozen = 0usize;
596        let mut cursor = self.next_pos;
597        let mut freeze_truncated = false;
598        debug_assert!(
599            self.dead_upto >= cursor,
600            "dead_upto {} < next_pos {cursor}",
601            self.dead_upto
602        );
603        'freeze: for m in &tail_abs {
604            // The skip-degradation diagnostic is dropped here for the same reason
605            // `Nfa::find_matches_dynamic` drops it: this is freeze-cursor bookkeeping, not an
606            // emission site — the executor recomputes the resume position for the matches it
607            // actually emits and reports from there.
608            let (resume, _) = self.skip.next_pos(m.start, m.end, &m.labels);
609            // `dead_upto >= cursor` throughout: it starts at or past `next_pos`, and a region only
610            // freezes once every position in it is proven dead, so `cursor = resume` keeps it. An
611            // EMPTY range here is the resumed freeze paying off — the whole region was proven dead
612            // by earlier visits, and the match freezes without a walk.
613            for p in self.dead_upto..resume {
614                let alive = self
615                    .nfa
616                    .reaches_boundary_alive(p, n_rows, matcher, budget, memoize)
617                    .await?;
618                // A spent budget is NOT a deadness verdict: the liveness walk returns `false`
619                // when it stops early, and freezing on that fabricated answer advances the cursor
620                // past positions that are genuinely alive — their matches are then lost forever
621                // (the cursor never rewinds) while the prune pass, which guards correctly,
622                // retains their rows forever. Exhaustion holds the freeze; the next rescan resumes
623                // it from `dead_upto` with a fresh budget, so each one makes progress.
624                if budget.hit {
625                    freeze_truncated = true;
626                    break 'freeze;
627                }
628                if alive {
629                    break 'freeze;
630                }
631                // Proven dead, and deadness is monotone under appends: never walk `p` again.
632                self.dead_upto = p + 1;
633            }
634            cursor = resume;
635            newly_frozen += 1;
636        }
637        self.next_pos = cursor;
638        // Freezing moves the resume point past matches, including past starts the finder never
639        // proved anything about; the matchless prefix begins at the resume point by definition.
640        self.matchless_upto = self.matchless_upto.max(cursor);
641        self.scan_cursor = self.scan_cursor.max(cursor);
642        self.freeze_truncated = freeze_truncated;
643
644        // Drop the previous provisional tail and reattach the freshly scanned suffix, moving each
645        // match's labels (this runs per arriving row — a clone here would copy every provisional
646        // label vector once per row for nothing).
647        self.matched.truncate(self.frozen_count);
648        for m in tail_abs {
649            let sm = SeqMatch {
650                start_seq: self.seq_index[m.start],
651                end_seq: Seq(self.seq_index[m.end - 1].0 + 1),
652                labels: m.labels,
653            };
654            self.matched.push(sm);
655        }
656        self.frozen_count += newly_frozen;
657        self.incomplete = scan_truncated;
658
659        Ok(())
660    }
661
662    /// Invalidate everything at and after the fed row identified by `seq`, so the caller can re-feed
663    /// a corrected sorted suffix (an out-of-order row landing before rows already fed). `seq` is the
664    /// stable identity of the first buffered row whose sorted position changes; the executor computes
665    /// it (the first buffered order key `>=` the late row's), and here we only map it back to a fed
666    /// position via `seq_index`.
667    ///
668    /// A seq never fed (e.g. an order key beyond everything buffered) is a no-op; truncating at the
669    /// first fed row (position 0) is a full reset. After this call `provisional()` never returns a
670    /// match overlapping the truncated region.
671    ///
672    /// Why this needs the `matcher` (and so mirrors [`IncrementalMatcher::advance`]'s freezing gate
673    /// rather than a purely positional rule): a match froze against a *later* boundary, and freezing
674    /// only requires every region position to be dead at *that* boundary — a position may still hold
675    /// a path that stays alive *through* the rows now being truncated (a longer, higher-preference
676    /// alternative that only died past the truncation point). Such a frozen match is not final once
677    /// those rows change, even when its own span ends before the truncation point. So we recompute
678    /// the surviving frozen prefix with the exact gate `advance` uses — region-wide
679    /// [`Nfa::reaches_boundary_alive`] — but against the truncation boundary. Only a region entirely
680    /// dead at that boundary is independent of the truncated/re-fed rows and may be kept; the rest
681    /// (and every provisional match) is dropped and re-derived by the following `advance`, which
682    /// rescans from the rewound `next_pos`.
683    /// Test-only on this executor: the emit-on-update mode that drives it is not part of this
684    /// operator; the differential oracle exercises it so the contract stays proven.
685    #[cfg(test)]
686    pub async fn truncate_from_seq(
687        &mut self,
688        seq: Seq,
689        matcher: &(impl CandidateMatcher + Sync),
690        budget: &mut ScanBudget,
691        memoize: bool,
692    ) -> StreamExecutorResult<()> {
693        // Seqs are stable row identities, not sort keys, so `seq_index` is not ordered by value; find
694        // the exact entry. A missing seq means nothing buffered at/after it changed — leave state as
695        // is.
696        let Some(trunc_pos) = self.seq_index.iter().position(|&s| s == seq) else {
697            return Ok(());
698        };
699
700        let mut kept = 0usize;
701        let mut cursor = 0usize;
702        'keep: for m in &self.matched[..self.frozen_count] {
703            // Frozen matches are stored in scan order, so each start is at or after the cursor; search
704            // forward from there to recover its buffer position (seqs are not positions).
705            let start_pos = cursor
706                + self.seq_index[cursor..]
707                    .iter()
708                    .position(|&s| s == m.start_seq)
709                    .expect("frozen match start seq must still be fed at truncation");
710            let end_pos = start_pos + m.labels.len();
711            // A match reaching into (or across) the truncated region cannot survive: the re-fed rows
712            // may change its greedy extent or its skip-resume point.
713            if end_pos > trunc_pos {
714                break;
715            }
716            // `resume <= end_pos <= trunc_pos`, so every checked position is in the retained region.
717            // Diagnostic dropped: truncation bookkeeping, not an emission site (see
718            // `Nfa::find_matches_dynamic` for the policy).
719            let (resume, _) = self.skip.next_pos(start_pos, end_pos, &m.labels);
720            for p in cursor..resume {
721                let alive = self
722                    .nfa
723                    .reaches_boundary_alive(p, trunc_pos, matcher, budget, memoize)
724                    .await?;
725                // As in `rescan`'s freeze loop: a spent budget is not a deadness verdict — keep
726                // fewer matches frozen rather than freeze on a fabricated "dead".
727                if budget.hit || alive {
728                    break 'keep;
729                }
730            }
731            cursor = resume;
732            kept += 1;
733        }
734
735        self.next_pos = cursor;
736        // The re-fed rows may differ, and a dead or matchless verdict for any position could have
737        // been decided by a path that died on one of them; forget every verdict beyond the kept
738        // frozen prefix.
739        self.dead_upto = cursor;
740        self.matchless_upto = cursor;
741        self.scan_cursor = cursor;
742        self.incomplete = false;
743        self.freeze_truncated = false;
744        self.frozen_count = kept;
745        self.matched.truncate(kept);
746        self.seq_index.truncate(trunc_pos);
747        Ok(())
748    }
749
750    /// Finalize the evicted prefix in place, or report that the matcher must be rebuilt. The caller
751    /// is evicting `[.., boundary)` rows from its buffer; `boundary` is the first row seq that is
752    /// *not* evicted (an exclusive upper bound). On success the matches lying wholly within the
753    /// evicted prefix leave the diffable set, the surviving matcher is rebased onto the surviving
754    /// buffer, and [`IncrementalMatcher::provisional`] then returns only still-revisable matches.
755    /// The finalized matches are not returned (see [`Finalized::Rebased`]); tests derive them by
756    /// diffing `provisional()` before/after.
757    ///
758    /// This owns the finalize-vs-rebuild decision the executor used to make itself. It returns
759    /// [`Finalized::MustRebuild`] — leaving the matcher untouched — in the shapes where an in-place
760    /// rebase is unsound, so the executor never needs to pre-check (and no debug assertion can trip
761    /// nor `next_pos -= final_pos` underflow):
762    /// - **Boundary never fed** (only unfed/unsafe rows survive) or **past the frozen prefix**
763    ///   (`final_pos > next_pos`): finalization would reach into the open, still-revisable region.
764    /// - **A frozen match straddles the boundary strictly inside the frozen prefix** — it starts
765    ///   before `final_pos` and ends after, while `final_pos < next_pos`. This arises only under the
766    ///   overlapping skip modes (`TO NEXT ROW`/`TO FIRST`/`TO LAST`), whose resume precedes the match
767    ///   end so a frozen span can outrun its own resume; see the consume/keep walk below for why it
768    ///   is declined. It is not reachable through the executor's eviction (which always lands the
769    ///   boundary at `final_pos == next_pos`, see below), only through a direct API call.
770    ///
771    /// **Why the overlapping skip modes now rebase** (they previously always rebuilt): the executor
772    /// evicts from the first row still live at the safe boundary. Every position in `[0, next_pos)`
773    /// was liveness-checked dead when its region froze, and deadness is monotone in the boundary, so
774    /// at the (same-or-later) eviction boundary `[0, next_pos)` is still dead and the first live row
775    /// is `>= next_pos`. The check above bounds `final_pos <= next_pos`, so through the executor
776    /// `final_pos == next_pos` exactly. At that boundary every frozen match starts before `next_pos`
777    /// and is therefore consumed — none is retained — so `next_pos` rebases to `0` and the
778    /// provisional tail is exactly the matches found over the surviving suffix, regardless of skip
779    /// mode. The verdict cursors and a truncated scan's found prefix shift down with the rows rather
780    /// than resetting: a verdict about a surviving start was computed over surviving rows only (see
781    /// the rebase step in the body), so the resumed scan finds exactly what a fresh matcher over
782    /// the survivors would, without re-walking the starts already decided. `PAST LAST ROW`
783    /// additionally tiles `[0, next_pos)` with non-overlapping spans (`resume == end`), so *any*
784    /// boundary within the frozen prefix retains a suffix of frozen matches soundly.
785    ///
786    /// On [`Finalized::Rebased`] the evicted rows physically leave the front of the logical buffer:
787    /// `seq_index` drains its prefix and `next_pos`/`frozen_count` shift down. Only rows at positions
788    /// `>= final_pos` survive, and rebasing is a uniform downward shift of those rows; paths *forward*
789    /// from a surviving position consume only surviving (unchanged) rows, so the freezing-soundness
790    /// argument (a frozen region is dead at its boundary) is preserved. Match spans are anchored by
791    /// seq, so retained and returned [`SeqMatch`]es keep their identities without adjustment.
792    pub fn finalize_evicted_prefix(&mut self, boundary: Seq) -> Finalized {
793        // Map the boundary seq to a fed position. Absent means the boundary row was never fed — the
794        // whole fed prefix is being evicted and only unfed/unsafe rows survive — which cannot be
795        // rebased against.
796        let Some(final_pos) = self.seq_index.iter().position(|&s| s == boundary) else {
797            return Finalized::MustRebuild;
798        };
799        // The boundary must lie within the frozen prefix; past it would reach the open region.
800        if final_pos > self.next_pos {
801            return Finalized::MustRebuild;
802        }
803        // Finalized matches are the leading run of frozen matches whose *start* is being evicted
804        // (`start_pos < final_pos`): their first row leaves the buffer, so they are consumed — final,
805        // already emitted — and drop from the diffable set. Only frozen matches can start within
806        // `[0, next_pos)` (a provisional match starts at `>= next_pos`) and they are stored in scan
807        // order, so this is a single leading run; stop at the first match that starts at/after the
808        // boundary (it survives). Recover each start position from `seq_index` (seqs are identities,
809        // not positions).
810        let mut finalized = 0usize;
811        let mut cursor = 0usize;
812        for m in &self.matched[..self.frozen_count] {
813            let start_pos = cursor
814                + self.seq_index[cursor..]
815                    .iter()
816                    .position(|&s| s == m.start_seq)
817                    .expect("finalized match start seq must still be fed");
818            if start_pos >= final_pos {
819                // Starts at/after the boundary: wholly retained (and so is everything after it).
820                break;
821            }
822            let end_pos = start_pos + m.labels.len();
823            // A consumed match whose span straddles the boundary (`end_pos > final_pos`, possible
824            // only under the overlapping modes) orphans its surviving rows `[final_pos, end_pos)`.
825            // Dropping it is sound only when the boundary sits exactly at the scan cursor
826            // (`final_pos == next_pos`): then no frozen match is retained, every surviving row
827            // belongs to the provisional region, and the verdicts about surviving starts that the
828            // rebase keeps were computed over surviving rows (see the rebase step below). That is
829            // the only boundary the executor produces; decline a mid-frozen straddle (a direct-API
830            // shape) rather than leave a retained frozen match that overlaps the surviving suffix.
831            if end_pos > final_pos && final_pos != self.next_pos {
832                return Finalized::MustRebuild;
833            }
834            finalized += 1;
835            // Later matches start strictly after this one (`resume > start`), so search forward.
836            cursor = start_pos + 1;
837        }
838
839        // Drop the finalized matches and rebase the buffer: every position shifts down by
840        // `final_pos`, verdicts included. A verdict about a start `p >= final_pos` was computed over
841        // rows `>= p` only — the binder requires a `PREV(.., k)` to sit at least `k` rows from the
842        // match start (`min_start_distances`), running `FIRST`/`LAST` read inside the match, and
843        // there is no forward navigation — so evicting `[0, final_pos)` invalidates nothing about a
844        // surviving start, and the matchless / dead prefixes and the scan cursor move with the rows
845        // instead of resetting to `next_pos`. That also keeps a truncated scan's found prefix (its
846        // matches are identified by seq and start at `>= next_pos`) together with `incomplete` and
847        // `freeze_truncated`: the next refresh resumes where the scan stopped rather than
848        // rescanning the surviving suffix after every emitted match, and `final_pos == 0` is a
849        // genuine no-op. Clearing `incomplete` here was #27197 — it turned partial information
850        // into a completed-scan verdict and the deadline prune deleted the rows of matches the
851        // truncated scan never reached. Truncation (`truncate`) still resets all three: there the
852        // rows a verdict was computed over do change.
853        // Checked at runtime, not only in debug builds: production has no overflow checks, and a
854        // breached invariant would wrap one of the four shifts below into a cursor past the
855        // buffer — a rescan that silently finds nothing, or a freeze that never completes.
856        // Nothing has been mutated yet, so declining is clean: the caller rebuilds the matcher
857        // from the surviving rows, which needs no cursor at all.
858        if !(final_pos <= self.next_pos
859            && self.next_pos <= self.matchless_upto
860            && self.matchless_upto <= self.dead_upto
861            && self.next_pos <= self.scan_cursor)
862        {
863            crate::consistency::consistency_panic!(
864                final_pos,
865                next_pos = self.next_pos,
866                matchless_upto = self.matchless_upto,
867                dead_upto = self.dead_upto,
868                scan_cursor = self.scan_cursor,
869                "MATCH_RECOGNIZE cursor invariant violated before the eviction rebase; rebuilding",
870            );
871            return Finalized::MustRebuild;
872        }
873        self.matched.drain(..finalized);
874        self.frozen_count -= finalized;
875        self.next_pos -= final_pos;
876        self.dead_upto -= final_pos;
877        self.matchless_upto -= final_pos;
878        self.scan_cursor -= final_pos;
879        self.seq_index.drain(..final_pos);
880        Finalized::Rebased
881    }
882
883    /// Current provisional matches over everything fed so far, as if input ended now.
884    pub fn provisional(&self) -> &[SeqMatch] {
885        &self.matched
886    }
887
888    /// Seqs of the rows fed so far, in feed (buffer-position) order — i.e. `seq_index`. The executor
889    /// reads this to align the matcher with the freshly-scanned state-table buffer each visit and to
890    /// detect an out-of-order safe row (one whose sorted position precedes an already-fed row).
891    /// Test-only on this executor: the emit-on-update mode that drives it is not part of this
892    /// operator; the differential oracle exercises it so the contract stays proven.
893    #[cfg(test)]
894    pub fn fed_seqs(&self) -> &[Seq] {
895        &self.seq_index
896    }
897
898    /// Number of leading buffer positions that are frozen (immutable under future appends) — i.e.
899    /// `next_pos`, the scan-resume point. The production caller was absorbed into
900    /// [`IncrementalMatcher::finalize_evicted_prefix`]'s internal boundary checks, so this is
901    /// test-only observability (like [`IncrementalMatcher::frozen`]). Distinct from `frozen_count`,
902    /// which counts frozen *matches*, not positions.
903    #[cfg(test)]
904    fn frozen_prefix_len(&self) -> usize {
905        self.next_pos
906    }
907
908    /// Number of leading `provisional()` entries that are frozen (final under future appends).
909    /// Test-only observability for asserting freezing behavior directly.
910    #[cfg(test)]
911    fn frozen(&self) -> usize {
912        self.frozen_count
913    }
914}
915
916#[cfg(test)]
917mod tests {
918    use std::collections::BTreeSet;
919
920    use rand::rngs::SmallRng;
921    use rand::{Rng, SeedableRng};
922    use risingwave_common::array::Op;
923    use risingwave_common::row::OwnedRow;
924    use risingwave_common::types::ScalarImpl;
925
926    use super::{
927        Finalized, IncrementalMatcher, Seq, SeqMatch, diff_provisional, plan_provisional_rows,
928    };
929    use crate::executor::error::StreamExecutorResult;
930    use crate::executor::match_recognize::nfa::{
931        CandidateMatcher, Nfa, Pattern, Quantifier, ScanBudget, SetMatcher, SkipMode,
932    };
933
934    /// A `SeqMatch` with the given extent and labels (identity is `start`).
935    fn dm(start: i64, end: i64, ls: &[&str]) -> SeqMatch {
936        SeqMatch {
937            start_seq: Seq(start),
938            end_seq: Seq(end),
939            labels: labels(ls),
940        }
941    }
942
943    /// Wrap raw seq literals for the matcher feed API (`advance` takes `&[Seq]`); tests use bare ints
944    /// (seqs equal final sorted positions in these tests, so the ints read naturally).
945    fn ss(xs: &[i64]) -> Vec<Seq> {
946        xs.iter().map(|&x| Seq(x)).collect()
947    }
948
949    /// Run an in-place finalization and return the matches it removed, derived by diffing
950    /// `provisional()` before/after — sound because finalization removes exactly a leading run of
951    /// the stored matches, so the removed ones are the vanished prefix (scan order, seqs intact).
952    /// Fails the test on the `MustRebuild` outcome (the caller-drops-and-rebuilds path is asserted
953    /// separately).
954    fn finalize_rebased(inc: &mut IncrementalMatcher, boundary: Seq) -> Vec<SeqMatch> {
955        let before = inc.provisional().to_vec();
956        match inc.finalize_evicted_prefix(boundary) {
957            Finalized::Rebased => before[..before.len() - inc.provisional().len()].to_vec(),
958            Finalized::MustRebuild => panic!("expected Finalized::Rebased, got MustRebuild"),
959        }
960    }
961
962    /// A one-column output row carrying `v`, so distinct rows are easy to assert on.
963    fn orow(v: i64) -> OwnedRow {
964        OwnedRow::new(vec![Some(ScalarImpl::Int64(v))])
965    }
966
967    #[test]
968    fn diff_emits_delete_insert_for_revision() {
969        // start 5 revised: end 7 (row A) -> end 9 (row B). Delete(A) then Insert(B).
970        let old = vec![(dm(5, 7, &["a", "b"]), orow(100))];
971        let new = vec![(dm(5, 9, &["a", "b", "b"]), orow(200))];
972        assert_eq!(
973            diff_provisional(&old, &new),
974            vec![(Op::Delete, orow(100)), (Op::Insert, orow(200))]
975        );
976    }
977
978    #[test]
979    fn diff_emits_delete_for_vanished_match() {
980        let old = vec![(dm(5, 7, &["a", "b"]), orow(100))];
981        let new: Vec<(SeqMatch, OwnedRow)> = vec![];
982        assert_eq!(diff_provisional(&old, &new), vec![(Op::Delete, orow(100))]);
983    }
984
985    #[test]
986    fn diff_emits_insert_for_brand_new_match() {
987        let old: Vec<(SeqMatch, OwnedRow)> = vec![];
988        let new = vec![(dm(5, 7, &["a", "b"]), orow(200))];
989        assert_eq!(diff_provisional(&old, &new), vec![(Op::Insert, orow(200))]);
990    }
991
992    #[test]
993    fn diff_unchanged_match_emits_nothing() {
994        let old = vec![(dm(5, 7, &["a", "b"]), orow(100))];
995        let new = vec![(dm(5, 7, &["a", "b"]), orow(100))];
996        assert_eq!(diff_provisional(&old, &new), vec![]);
997    }
998
999    /// Recovery-rebuild seam (Task 8). On recovery the executor reseeds its emit-on-update diff
1000    /// base by feeding the recovered buffer to a FRESH matcher (`rebuild_last_emitted` →
1001    /// `compute_partition_emitted`) and emitting nothing. That silent reseed is sound only if the
1002    /// from-scratch recomputation reproduces the pre-crash matcher's provisional set exactly — the
1003    /// set downstream already holds. This test exercises that property at the matcher+diff seam:
1004    /// the pre-crash side reaches its state through multi-visit incremental advances, a
1005    /// finalize-eviction (the finalized match left the base without a retract, its rows left the
1006    /// buffer), and a further advance; the recovery side is one whole-buffer feed of the surviving
1007    /// rows into a fresh matcher — exactly what the rebuild does. Both must agree on full
1008    /// `(start, end, labels)` triples, and a diff between output rows synthesized deterministically
1009    /// from each match (`build_match_row` is deterministic in the same way: pure in buffer content)
1010    /// must be empty — the reseed leaves nothing to re-emit at the next barrier.
1011    #[tokio::test]
1012    async fn recovery_rebuild_reproduces_pre_crash_provisional_set() {
1013        let pat = Pattern::Concat(vec![
1014            Pattern::Var("a".into()),
1015            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1016        ]);
1017        let nfa = Nfa::compile(&pat);
1018        let skip = SkipMode::PastLastRow;
1019        // Deterministic stand-in for `build_match_row`: a pure function of the match content.
1020        let out_row = |m: &SeqMatch| orow(m.start_seq.0 * 1000 + m.end_seq.0);
1021
1022        // Pre-crash: rows 0:a 1:b 2:a 3:b arrive across two visits; the first `a b` = (0,2)
1023        // freezes once the `a` at position 2 breaks the greedy `b+`.
1024        let pre = from_str("abab");
1025        let m_pre = SetMatcher::new(pre.clone());
1026        let mut pre_inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1027        pre_inc
1028            .advance(&ss(&[0, 1]), &m_pre, &mut ScanBudget::unlimited(), false)
1029            .await
1030            .unwrap();
1031        pre_inc
1032            .advance(&ss(&[2, 3]), &m_pre, &mut ScanBudget::unlimited(), false)
1033            .await
1034            .unwrap();
1035        // A watermark finalizes and evicts the frozen (0,2): its rows leave the buffer and it
1036        // leaves the diff base without a retraction (a permanent result downstream).
1037        let removed = finalize_rebased(&mut pre_inc, Seq(2));
1038        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "b"]))]);
1039        // One more row arrives; the last pre-crash barrier emitted this provisional set, so the
1040        // MV's provisional portion == this base. Surviving buffer: seqs 2,3,4 = {a},{b},{b}.
1041        let tail = from_str("abb");
1042        let m_tail = SetMatcher::new(tail.clone());
1043        pre_inc
1044            .advance(&ss(&[4]), &m_tail, &mut ScanBudget::unlimited(), false)
1045            .await
1046            .unwrap();
1047        let pre_base: Vec<(SeqMatch, OwnedRow)> = pre_inc
1048            .provisional()
1049            .iter()
1050            .map(|m| (m.clone(), out_row(m)))
1051            .collect();
1052        assert!(!pre_base.is_empty());
1053
1054        // Crash. Recovery: the restored state table holds only the surviving rows; the rebuild
1055        // feeds them, whole-buffer, into a FRESH matcher.
1056        let mut rec_inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1057        rec_inc
1058            .advance(
1059                &ss(&[2, 3, 4]),
1060                &m_tail,
1061                &mut ScanBudget::unlimited(),
1062                false,
1063            )
1064            .await
1065            .unwrap();
1066        let rec_set: Vec<(SeqMatch, OwnedRow)> = rec_inc
1067            .provisional()
1068            .iter()
1069            .map(|m| (m.clone(), out_row(m)))
1070            .collect();
1071
1072        // The from-scratch recomputation reproduces the pre-crash provisional set exactly...
1073        assert_eq!(provisional_triples(&rec_inc), provisional_triples(&pre_inc));
1074        // ...so seeding `last_emitted` with it and re-diffing (as the next barrier would over an
1075        // unchanged buffer) re-emits nothing — the silent rebuild is exact, not approximate.
1076        assert_eq!(diff_provisional(&pre_base, &rec_set), vec![]);
1077    }
1078
1079    /// The row-independent classification the executor runs before building any output row. An
1080    /// unchanged provisional set must reuse every base row (all `Some`, zero rebuilds — the
1081    /// steady-state win); a same-`start_seq` match with a changed extent or changed labels, and a
1082    /// brand-new start, must each rebuild (`None`).
1083    #[test]
1084    fn plan_reuses_unchanged_and_rebuilds_changed() {
1085        let base = vec![
1086            (dm(5, 7, &["a", "b"]), orow(100)),
1087            (dm(10, 12, &["a", "b"]), orow(200)),
1088        ];
1089
1090        // Identical provisional set (order need not match the base): every row reused, none rebuilt.
1091        let same = vec![dm(10, 12, &["a", "b"]), dm(5, 7, &["a", "b"])];
1092        let plan = plan_provisional_rows(&base, &same);
1093        assert_eq!(plan, vec![Some(1), Some(0)]);
1094        assert!(
1095            plan.iter().all(Option::is_some),
1096            "an unchanged provisional set must rebuild no output rows"
1097        );
1098
1099        // Changed extent (same start), changed labels (same start+extent), and a brand-new start:
1100        // each must be rebuilt.
1101        let changed = vec![
1102            dm(5, 9, &["a", "b", "b"]), // start 5: extent 7 -> 9
1103            dm(10, 12, &["b", "b"]),    // start 10: labels [a,b] -> [b,b]
1104            dm(20, 22, &["a", "b"]),    // brand-new start
1105        ];
1106        assert_eq!(
1107            plan_provisional_rows(&base, &changed),
1108            vec![None, None, None]
1109        );
1110    }
1111
1112    /// Same extent and labels, but the evaluated output row changed — still a revision (the diff
1113    /// compares the output row, not just the span/labels).
1114    #[test]
1115    fn diff_same_extent_different_row_is_revision() {
1116        let old = vec![(dm(5, 7, &["a", "b"]), orow(100))];
1117        let new = vec![(dm(5, 7, &["a", "b"]), orow(101))];
1118        assert_eq!(
1119            diff_provisional(&old, &new),
1120            vec![(Op::Delete, orow(100)), (Op::Insert, orow(101))]
1121        );
1122    }
1123
1124    /// Regression: the inputs are NOT start-seq sorted. `provisional()` yields matches in the
1125    /// matcher's scan (buffer-position) order, and seqs are minted at *arrival* — so a late row that
1126    /// sorts earlier carries a higher seq at an earlier position, and position order diverges from
1127    /// `start_seq` order. Here `old` holds (start 30, X) at the earlier position before (start 10, K);
1128    /// `new` still holds the unchanged (start 10, K). A merge that trusted the input order would
1129    /// emit Insert(K), Delete(X), Delete(K) — net-removing K downstream even though it is still
1130    /// live. The internal sort must yield exactly Delete(X), keeping K present.
1131    #[test]
1132    fn diff_position_ordered_inputs_from_out_of_order_arrival() {
1133        let old = vec![
1134            (dm(30, 32, &["a", "b"]), orow(300)), // X: earlier position, later-minted seq
1135            (dm(10, 12, &["a", "b"]), orow(100)), // K: later position, earlier-minted seq
1136        ];
1137        let new = vec![(dm(10, 12, &["a", "b"]), orow(100))];
1138        assert_eq!(diff_provisional(&old, &new), vec![(Op::Delete, orow(300))]);
1139
1140        // Same provenance with both sides unsorted and K revised: the pairing must still be by
1141        // identity, so X vanishes and K is retract-updated — never net-deleted.
1142        let old = vec![
1143            (dm(30, 32, &["a", "b"]), orow(300)),
1144            (dm(10, 12, &["a", "b"]), orow(100)),
1145        ];
1146        let new = vec![
1147            (dm(40, 42, &["a", "b"]), orow(400)),
1148            (dm(10, 13, &["a", "b", "b"]), orow(101)),
1149        ];
1150        assert_eq!(
1151            diff_provisional(&old, &new),
1152            vec![
1153                (Op::Delete, orow(100)),
1154                (Op::Insert, orow(101)),
1155                (Op::Delete, orow(300)),
1156                (Op::Insert, orow(400)),
1157            ]
1158        );
1159    }
1160
1161    /// Mixed sequence in one pass: start 1 unchanged, start 5 revised, start 9 vanished, start 11
1162    /// brand new. Ops must come out start-seq ascending, Delete-before-Insert per revised identity.
1163    #[test]
1164    fn diff_mixed_sequence_orders_by_start_seq() {
1165        let old = vec![
1166            (dm(1, 3, &["a", "b"]), orow(10)),
1167            (dm(5, 7, &["a", "b"]), orow(50)),
1168            (dm(9, 10, &["a"]), orow(90)),
1169        ];
1170        let new = vec![
1171            (dm(1, 3, &["a", "b"]), orow(10)),
1172            (dm(5, 9, &["a", "b", "b"]), orow(59)),
1173            (dm(11, 12, &["a"]), orow(110)),
1174        ];
1175        assert_eq!(
1176            diff_provisional(&old, &new),
1177            vec![
1178                (Op::Delete, orow(50)),
1179                (Op::Insert, orow(59)),
1180                (Op::Delete, orow(90)),
1181                (Op::Insert, orow(110)),
1182            ]
1183        );
1184    }
1185
1186    /// Oracle: feeding rows incrementally (in any split) must equal one batch
1187    /// `find_matches_dynamic` over the same rows — compared as full `(start, end, labels)` triples
1188    /// (like [`assert_equiv_with`]), so a *steal* (a row rebinding to a different variable under an
1189    /// unchanged span) is caught, not just span changes.
1190    async fn assert_equiv(
1191        nfa: &Nfa,
1192        skip: SkipMode,
1193        rows: &[BTreeSet<String>],
1194        split_at: &[usize],
1195    ) {
1196        let matcher = SetMatcher::new(rows.to_vec());
1197        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1198        let mut fed = 0usize;
1199        for &cut in split_at.iter().chain(std::iter::once(&rows.len())) {
1200            let seqs: Vec<Seq> = (fed..cut).map(|i| Seq(i as i64)).collect();
1201            inc.advance(&seqs, &matcher, &mut ScanBudget::unlimited(), false)
1202                .await
1203                .unwrap();
1204            fed = cut;
1205        }
1206        assert_eq!(
1207            provisional_triples(&inc),
1208            batch_triples(nfa, &skip, rows).await
1209        );
1210    }
1211
1212    fn sets(labels: &[&str]) -> BTreeSet<String> {
1213        labels.iter().map(|s| s.to_string()).collect()
1214    }
1215
1216    /// One row per non-whitespace char, each satisfying the single variable named by that char.
1217    fn from_str(s: &str) -> Vec<BTreeSet<String>> {
1218        s.chars()
1219            .filter(|c| !c.is_whitespace())
1220            .map(|c| BTreeSet::from([c.to_string()]))
1221            .collect()
1222    }
1223
1224    /// `n` rows that each satisfy both `a` and `b`, so quantifier preference (not the predicate)
1225    /// decides the split — mirrors `nfa`'s own `ab_rows` helper.
1226    fn ab_rows(n: usize) -> Vec<BTreeSet<String>> {
1227        vec![BTreeSet::from(["a".to_owned(), "b".to_owned()]); n]
1228    }
1229
1230    fn quant(inner: Pattern, q: Quantifier, reluctant: bool) -> Pattern {
1231        Pattern::Quantified(Box::new(inner), q, reluctant)
1232    }
1233
1234    fn labels(ls: &[&str]) -> Vec<String> {
1235        ls.iter().map(|s| s.to_string()).collect()
1236    }
1237
1238    /// Provisional matches as `(start_pos, end_pos, labels)`. In the truncation tests seqs are
1239    /// assigned equal to final sorted position, so a seq is directly its position and these triples
1240    /// line up with the batch oracle's position-anchored spans.
1241    fn provisional_triples(inc: &IncrementalMatcher) -> Vec<(usize, usize, Vec<String>)> {
1242        inc.provisional()
1243            .iter()
1244            .map(|m| {
1245                (
1246                    m.start_seq.0 as usize,
1247                    m.end_seq.0 as usize,
1248                    m.labels.clone(),
1249                )
1250            })
1251            .collect()
1252    }
1253
1254    /// Batch oracle over `rows` as `(start, end, labels)` triples (labels included so a *steal* —
1255    /// a row rebinding to a different variable — is caught, not just span changes).
1256    async fn batch_triples(
1257        nfa: &Nfa,
1258        skip: &SkipMode,
1259        rows: &[BTreeSet<String>],
1260    ) -> Vec<(usize, usize, Vec<String>)> {
1261        let matcher = SetMatcher::new(rows.to_vec());
1262        nfa.find_matches_dynamic(rows.len(), &matcher, skip)
1263            .await
1264            .unwrap()
1265            .iter()
1266            .map(|m| (m.start, m.end, m.labels.clone()))
1267            .collect()
1268    }
1269
1270    #[tokio::test]
1271    async fn incremental_equals_batch_in_order() {
1272        // pattern (a b+) — greedy trailing quantifier exercises the "still-open trailing match must
1273        // re-scan" rule.
1274        let pat = Pattern::Concat(vec![
1275            Pattern::Var("a".into()),
1276            Pattern::Quantified(Box::new(Pattern::Var("b".into())), Quantifier::Plus, false),
1277        ]);
1278        let nfa = Nfa::compile(&pat);
1279        let rows = vec![
1280            sets(&["a"]),
1281            sets(&["b"]),
1282            sets(&["b"]),
1283            sets(&["a"]),
1284            sets(&["b"]),
1285        ];
1286        // every split point, including feeding one row at a time
1287        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1]).await;
1288        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1, 2, 3, 4]).await;
1289        assert_equiv(&nfa, SkipMode::ToNextRow, &rows, &[2]).await;
1290    }
1291
1292    #[tokio::test]
1293    async fn alternation_incremental_equals_batch() {
1294        // (a | b) c — the standard alternation shape from nfa.rs's own tests. The trailing `c`
1295        // makes a match ending at the buffer boundary re-attempt until the `c` row arrives.
1296        let pat = Pattern::Concat(vec![
1297            Pattern::Alt(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]),
1298            Pattern::Var("c".into()),
1299        ]);
1300        let nfa = Nfa::compile(&pat);
1301        let rows = from_str("acbc");
1302        for split in [&[1][..], &[2][..], &[1, 2, 3][..], &[3][..]] {
1303            assert_equiv(&nfa, SkipMode::PastLastRow, &rows, split).await;
1304        }
1305    }
1306
1307    #[tokio::test]
1308    async fn range_quantifier_incremental_equals_batch() {
1309        // a{2,3} — a bounded range that greedily takes up to three, with the optional third copy
1310        // ending at the boundary (open) until the next row disambiguates it.
1311        let pat = quant(
1312            Pattern::Var("a".into()),
1313            Quantifier::Range {
1314                min: 2,
1315                max: Some(3),
1316            },
1317            false,
1318        );
1319        let nfa = Nfa::compile(&pat);
1320        let rows = from_str("aaxaa");
1321        for split in [&[1][..], &[2][..], &[1, 2, 3, 4][..], &[3][..]] {
1322            assert_equiv(&nfa, SkipMode::PastLastRow, &rows, split).await;
1323        }
1324    }
1325
1326    #[tokio::test]
1327    async fn reluctant_quantifier_incremental_equals_batch() {
1328        // a+? b over rows that each satisfy both a and b: the reluctant `a+?` takes the fewest `a`,
1329        // so each match is exactly "ab" and the split between them is preference-, not predicate-,
1330        // driven.
1331        let pat = Pattern::Concat(vec![
1332            quant(Pattern::Var("a".into()), Quantifier::Plus, true),
1333            Pattern::Var("b".into()),
1334        ]);
1335        let nfa = Nfa::compile(&pat);
1336        let rows = ab_rows(4);
1337        for split in [&[1][..], &[2][..], &[1, 2, 3][..]] {
1338            assert_equiv(&nfa, SkipMode::PastLastRow, &rows, split).await;
1339        }
1340    }
1341
1342    #[tokio::test]
1343    async fn to_next_row_overlap_incremental_equals_batch() {
1344        // a+ with SKIP TO NEXT ROW: overlapping matches (0,3),(1,3),(2,3) over "aaa" — the exact
1345        // overlap case from nfa.rs's `find_matches_skip_to_next_row_overlaps`. Every open match
1346        // ends at the boundary, so none freezes until a non-`a` row (or nothing) follows.
1347        let pat = quant(Pattern::Var("a".into()), Quantifier::Plus, false);
1348        let nfa = Nfa::compile(&pat);
1349        let rows = from_str("aaa");
1350        for split in [&[1][..], &[2][..], &[1, 2][..]] {
1351            assert_equiv(&nfa, SkipMode::ToNextRow, &rows, split).await;
1352        }
1353        // and with a trailing non-`a` row that closes all three matches strictly before the boundary
1354        let rows = from_str("aaab");
1355        assert_equiv(&nfa, SkipMode::ToNextRow, &rows, &[1, 2, 3]).await;
1356    }
1357
1358    #[tokio::test]
1359    async fn empty_advances_are_noops() {
1360        // A repeated split point feeds an empty `advance(&[])`; a leading `0` feeds one before any
1361        // real row. Both must be no-ops, so the final matches still equal the batch answer.
1362        let pat = Pattern::Concat(vec![
1363            Pattern::Var("a".into()),
1364            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1365        ]);
1366        let nfa = Nfa::compile(&pat);
1367        let rows = from_str("abbab");
1368        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[2, 2, 3]).await; // empty in the middle
1369        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[0, 1, 3]).await; // empty at the very start
1370        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1, 3, 3, 3]).await; // empties at the end
1371    }
1372
1373    #[tokio::test]
1374    async fn single_row_feeds_over_twelve_rows() {
1375        // Feed 12-row inputs one row at a time (split = [1..=11]) for several patterns.
1376        let one_at_a_time: Vec<usize> = (1..12).collect();
1377
1378        let ab_plus = Nfa::compile(&Pattern::Concat(vec![
1379            Pattern::Var("a".into()),
1380            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1381        ]));
1382        assert_equiv(
1383            &ab_plus,
1384            SkipMode::PastLastRow,
1385            &from_str("abbabbaabbab"),
1386            &one_at_a_time,
1387        )
1388        .await;
1389
1390        let alt_c = Nfa::compile(&Pattern::Concat(vec![
1391            Pattern::Alt(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]),
1392            Pattern::Var("c".into()),
1393        ]));
1394        assert_equiv(
1395            &alt_c,
1396            SkipMode::PastLastRow,
1397            &from_str("acbcacxbcacb"),
1398            &one_at_a_time,
1399        )
1400        .await;
1401        assert_equiv(
1402            &alt_c,
1403            SkipMode::PastLastRow,
1404            &from_str("acbcbcacacbc"),
1405            &one_at_a_time,
1406        )
1407        .await;
1408
1409        // overlapping matches under ToNextRow, single-fed
1410        let a_plus = Nfa::compile(&quant(Pattern::Var("a".into()), Quantifier::Plus, false));
1411        assert_equiv(
1412            &a_plus,
1413            SkipMode::ToNextRow,
1414            &from_str("aaxaaaxaaaax"),
1415            &one_at_a_time,
1416        )
1417        .await;
1418    }
1419
1420    /// The freezing gate must consult boundary liveness, not just "match ended before the boundary".
1421    /// `(a b c) | a` over `[a, b]` returns the fallback `a` match `(0,1)` while the longer,
1422    /// higher-preference `a b c` branch is still alive *at* the boundary (waiting for `c`). The
1423    /// naive `end < n_rows` rule would freeze `(0,1)`; the liveness gate sees position 0 alive and
1424    /// defers, so when `c` arrives the rescan finds the batch answer `(0,3)` ("abc").
1425    #[tokio::test]
1426    async fn alternation_alive_at_boundary_defers_freezing() {
1427        let pat = Pattern::Alt(vec![
1428            Pattern::Concat(vec![
1429                Pattern::Var("a".into()),
1430                Pattern::Var("b".into()),
1431                Pattern::Var("c".into()),
1432            ]),
1433            Pattern::Var("a".into()),
1434        ]);
1435        let nfa = Nfa::compile(&pat);
1436        let rows = from_str("abc");
1437        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[2]).await;
1438        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1, 2]).await;
1439    }
1440
1441    /// A *gap* position (no match there yet) can be the live one: `(a n n n) | n` over `[a, n, n]`
1442    /// finds only `n` matches, but position 0's `a n n n` branch is alive at the boundary — one more
1443    /// `n` turns the batch answer into the single match `(0,4)`. Freezing the early `n` matches
1444    /// (whose own starts are dead) would lose it, so the gate must check every position in the
1445    /// would-be-frozen region, not just match starts.
1446    #[tokio::test]
1447    async fn live_gap_position_defers_freezing() {
1448        let pat = Pattern::Alt(vec![
1449            Pattern::Concat(vec![
1450                Pattern::Var("a".into()),
1451                Pattern::Var("n".into()),
1452                Pattern::Var("n".into()),
1453                Pattern::Var("n".into()),
1454            ]),
1455            Pattern::Var("n".into()),
1456        ]);
1457        let nfa = Nfa::compile(&pat);
1458        let rows = from_str("annn");
1459        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[3]).await;
1460        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1, 2, 3]).await;
1461    }
1462
1463    /// A greedy trailing quantifier keeps the last match alive at the buffer end forever: `(a b+)`
1464    /// fed one row at a time never freezes its trailing match, and the match keeps extending as
1465    /// each `b` arrives.
1466    #[tokio::test]
1467    async fn trailing_quantified_match_extends_without_freezing() {
1468        let pat = Pattern::Concat(vec![
1469            Pattern::Var("a".into()),
1470            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1471        ]);
1472        let nfa = Nfa::compile(&pat);
1473        let rows = from_str("abbb");
1474        let matcher = SetMatcher::new(rows.clone());
1475        let mut inc =
1476            IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), SkipMode::PastLastRow);
1477
1478        // [a]: `a` alone doesn't satisfy `a b+`, but it is alive (a `b` may arrive) — no match yet,
1479        // nothing frozen.
1480        inc.advance(&ss(&[0]), &matcher, &mut ScanBudget::unlimited(), false)
1481            .await
1482            .unwrap();
1483        assert_eq!(inc.provisional(), &[]);
1484        assert_eq!(inc.frozen(), 0);
1485
1486        // Each appended `b` extends the same match by one row; it always ends at the buffer end, so
1487        // it stays alive and never freezes.
1488        for (seq, expected_end) in [(1i64, 2i64), (2, 3), (3, 4)] {
1489            inc.advance(&ss(&[seq]), &matcher, &mut ScanBudget::unlimited(), false)
1490                .await
1491                .unwrap();
1492            assert_eq!(
1493                inc.provisional(),
1494                &[SeqMatch {
1495                    start_seq: Seq(0),
1496                    end_seq: Seq(expected_end),
1497                    labels: std::iter::once("a".to_owned())
1498                        .chain(std::iter::repeat_n(
1499                            "b".to_owned(),
1500                            expected_end as usize - 1
1501                        ))
1502                        .collect(),
1503                }]
1504            );
1505            assert_eq!(inc.frozen(), 0);
1506        }
1507    }
1508
1509    /// Step 1 out-of-order reinsert with a *steal*. Rows arrive `[r0, r1, r3, r4]`; a late `r2`
1510    /// lands between `r1` and `r3`. Pattern `a+ b` over the pre-insert rows `[{a}, {a,b}, {x}, {x}]`
1511    /// freezes the match `a b` = `(0,2)` with `r1` bound as the closing `b`. The late `r2 = {b}`
1512    /// inserted at position 2 lets the greedy `a+` swallow `r1` as an extra `a` and bind `r2` as the
1513    /// `b`, so the batch answer over `[{a}, {a,b}, {b}, {x}, {x}]` is the longer `(0,3)` = `a a b`
1514    /// (r1 stolen from `b` to `a`). Truncating at `r3`'s seq must invalidate the frozen `(0,2)` — its
1515    /// end reaches the truncation point — and rewind so the re-feed re-derives `(0,3)`.
1516    #[tokio::test]
1517    async fn out_of_order_reinsert_equals_batch() {
1518        let pat = Pattern::Concat(vec![
1519            quant(Pattern::Var("a".into()), Quantifier::Plus, false),
1520            Pattern::Var("b".into()),
1521        ]);
1522        let nfa = Nfa::compile(&pat);
1523        let skip = SkipMode::PastLastRow;
1524
1525        // Buffer before the late arrival (sorted positions 0..4).
1526        let pre_rows = vec![sets(&["a"]), sets(&["a", "b"]), sets(&["x"]), sets(&["x"])];
1527        let pre_matcher = SetMatcher::new(pre_rows.clone());
1528
1529        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1530        inc.advance(
1531            &ss(&[0, 1, 2, 3]),
1532            &pre_matcher,
1533            &mut ScanBudget::unlimited(),
1534            false,
1535        )
1536        .await
1537        .unwrap();
1538        // `a b` = (0,2) freezes with r1 bound `b`.
1539        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
1540        assert_eq!(inc.frozen(), 1);
1541
1542        // r2 = {b} sorts between r1 (pos 1) and r3 (pos 2). r3 is the first buffered row whose sorted
1543        // position changes, so the caller truncates at r3's seq (2).
1544        inc.truncate_from_seq(Seq(2), &pre_matcher, &mut ScanBudget::unlimited(), false)
1545            .await
1546            .unwrap();
1547        // The frozen match reached the truncation point, so nothing survives.
1548        assert_eq!(provisional_triples(&inc), vec![]);
1549        assert_eq!(inc.frozen(), 0);
1550
1551        // Re-feed the sorted suffix [r2, r3, r4] with their final positions as seqs.
1552        let final_rows = vec![
1553            sets(&["a"]),
1554            sets(&["a", "b"]),
1555            sets(&["b"]),
1556            sets(&["x"]),
1557            sets(&["x"]),
1558        ];
1559        let final_matcher = SetMatcher::new(final_rows.clone());
1560        inc.advance(
1561            &ss(&[2, 3, 4]),
1562            &final_matcher,
1563            &mut ScanBudget::unlimited(),
1564            false,
1565        )
1566        .await
1567        .unwrap();
1568
1569        assert_eq!(
1570            provisional_triples(&inc),
1571            batch_triples(&nfa, &skip, &final_rows).await
1572        );
1573        assert_eq!(
1574            provisional_triples(&inc),
1575            vec![(0, 3, labels(&["a", "a", "b"]))]
1576        );
1577    }
1578
1579    /// Truncation landing in the *middle* of the frozen region: an earlier frozen match survives
1580    /// while a later one is dropped, so `next_pos` rewinds to the survivor's resume point (not 0).
1581    /// Pattern `a b` over `[{a},{b},{x},{a},{b},{x}]` freezes both `(0,2)` and `(3,5)`. A late `{a}`
1582    /// sorts at position 3 (before the second match): truncating at that row's seq keeps `(0,2)` and
1583    /// invalidates `(3,5)`, and the re-feed re-derives the shifted second match `(4,6)`.
1584    #[tokio::test]
1585    async fn truncate_inside_frozen_region_keeps_earlier_matches() {
1586        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
1587        let nfa = Nfa::compile(&pat);
1588        let skip = SkipMode::PastLastRow;
1589
1590        let pre_rows = vec![
1591            sets(&["a"]),
1592            sets(&["b"]),
1593            sets(&["x"]),
1594            sets(&["a"]),
1595            sets(&["b"]),
1596            sets(&["x"]),
1597        ];
1598        let pre_matcher = SetMatcher::new(pre_rows.clone());
1599
1600        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1601        inc.advance(
1602            &ss(&[0, 1, 2, 3, 4, 5]),
1603            &pre_matcher,
1604            &mut ScanBudget::unlimited(),
1605            false,
1606        )
1607        .await
1608        .unwrap();
1609        assert_eq!(
1610            provisional_triples(&inc),
1611            vec![(0, 2, labels(&["a", "b"])), (3, 5, labels(&["a", "b"]))]
1612        );
1613        assert_eq!(inc.frozen(), 2);
1614
1615        // A late {a} sorts at position 3; the old row at position 3 is the first whose sorted position
1616        // changes, so the caller truncates at its seq (3).
1617        inc.truncate_from_seq(Seq(3), &pre_matcher, &mut ScanBudget::unlimited(), false)
1618            .await
1619            .unwrap();
1620        // (0,2) survives (its region is dead at boundary 3); (3,5) reaches past it and is dropped.
1621        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
1622        assert_eq!(inc.frozen(), 1);
1623
1624        // Re-feed the sorted suffix [late {a}, old rows] from position 3, with final positions as seqs.
1625        let final_rows = vec![
1626            sets(&["a"]),
1627            sets(&["b"]),
1628            sets(&["x"]),
1629            sets(&["a"]),
1630            sets(&["a"]),
1631            sets(&["b"]),
1632            sets(&["x"]),
1633        ];
1634        let final_matcher = SetMatcher::new(final_rows.clone());
1635        inc.advance(
1636            &ss(&[3, 4, 5, 6]),
1637            &final_matcher,
1638            &mut ScanBudget::unlimited(),
1639            false,
1640        )
1641        .await
1642        .unwrap();
1643
1644        assert_eq!(
1645            provisional_triples(&inc),
1646            batch_triples(&nfa, &skip, &final_rows).await
1647        );
1648        assert_eq!(
1649            provisional_triples(&inc),
1650            vec![(0, 2, labels(&["a", "b"])), (4, 6, labels(&["a", "b"]))]
1651        );
1652    }
1653
1654    /// Truncating at the first fed row is a full reset. A late `{a}` sorting before everything shifts
1655    /// all positions, so the caller truncates at seq 0; state must clear entirely, and re-feeding the
1656    /// whole corrected sequence must equal the batch answer.
1657    #[tokio::test]
1658    async fn truncate_to_zero_resets_and_refeeds() {
1659        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
1660        let nfa = Nfa::compile(&pat);
1661        let skip = SkipMode::PastLastRow;
1662
1663        let pre_rows = vec![sets(&["a"]), sets(&["b"]), sets(&["x"])];
1664        let pre_matcher = SetMatcher::new(pre_rows.clone());
1665
1666        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1667        inc.advance(
1668            &ss(&[0, 1, 2]),
1669            &pre_matcher,
1670            &mut ScanBudget::unlimited(),
1671            false,
1672        )
1673        .await
1674        .unwrap();
1675        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
1676        assert_eq!(inc.frozen(), 1);
1677
1678        // A late {a} sorts before r0, so r0 (seq 0) is the first row whose position changes.
1679        inc.truncate_from_seq(Seq(0), &pre_matcher, &mut ScanBudget::unlimited(), false)
1680            .await
1681            .unwrap();
1682        assert_eq!(provisional_triples(&inc), vec![]);
1683        assert_eq!(inc.frozen(), 0);
1684
1685        let final_rows = vec![sets(&["a"]), sets(&["a"]), sets(&["b"]), sets(&["x"])];
1686        let final_matcher = SetMatcher::new(final_rows.clone());
1687        inc.advance(
1688            &ss(&[0, 1, 2, 3]),
1689            &final_matcher,
1690            &mut ScanBudget::unlimited(),
1691            false,
1692        )
1693        .await
1694        .unwrap();
1695
1696        assert_eq!(
1697            provisional_triples(&inc),
1698            batch_triples(&nfa, &skip, &final_rows).await
1699        );
1700        assert_eq!(provisional_triples(&inc), vec![(1, 3, labels(&["a", "b"]))]);
1701    }
1702
1703    /// THE case a positional (matcher-free) truncation rule gets wrong — do not simplify
1704    /// `truncate_from_seq` back to "drop frozen matches whose end position >= `trunc_pos`".
1705    ///
1706    /// Pattern `(a b c d) | (a b)` (long branch preferred) over `[{a},{b},{c},{x}]`: the short
1707    /// branch matches `(0,2)`, and it freezes only once the `x` at position 3 kills the long branch
1708    /// (at boundary 3 the long branch is still alive — `a b c` reaches the boundary inside the
1709    /// automaton — so no freeze happens there). A late `{d}` then sorts at position 3, displacing
1710    /// the `x`. Truncating at the x-row's seq re-checks the frozen region against boundary 3, where
1711    /// position 0 is alive again, so `(0,2)` must be dropped even though its end (2) lies strictly
1712    /// before the truncation position (3); the re-feed then derives the long match `(0,4)`. The
1713    /// positional rule keeps `(0,2)` and rewinds to its resume point 2 — no match can start at
1714    /// `{c}`/`{d}`/`{x}`, so it would wrongly answer `(0,2)` forever.
1715    #[tokio::test]
1716    async fn truncation_recheck_drops_frozen_match_alive_at_new_boundary() {
1717        let pat = Pattern::Alt(vec![
1718            Pattern::Concat(vec![
1719                Pattern::Var("a".into()),
1720                Pattern::Var("b".into()),
1721                Pattern::Var("c".into()),
1722                Pattern::Var("d".into()),
1723            ]),
1724            Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]),
1725        ]);
1726        let nfa = Nfa::compile(&pat);
1727        let skip = SkipMode::PastLastRow;
1728
1729        let pre_rows = vec![sets(&["a"]), sets(&["b"]), sets(&["c"]), sets(&["x"])];
1730        let pre_matcher = SetMatcher::new(pre_rows.clone());
1731
1732        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1733        inc.advance(
1734            &ss(&[0, 1, 2, 3]),
1735            &pre_matcher,
1736            &mut ScanBudget::unlimited(),
1737            false,
1738        )
1739        .await
1740        .unwrap();
1741        // The `x` kills the long branch at position 3, so the short `(0,2)` freezes.
1742        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
1743        assert_eq!(inc.frozen(), 1);
1744
1745        // The late {d} sorts at position 3; the old {x} row (seq 3) is the first buffered row whose
1746        // sorted position changes, so the caller truncates at its seq.
1747        inc.truncate_from_seq(Seq(3), &pre_matcher, &mut ScanBudget::unlimited(), false)
1748            .await
1749            .unwrap();
1750        // Discriminator: the frozen (0,2) ends *before* the truncation position, yet position 0 is
1751        // alive at the new boundary — the liveness re-check must drop it. The positional rule keeps
1752        // it here, and these two assertions (and the batch check below) fail under that rule.
1753        assert_eq!(provisional_triples(&inc), vec![]);
1754        assert_eq!(inc.frozen(), 0);
1755
1756        // Re-feed the sorted suffix [late {d}, old {x}] with final positions as seqs.
1757        let final_rows = vec![
1758            sets(&["a"]),
1759            sets(&["b"]),
1760            sets(&["c"]),
1761            sets(&["d"]),
1762            sets(&["x"]),
1763        ];
1764        let final_matcher = SetMatcher::new(final_rows.clone());
1765        inc.advance(
1766            &ss(&[3, 4]),
1767            &final_matcher,
1768            &mut ScanBudget::unlimited(),
1769            false,
1770        )
1771        .await
1772        .unwrap();
1773
1774        assert_eq!(
1775            provisional_triples(&inc),
1776            batch_triples(&nfa, &skip, &final_rows).await
1777        );
1778        assert_eq!(
1779            provisional_triples(&inc),
1780            vec![(0, 4, labels(&["a", "b", "c", "d"]))]
1781        );
1782    }
1783
1784    /// Truncating at a seq that was never fed is a no-op: neither a seq past everything buffered nor
1785    /// one exactly one-past-the-end may touch state, and later appends must still equal the batch.
1786    #[tokio::test]
1787    async fn truncate_unknown_seq_is_noop() {
1788        let pat = Pattern::Concat(vec![
1789            Pattern::Var("a".into()),
1790            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1791        ]);
1792        let nfa = Nfa::compile(&pat);
1793        let skip = SkipMode::PastLastRow;
1794
1795        let rows = from_str("abbc");
1796        let matcher = SetMatcher::new(rows.clone());
1797
1798        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1799        inc.advance(
1800            &ss(&[0, 1, 2]),
1801            &matcher,
1802            &mut ScanBudget::unlimited(),
1803            false,
1804        )
1805        .await
1806        .unwrap();
1807        let before = inc.provisional().to_vec();
1808        let before_frozen = inc.frozen();
1809
1810        // A seq far past everything buffered, and the seq exactly one past the last fed row: both are
1811        // absent from `seq_index`, so both leave state untouched.
1812        inc.truncate_from_seq(Seq(99), &matcher, &mut ScanBudget::unlimited(), false)
1813            .await
1814            .unwrap();
1815        inc.truncate_from_seq(Seq(3), &matcher, &mut ScanBudget::unlimited(), false)
1816            .await
1817            .unwrap();
1818        assert_eq!(inc.provisional(), before.as_slice());
1819        assert_eq!(inc.frozen(), before_frozen);
1820
1821        // Appending really does append (nothing corrupted): the final answer equals the batch.
1822        inc.advance(&ss(&[3]), &matcher, &mut ScanBudget::unlimited(), false)
1823            .await
1824            .unwrap();
1825        assert_eq!(
1826            provisional_triples(&inc),
1827            batch_triples(&nfa, &skip, &rows).await
1828        );
1829    }
1830
1831    /// `SeqMatch`es (e.g. the finalized-and-returned ones) as `(start, end, labels)` triples, so
1832    /// they line up with the position-anchored batch oracle (seqs equal final sorted positions in
1833    /// these tests).
1834    fn seq_triples(ms: &[SeqMatch]) -> Vec<(usize, usize, Vec<String>)> {
1835        ms.iter()
1836            .map(|m| {
1837                (
1838                    m.start_seq.0 as usize,
1839                    m.end_seq.0 as usize,
1840                    m.labels.clone(),
1841                )
1842            })
1843            .collect()
1844    }
1845
1846    /// Generic oracle: feeding rows incrementally (in any split) through `matcher` must equal one
1847    /// batch `find_matches_dynamic` with the *same* `matcher`. Unlike [`assert_equiv`] this takes an
1848    /// arbitrary [`CandidateMatcher`] (not just [`SetMatcher`]), so a matcher that applies its own
1849    /// pruning — e.g. the `WITHIN` span prune — can be driven through the incremental path.
1850    async fn assert_equiv_with<M: CandidateMatcher + Sync>(
1851        nfa: &Nfa,
1852        skip: SkipMode,
1853        n_rows: usize,
1854        matcher: &M,
1855        split_at: &[usize],
1856    ) {
1857        let batch: Vec<(usize, usize, Vec<String>)> = nfa
1858            .find_matches_dynamic(n_rows, matcher, &skip)
1859            .await
1860            .unwrap()
1861            .iter()
1862            .map(|m| (m.start, m.end, m.labels.clone()))
1863            .collect();
1864        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1865        let mut fed = 0usize;
1866        for &cut in split_at.iter().chain(std::iter::once(&n_rows)) {
1867            let seqs: Vec<Seq> = (fed..cut).map(|i| Seq(i as i64)).collect();
1868            inc.advance(&seqs, matcher, &mut ScanBudget::unlimited(), false)
1869                .await
1870                .unwrap();
1871            fed = cut;
1872        }
1873        assert_eq!(provisional_triples(&inc), batch);
1874    }
1875
1876    /// A [`CandidateMatcher`] that models the `WITHIN` span prune the executor applies inside
1877    /// `DefineMatcher::matches` (see `executor.rs`): binding a candidate at `pos` extends the match
1878    /// to span `[match_start, pos]`, and the executor rejects the candidate when that span exceeds
1879    /// the bound, so the NFA backtracks to the longest match that fits the window. `WITHIN` lives
1880    /// entirely inside the `CandidateMatcher`; this module has no `WITHIN` logic of its own — it
1881    /// hands the matcher straight to `find_matches_dynamic` and `reaches_boundary_alive` — so
1882    /// driving a span-pruning matcher through the incremental path and checking equality with the
1883    /// batch path proves the pass-through. (`nfa.rs`'s `SetMatcher` has no `WITHIN`, and the real
1884    /// `DefineMatcher` needs the executor's expression/row machinery, so we model the prune here.)
1885    struct WithinSetMatcher {
1886        rows: Vec<BTreeSet<String>>,
1887        /// Max span in order-key units. Seqs equal positions here, so the span of a candidate at
1888        /// `pos` is `pos - match_start == labels.len()`.
1889        max_span: usize,
1890    }
1891
1892    impl CandidateMatcher for WithinSetMatcher {
1893        async fn matches(
1894            &self,
1895            var: &str,
1896            pos: usize,
1897            labels: &[String],
1898        ) -> StreamExecutorResult<bool> {
1899            if !self.rows[pos].contains(var) {
1900                return Ok(false);
1901            }
1902            let match_start = pos - labels.len();
1903            Ok(pos - match_start <= self.max_span)
1904        }
1905    }
1906
1907    /// (a) Finalize mid-stream, then keep feeding: the finalized prefix is removed from and returned
1908    /// out of the diffable set, `provisional()` keeps only the still-revisable matches, and the
1909    /// union `provisional() ∪ returned` equals the batch oracle over all rows — with the rebased
1910    /// bookkeeping proven by advancing further after the finalization and still matching the oracle.
1911    #[tokio::test]
1912    async fn finalize_removes_prefix_and_rebases_bookkeeping() {
1913        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
1914        let nfa = Nfa::compile(&pat);
1915        let skip = SkipMode::PastLastRow;
1916
1917        // 0:a 1:b 2:x 3:a 4:b 5:x 6:a 7:b 8:x  -> batch matches (0,2),(3,5),(6,8).
1918        let full = from_str("abxabxabx");
1919        let m_full = SetMatcher::new(full.clone());
1920
1921        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1922        inc.advance(
1923            &ss(&[0, 1, 2, 3, 4, 5]),
1924            &m_full,
1925            &mut ScanBudget::unlimited(),
1926            false,
1927        )
1928        .await
1929        .unwrap();
1930        assert_eq!(
1931            provisional_triples(&inc),
1932            vec![(0, 2, labels(&["a", "b"])), (3, 5, labels(&["a", "b"]))]
1933        );
1934        assert_eq!(inc.frozen(), 2);
1935
1936        // Finalize everything before seq 3 (evict sorted positions [0,3)): removes the wholly-inside
1937        // match (0,2); (3,5) starts at the boundary and is kept.
1938        let removed = finalize_rebased(&mut inc, Seq(3));
1939        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "b"]))]);
1940        assert_eq!(provisional_triples(&inc), vec![(3, 5, labels(&["a", "b"]))]);
1941        assert_eq!(inc.frozen(), 1);
1942
1943        // Keep feeding rows 6,7,8. Their buffer positions are now rebased (row 3 sits at position 0),
1944        // so the matcher indexes the surviving buffer `full[3..]`.
1945        let m_tail = SetMatcher::new(full[3..].to_vec());
1946        inc.advance(
1947            &ss(&[6, 7, 8]),
1948            &m_tail,
1949            &mut ScanBudget::unlimited(),
1950            false,
1951        )
1952        .await
1953        .unwrap();
1954        assert_eq!(
1955            provisional_triples(&inc),
1956            vec![(3, 5, labels(&["a", "b"])), (6, 8, labels(&["a", "b"]))]
1957        );
1958
1959        // Union of the returned finalized match and the surviving provisional set equals the batch
1960        // oracle over the whole run.
1961        let mut union = seq_triples(&removed);
1962        union.extend(provisional_triples(&inc));
1963        assert_eq!(union, batch_triples(&nfa, &skip, &full).await);
1964    }
1965
1966    /// (a) Bookkeeping consistency across *all three* operations after a finalization: finalize a
1967    /// prefix, advance to derive more matches, then take a late (out-of-order) row that reinserts
1968    /// into the already-rebased tail — `truncate_from_seq` + re-feed — and the union still equals the
1969    /// batch oracle over the corrected full sequence. Exercises seq→position mapping against the
1970    /// rebased `seq_index` in both `advance` and `truncate_from_seq`.
1971    #[tokio::test]
1972    async fn finalize_then_truncate_and_advance_equals_batch() {
1973        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
1974        let nfa = Nfa::compile(&pat);
1975        let skip = SkipMode::PastLastRow;
1976
1977        // 0:a 1:b 2:x 3:a 4:b 5:x 6:a 7:b 8:x
1978        let pre = from_str("abxabxabx");
1979        let m_pre = SetMatcher::new(pre.clone());
1980
1981        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1982        inc.advance(
1983            &ss(&[0, 1, 2, 3, 4, 5, 6, 7, 8]),
1984            &m_pre,
1985            &mut ScanBudget::unlimited(),
1986            false,
1987        )
1988        .await
1989        .unwrap();
1990        assert_eq!(
1991            provisional_triples(&inc),
1992            vec![
1993                (0, 2, labels(&["a", "b"])),
1994                (3, 5, labels(&["a", "b"])),
1995                (6, 8, labels(&["a", "b"])),
1996            ]
1997        );
1998        assert_eq!(inc.frozen(), 3);
1999
2000        // Finalize before seq 3 (evict [0,3)); returns (0,2), rebases so row 3 is now position 0.
2001        let removed = finalize_rebased(&mut inc, Seq(3));
2002        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "b"]))]);
2003        assert_eq!(
2004            provisional_triples(&inc),
2005            vec![(3, 5, labels(&["a", "b"])), (6, 8, labels(&["a", "b"]))]
2006        );
2007
2008        // A late {a} sorts at global position 6 (before old row 6): old row 6 (seq 6) is the first
2009        // buffered row whose sorted position changes, so the caller truncates at seq 6. The matcher
2010        // indexes the rebased surviving buffer `pre[3..]`.
2011        let m_pre_tail = SetMatcher::new(pre[3..].to_vec());
2012        inc.truncate_from_seq(Seq(6), &m_pre_tail, &mut ScanBudget::unlimited(), false)
2013            .await
2014            .unwrap();
2015        // (3,5) survives (its region is dead at the truncation boundary); (6,8) reaches past it and
2016        // is dropped, to be re-derived by the re-feed.
2017        assert_eq!(provisional_triples(&inc), vec![(3, 5, labels(&["a", "b"]))]);
2018
2019        // Corrected full sequence with the late {a} inserted at position 6:
2020        // 0:a 1:b 2:x 3:a 4:b 5:x 6:a 7:a 8:b 9:x  -> batch (0,2),(3,5),(7,9).
2021        let corrected = from_str("abxabxaabx");
2022        // Re-feed the sorted suffix from global position 6 (seqs 6..=9), matcher over the rebased
2023        // surviving buffer `corrected[3..]`.
2024        let m_corr_tail = SetMatcher::new(corrected[3..].to_vec());
2025        inc.advance(
2026            &ss(&[6, 7, 8, 9]),
2027            &m_corr_tail,
2028            &mut ScanBudget::unlimited(),
2029            false,
2030        )
2031        .await
2032        .unwrap();
2033
2034        let mut union = seq_triples(&removed);
2035        union.extend(provisional_triples(&inc));
2036        assert_eq!(union, batch_triples(&nfa, &skip, &corrected).await);
2037    }
2038
2039    /// (a) Robustness: neither a never-fed boundary nor one at the very first row may touch state,
2040    /// and later appends still equal the batch. A never-fed boundary reports `MustRebuild` (the
2041    /// caller drops and rebuilds) without mutating; a boundary at the first row (`final_pos == 0`)
2042    /// evicts nothing (`Rebased` with an empty removed set).
2043    #[tokio::test]
2044    async fn finalize_unknown_or_zero_seq_leaves_state_intact() {
2045        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
2046        let nfa = Nfa::compile(&pat);
2047        let skip = SkipMode::PastLastRow;
2048
2049        let rows = from_str("abxab");
2050        let matcher = SetMatcher::new(rows.clone());
2051
2052        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2053        inc.advance(
2054            &ss(&[0, 1, 2, 3]),
2055            &matcher,
2056            &mut ScanBudget::unlimited(),
2057            false,
2058        )
2059        .await
2060        .unwrap();
2061        let before = inc.provisional().to_vec();
2062        let before_frozen = inc.frozen();
2063
2064        // Never fed: decline (the executor drops + rebuilds), leaving state untouched.
2065        assert!(matches!(
2066            inc.finalize_evicted_prefix(Seq(99)),
2067            Finalized::MustRebuild
2068        ));
2069        // final_pos == 0: rebased, evicting nothing.
2070        assert_eq!(finalize_rebased(&mut inc, Seq(0)), vec![]);
2071        assert_eq!(inc.provisional(), before.as_slice());
2072        assert_eq!(inc.frozen(), before_frozen);
2073
2074        inc.advance(&ss(&[4]), &matcher, &mut ScanBudget::unlimited(), false)
2075            .await
2076            .unwrap();
2077        assert_eq!(
2078            provisional_triples(&inc),
2079            batch_triples(&nfa, &skip, &rows).await
2080        );
2081    }
2082
2083    /// (b) `WITHIN` parity: a matcher that applies the `WITHIN` span prune drives identically through
2084    /// the incremental path and the batch path. `a b+` with a max span of one row (so a match may
2085    /// span at most two rows, `a b`) over `abbabb`: without `WITHIN` the greedy `b+` swallows both
2086    /// `b`s per match; `WITHIN` caps each match at `ab`. The incremental path must track that through
2087    /// both matching and the freeze-gate liveness check — proving pass-through, since this module has
2088    /// no `WITHIN` logic of its own.
2089    #[tokio::test]
2090    async fn within_span_prune_incremental_equals_batch() {
2091        let pat = Pattern::Concat(vec![
2092            Pattern::Var("a".into()),
2093            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
2094        ]);
2095        let nfa = Nfa::compile(&pat);
2096        let rows = from_str("abbabb");
2097        let matcher = WithinSetMatcher {
2098            rows: rows.clone(),
2099            max_span: 1,
2100        };
2101        for split in [
2102            &[1][..],
2103            &[2][..],
2104            &[3][..],
2105            &[1, 2, 3, 4, 5][..],
2106            &[2, 4][..],
2107        ] {
2108            assert_equiv_with(&nfa, SkipMode::PastLastRow, rows.len(), &matcher, split).await;
2109        }
2110    }
2111
2112    /// (c) Finalization must never reach into the open (non-frozen) trailing region: the boundary
2113    /// has to lie within the frozen prefix. `a b+` fed one row at a time keeps its trailing greedy
2114    /// match alive at the buffer end forever, so nothing freezes (`next_pos == 0`). Finalizing before
2115    /// seq 2 (a position past the frozen prefix) must return `MustRebuild` rather than reach into the
2116    /// open region (formerly a debug-assert panic; now a matcher-owned decision).
2117    #[tokio::test]
2118    async fn finalize_into_open_region_must_rebuild() {
2119        let pat = Pattern::Concat(vec![
2120            Pattern::Var("a".into()),
2121            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
2122        ]);
2123        let nfa = Nfa::compile(&pat);
2124        let rows = from_str("abb");
2125        let matcher = SetMatcher::new(rows.clone());
2126
2127        let mut inc =
2128            IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), SkipMode::PastLastRow);
2129        inc.advance(
2130            &ss(&[0, 1, 2]),
2131            &matcher,
2132            &mut ScanBudget::unlimited(),
2133            false,
2134        )
2135        .await
2136        .unwrap();
2137        // Trailing greedy match (0,3) stays alive at the boundary: nothing frozen.
2138        assert_eq!(inc.frozen(), 0);
2139        // seq 2 sits at position 2, past the frozen prefix (next_pos == 0): decline to rebase.
2140        assert!(matches!(
2141            inc.finalize_evicted_prefix(Seq(2)),
2142            Finalized::MustRebuild
2143        ));
2144    }
2145
2146    /// (Task 7, Step 1) After `finalize_evicted_prefix` evicts a frozen match, the matcher never
2147    /// revisits its rows: subsequent `advance`s extend only the *surviving* matches, and a
2148    /// `diff_provisional` against the executor's pruned diff base (the finalized start dropped)
2149    /// emits no op touching that start.
2150    ///
2151    /// The pattern is the greedy `a b+`, whose trailing quantifier *would* keep swallowing later
2152    /// `b`s if a match were still open — the concrete "later rows would have extended it under
2153    /// `PastLastRow`" shape from the brief. It cannot re-extend the finalized `a b` here, and that is
2154    /// the freezing invariant, not luck: a match only freezes once its whole scan region is dead at
2155    /// the boundary, and a position dead at a boundary stays dead at every larger boundary, so no
2156    /// appended row can revive it. Finalization then drains the frozen match's rows from
2157    /// `seq_index` and rebases the scan cursor past them, so the later `b` attaches to the surviving
2158    /// second match instead. This locks in the property Task 7's watermark emit-before-finalize
2159    /// relies on: a finalized match is a permanent result the diff base must forget without a
2160    /// retraction, and later input can neither resurrect nor mutate it.
2161    #[tokio::test]
2162    async fn finalize_evicts_then_later_rows_never_revisit_finalized_match() {
2163        let pat = Pattern::Concat(vec![
2164            Pattern::Var("a".into()),
2165            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
2166        ]);
2167        let nfa = Nfa::compile(&pat);
2168        let skip = SkipMode::PastLastRow;
2169
2170        // 0:a 1:b 2:a 3:b -> the first `a b` = (0,2) freezes (its region goes dead at the boundary
2171        // once the second match's `a` at position 2 breaks the greedy `b+`); the trailing (2,4)
2172        // stays open at the boundary and does not freeze.
2173        let pre = from_str("abab");
2174        let m_pre = SetMatcher::new(pre.clone());
2175        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2176        inc.advance(
2177            &ss(&[0, 1, 2, 3]),
2178            &m_pre,
2179            &mut ScanBudget::unlimited(),
2180            false,
2181        )
2182        .await
2183        .unwrap();
2184        assert_eq!(
2185            provisional_triples(&inc),
2186            vec![(0, 2, labels(&["a", "b"])), (2, 4, labels(&["a", "b"]))]
2187        );
2188        assert_eq!(inc.frozen(), 1); // only the first match froze; the trailing one is still open
2189
2190        // Executor-side diff base: everything currently provisional has been emitted (one row per
2191        // match, keyed by its start seq so an op referencing the finalized start is detectable).
2192        let base: Vec<(SeqMatch, OwnedRow)> = inc
2193            .provisional()
2194            .iter()
2195            .map(|m| (m.clone(), orow(m.start_seq.0)))
2196            .collect();
2197
2198        // Finalize before seq 2: evict [0:a, 1:b], returning the frozen (0,2). The executor prunes
2199        // the finalized start from its base without a retraction (a finalized match is permanent).
2200        let removed = finalize_rebased(&mut inc, Seq(2));
2201        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "b"]))]);
2202        assert_eq!(provisional_triples(&inc), vec![(2, 4, labels(&["a", "b"]))]);
2203        let mut base_pruned = base.clone();
2204        base_pruned.retain(|(m, _)| !removed.iter().any(|fm| fm.start_seq == m.start_seq));
2205
2206        // Feed a later `b` (seq 4). If the matcher revisited the evicted `a b`, the greedy `b+`
2207        // would extend it to `a b b`; instead its rows are gone and the surviving second match
2208        // (seq 2) grows to `a b b` = (2,5). The matcher indexes the rebased surviving buffer: old
2209        // positions 2,3 sit at 0,1 (rows {a},{b}) and seq 4 lands at position 2.
2210        let tail = from_str("abb");
2211        let m_tail = SetMatcher::new(tail.clone());
2212        inc.advance(&ss(&[4]), &m_tail, &mut ScanBudget::unlimited(), false)
2213            .await
2214            .unwrap();
2215        assert_eq!(
2216            provisional_triples(&inc),
2217            vec![(2, 5, labels(&["a", "b", "b"]))]
2218        );
2219        assert!(
2220            inc.provisional().iter().all(|m| m.start_seq != Seq(0)),
2221            "finalized match start must never be revisited"
2222        );
2223
2224        // The diff against the pruned base is exactly the surviving match's revision — a
2225        // Delete/Insert pair of its row (orow(2), extent 4 -> 5) — and nothing else; in particular
2226        // no op references the finalized start (whose base row was orow(0)).
2227        let new_emitted: Vec<(SeqMatch, OwnedRow)> = inc
2228            .provisional()
2229            .iter()
2230            .map(|m| (m.clone(), orow(m.start_seq.0)))
2231            .collect();
2232        let ops = diff_provisional(&base_pruned, &new_emitted);
2233        assert_eq!(ops, vec![(Op::Delete, orow(2)), (Op::Insert, orow(2))]);
2234    }
2235
2236    /// The overlapping skip modes now rebase across a consumed straddling match instead of forcing a
2237    /// rebuild. With `SKIP TO NEXT ROW` the resume point precedes the match end (`resume == start + 1
2238    /// < end`), so a FROZEN match's span can extend past the frozen prefix. `pattern (a a)` over
2239    /// three qualifying rows: (0,2) freezes with a frozen prefix of 1 — position 0 is dead (the
2240    /// pattern is exactly two rows) — while (1,3) ends at the boundary and stays alive. The
2241    /// executor's eviction boundary is the first alive position (1, exactly the frozen prefix
2242    /// `next_pos`), and the frozen (0,2) straddles it: its start row (seq 0) is evicted, so (0,2) is
2243    /// consumed (final, already emitted) and dropped. Because the boundary sits at `next_pos`, no
2244    /// frozen match survives and `next_pos` rebases to 0, so the surviving suffix is re-derived from
2245    /// scratch — `provisional()` then equals a fresh scan over the survivors (checked here).
2246    #[tokio::test]
2247    async fn finalize_under_to_next_row_rebases_consuming_straddler() {
2248        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("a".into())]);
2249        let nfa = Nfa::compile(&pat);
2250        let skip = SkipMode::ToNextRow;
2251        let rows = from_str("aaa");
2252        let matcher = SetMatcher::new(rows.clone());
2253
2254        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2255        inc.advance(
2256            &ss(&[0, 1, 2]),
2257            &matcher,
2258            &mut ScanBudget::unlimited(),
2259            false,
2260        )
2261        .await
2262        .unwrap();
2263        // Overlapping matches (0,2) and (1,3); only (0,2) froze, and the frozen prefix (its resume
2264        // point, 1) sits strictly inside its span [0, 2).
2265        assert_eq!(
2266            provisional_triples(&inc),
2267            vec![(0, 2, labels(&["a", "a"])), (1, 3, labels(&["a", "a"]))]
2268        );
2269        assert_eq!(inc.frozen(), 1);
2270        assert_eq!(inc.frozen_prefix_len(), 1);
2271        // The executor-shaped call: evict before the first alive position (seq 1 == next_pos). The
2272        // frozen (0,2) straddles the boundary but its start is evicted, so it is consumed and the
2273        // matcher rebases in place rather than declining.
2274        let removed = finalize_rebased(&mut inc, Seq(1));
2275        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "a"]))]);
2276        // Post-finalize: only the surviving match (1,3) remains, and it equals a fresh batch scan
2277        // over the surviving rows `full[1..]` (positions shifted up by the one evicted row).
2278        assert_eq!(provisional_triples(&inc), vec![(1, 3, labels(&["a", "a"]))]);
2279        let fresh: Vec<(usize, usize, Vec<String>)> = batch_triples(&nfa, &skip, &rows[1..])
2280            .await
2281            .into_iter()
2282            .map(|(s, e, ls)| (s + 1, e + 1, ls))
2283            .collect();
2284        assert_eq!(provisional_triples(&inc), fresh);
2285    }
2286
2287    /// Dropping the matcher and rebuilding from the surviving rows — the [`Finalized::MustRebuild`]
2288    /// fallback the executor still takes when finalize declines (e.g. a whole-buffer drain, or a
2289    /// WITHIN-expired boundary past the frozen prefix) — remains oracle-correct: a FRESH matcher fed
2290    /// the surviving rows equals the batch answer over them, and its union with the evicted
2291    /// (already-emitted/finalized) match equals the batch answer over the full input. (The specific
2292    /// `TO NEXT ROW` shape here now *rebases* through the executor — see the test above — but the
2293    /// rebuild path this exercises is still reached on the declined shapes and must stay sound.)
2294    #[tokio::test]
2295    async fn to_next_row_drop_and_rebuild_across_eviction_equals_batch() {
2296        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("a".into())]);
2297        let nfa = Nfa::compile(&pat);
2298        let skip = SkipMode::ToNextRow;
2299
2300        // Matches (0,2) frozen, (1,3) boundary-held.
2301        let full = from_str("aaa");
2302        let m_full = SetMatcher::new(full.clone());
2303        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2304        inc.advance(
2305            &ss(&[0, 1, 2]),
2306            &m_full,
2307            &mut ScanBudget::unlimited(),
2308            false,
2309        )
2310        .await
2311        .unwrap();
2312        assert_eq!(
2313            provisional_triples(&inc),
2314            vec![(0, 2, labels(&["a", "a"])), (1, 3, labels(&["a", "a"]))]
2315        );
2316        // Model the drop-and-rebuild fallback directly: take the evicted match, drop the matcher,
2317        // and rebuild from the surviving rows — exactly what the executor does when finalize returns
2318        // `MustRebuild`. The evicted (0,2) was already delivered (emitted under EOWC; pruned from the
2319        // diff base without a retract under EOU).
2320        let evicted = provisional_triples(&inc)[0].clone();
2321        drop(inc);
2322
2323        // Rebuild: a fresh matcher fed the surviving rows (seqs 1, 2), with the matcher indexing
2324        // the rebased surviving buffer `full[1..]` — exactly what the executor's next visit does.
2325        let m_tail = SetMatcher::new(full[1..].to_vec());
2326        let mut rebuilt = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2327        rebuilt
2328            .advance(&ss(&[1, 2]), &m_tail, &mut ScanBudget::unlimited(), false)
2329            .await
2330            .unwrap();
2331
2332        // Oracle over the surviving rows (batch positions shifted by the eviction offset to line up
2333        // with the surviving seqs).
2334        let shifted_batch: Vec<(usize, usize, Vec<String>)> =
2335            batch_triples(&nfa, &skip, &full[1..])
2336                .await
2337                .into_iter()
2338                .map(|(s, e, ls)| (s + 1, e + 1, ls))
2339                .collect();
2340        assert_eq!(provisional_triples(&rebuilt), shifted_batch);
2341        assert_eq!(
2342            provisional_triples(&rebuilt),
2343            vec![(1, 3, labels(&["a", "a"]))]
2344        );
2345
2346        // Union of the evicted match and the rebuilt provisional set equals the batch answer over
2347        // the full input — nothing lost, nothing duplicated across the eviction.
2348        let mut union = vec![evicted];
2349        union.extend(provisional_triples(&rebuilt));
2350        assert_eq!(union, batch_triples(&nfa, &skip, &full).await);
2351    }
2352
2353    /// The `refresh_matcher` over-feed rollback shape (see `executor.rs`): under emit-on-update the
2354    /// whole buffer is fed at a barrier, then the watermark's eviction visit narrows back to the
2355    /// safe prefix — `truncate_from_seq` at the first over-fed row rolls the tail back, but it also
2356    /// drops the provisional matches over the *retained* fed suffix, and with nothing left to
2357    /// re-feed no `advance` follows to re-derive them. `rescan` must restore the invariant:
2358    /// `provisional()` equals the batch answer over the safe prefix, labels included.
2359    #[tokio::test]
2360    async fn overfeed_rollback_rescan_equals_batch_over_safe_prefix() {
2361        let pat = Pattern::Concat(vec![
2362            Pattern::Var("a".into()),
2363            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
2364        ]);
2365        let nfa = Nfa::compile(&pat);
2366        let skip = SkipMode::PastLastRow;
2367
2368        // 0:a 1:b 2:a 3:b 4:b — whole-buffer matches (0,2) (frozen: the `a` at 2 breaks the greedy
2369        // `b+`) and (2,5) (trailing, provisional).
2370        let full = from_str("ababb");
2371        let m_full = SetMatcher::new(full.clone());
2372        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2373        inc.advance(
2374            &ss(&[0, 1, 2, 3, 4]),
2375            &m_full,
2376            &mut ScanBudget::unlimited(),
2377            false,
2378        )
2379        .await
2380        .unwrap();
2381        assert_eq!(
2382            provisional_triples(&inc),
2383            vec![
2384                (0, 2, labels(&["a", "b"])),
2385                (2, 5, labels(&["a", "b", "b"]))
2386            ]
2387        );
2388        assert_eq!(inc.frozen(), 1);
2389
2390        // Roll back to the safe prefix [0, 4): truncate at the first over-fed row's seq. This drops
2391        // the provisional (2,5) even though rows 2 and 3 stay fed — the reason a rescan (and not a
2392        // re-feed, which would double-enter the retained rows in `seq_index`) must follow.
2393        let m_safe = SetMatcher::new(full[..4].to_vec());
2394        inc.truncate_from_seq(Seq(4), &m_safe, &mut ScanBudget::unlimited(), false)
2395            .await
2396            .unwrap();
2397        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
2398
2399        // Rescan re-derives the dropped suffix matches in place: `provisional()` now equals the
2400        // batch answer over the safe prefix — the invariant the executor's eviction pass reads.
2401        inc.rescan(&m_safe, &mut ScanBudget::unlimited(), false)
2402            .await
2403            .unwrap();
2404        assert_eq!(
2405            provisional_triples(&inc),
2406            batch_triples(&nfa, &skip, &full[..4]).await
2407        );
2408        assert_eq!(
2409            provisional_triples(&inc),
2410            vec![(0, 2, labels(&["a", "b"])), (2, 4, labels(&["a", "b"]))]
2411        );
2412
2413        // Re-feeding the rolled-back row (the next barrier's whole-buffer feed) still equals the
2414        // batch over the whole buffer — the rollback+rescan corrupted nothing.
2415        inc.advance(&ss(&[4]), &m_full, &mut ScanBudget::unlimited(), false)
2416            .await
2417            .unwrap();
2418        assert_eq!(
2419            provisional_triples(&inc),
2420            batch_triples(&nfa, &skip, &full).await
2421        );
2422    }
2423
2424    // ---- Randomized operation-sequence oracle (spec §8.1) ---------------------------------------
2425    //
2426    // The targeted tests above pin specific shapes; this property test closes the randomized-oracle
2427    // gap by driving *arbitrary* interleavings of the matcher's operations against the batch
2428    // reference. For each seed it draws a random pattern (over a small grammar), a random
2429    // `AFTER MATCH SKIP` mode (all four), random satisfied-set rows, and a random op sequence
2430    // (`advance` in in-order chunks incl. empty, `truncate_from_seq` at valid and never-fed seqs,
2431    // `finalize_evicted_prefix` at executor-reachable boundaries). After *every* op it asserts the
2432    // core invariant — `provisional()` (full `(start, end, labels)` triples) equals a from-scratch
2433    // batch `find_matches_dynamic` over the currently-live rows for the same skip mode. Seeds are
2434    // fixed (`0..N`), so CI is deterministic; everything is in-memory, so the sweep runs in seconds.
2435
2436    /// Build a random pattern over `vars`, shrinking toward a leaf as `budget` decreases so the
2437    /// compiled NFA stays small (≤64 states → bitmask visited-set) and the batch rescans stay cheap.
2438    fn gen_pattern(rng: &mut SmallRng, vars: &[&str], budget: usize) -> Pattern {
2439        let pick_var =
2440            |rng: &mut SmallRng| Pattern::Var(vars[rng.random_range(0..vars.len())].into());
2441        // Out of budget → leaf; otherwise choose a construct.
2442        let choice = if budget == 0 {
2443            0
2444        } else {
2445            rng.random_range(0..4)
2446        };
2447        match choice {
2448            // Concatenation of 2–3 sub-patterns.
2449            1 => Pattern::Concat(
2450                (0..rng.random_range(2..=3))
2451                    .map(|_| gen_pattern(rng, vars, budget - 1))
2452                    .collect(),
2453            ),
2454            // Alternation of 2–3 sub-patterns.
2455            2 => Pattern::Alt(
2456                (0..rng.random_range(2..=3))
2457                    .map(|_| gen_pattern(rng, vars, budget - 1))
2458                    .collect(),
2459            ),
2460            // A quantified sub-pattern (greedy or reluctant), all four quantifier shapes.
2461            3 => {
2462                let inner = gen_pattern(rng, vars, budget - 1);
2463                let q = match rng.random_range(0..4) {
2464                    0 => Quantifier::Star,
2465                    1 => Quantifier::Plus,
2466                    2 => Quantifier::Question,
2467                    _ => {
2468                        let min = rng.random_range(0..=2);
2469                        let max = rng.random_bool(0.5).then(|| min + rng.random_range(0..=2));
2470                        Quantifier::Range { min, max }
2471                    }
2472                };
2473                Pattern::Quantified(Box::new(inner), q, rng.random_bool(0.5))
2474            }
2475            // Leaf variable.
2476            _ => pick_var(rng),
2477        }
2478    }
2479
2480    /// A random `AFTER MATCH SKIP` mode; the variable-targeted modes bind a symbol from `vars` (a
2481    /// valid symbol name — `next_pos` degrades to `PAST LAST ROW` if that symbol is absent from a
2482    /// given match, which is itself a shape worth exercising).
2483    fn gen_skip(rng: &mut SmallRng, vars: &[&str]) -> SkipMode {
2484        match rng.random_range(0..4) {
2485            0 => SkipMode::PastLastRow,
2486            1 => SkipMode::ToNextRow,
2487            2 => SkipMode::ToFirst(vars[rng.random_range(0..vars.len())].into()),
2488            _ => SkipMode::ToLast(vars[rng.random_range(0..vars.len())].into()),
2489        }
2490    }
2491
2492    /// The core oracle assertion: the incremental matcher's provisional set equals a from-scratch
2493    /// batch scan over the currently-live rows `full_rows[evicted..fed]`. Seqs equal original
2494    /// positions, so a live match's seq-anchored triple is its batch (position-anchored) triple
2495    /// shifted up by the evicted prefix length.
2496    async fn assert_matches_batch(
2497        inc: &IncrementalMatcher,
2498        nfa: &Nfa,
2499        skip: &SkipMode,
2500        full_rows: &[BTreeSet<String>],
2501        evicted: usize,
2502        fed: usize,
2503        ctx: &str,
2504    ) {
2505        let batch: Vec<(usize, usize, Vec<String>)> =
2506            batch_triples(nfa, skip, &full_rows[evicted..fed])
2507                .await
2508                .into_iter()
2509                .map(|(s, e, ls)| (s + evicted, e + evicted, ls))
2510                .collect();
2511        assert_eq!(provisional_triples(inc), batch, "oracle divergence {ctx}");
2512    }
2513
2514    /// The same operation sweep under a STARVED budget. Two properties, neither of which the
2515    /// unlimited oracle can reach:
2516    ///
2517    /// 1. A truncated matcher is INCOMPLETE, never wrong — its provisional set is a leftmost
2518    ///    *prefix* of the batch answer. The scan pulls matches in preference order and a budget
2519    ///    abort inside a higher-preference subtree propagates out rather than falling through to a
2520    ///    lower-preference alternative, so starvation can drop a suffix but can never fabricate a
2521    ///    match, reorder two, or steal a row into a different variable.
2522    /// 2. Starvation is always RECOVERABLE: one re-derive with budget restores exact equality.
2523    ///    This is precisely the contract the executor's watermark arm relies on when it refreshes a
2524    ///    partition before deciding anything, and nothing else tests it.
2525    #[tokio::test]
2526    async fn randomized_operation_sequence_oracle_under_a_starved_budget() {
2527        const SEEDS: u64 = 200;
2528        const OPS: usize = 30;
2529        let var_pool: [&[&str]; 3] = [&["a", "b"], &["a", "b", "c"], &["a", "b", "c", "d"]];
2530        // Guards against the test passing for the wrong reason: budgets small enough to matter must
2531        // actually be exhausted, and a truncated state must actually differ from the batch answer at
2532        // least sometimes. Without these a future change to the budget sizing could silently turn
2533        // this into the unlimited oracle run twice.
2534        let mut starved_ops = 0usize;
2535        let mut strictly_shorter = 0usize;
2536
2537        for seed in 0..SEEDS {
2538            let mut rng = SmallRng::seed_from_u64(seed ^ 0x5741_2764_u64);
2539            let vars = var_pool[rng.random_range(0..var_pool.len())];
2540            let pattern = gen_pattern(&mut rng, vars, 3);
2541            let nfa = Nfa::compile(&pattern);
2542            let skip = gen_skip(&mut rng, vars);
2543
2544            let n_rows = rng.random_range(3..=7);
2545            let full_rows: Vec<BTreeSet<String>> = (0..n_rows)
2546                .map(|_| {
2547                    vars.iter()
2548                        .filter(|_| rng.random_bool(0.6))
2549                        .map(|v| (*v).to_owned())
2550                        .collect()
2551                })
2552                .collect();
2553
2554            let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2555            let mut fed = 0usize;
2556            let evicted = 0usize;
2557
2558            for op in 0..OPS {
2559                let matcher = SetMatcher::new(full_rows[evicted..].to_vec());
2560                let ctx = format!("seed {seed} op {op} (starved)");
2561                // 0 is included deliberately: a scan that dies on entry, having decided nothing.
2562                let mut budget = ScanBudget::new(rng.random_range(0..=8));
2563
2564                match rng.random_range(0..2) {
2565                    0 => {
2566                        let remaining = full_rows.len() - fed;
2567                        let chunk = if remaining == 0 {
2568                            0
2569                        } else {
2570                            rng.random_range(0..=remaining.min(3))
2571                        };
2572                        let seqs: Vec<Seq> = (fed..fed + chunk).map(|i| Seq(i as i64)).collect();
2573                        inc.advance(&seqs, &matcher, &mut budget, false)
2574                            .await
2575                            .unwrap();
2576                        fed += chunk;
2577                    }
2578                    _ => {
2579                        let k = rng.random_range(0..=fed + 2);
2580                        inc.truncate_from_seq(Seq(k as i64), &matcher, &mut budget, false)
2581                            .await
2582                            .unwrap();
2583                        if (evicted..fed).contains(&k) {
2584                            fed = k;
2585                        }
2586                        inc.rescan(&matcher, &mut budget, false).await.unwrap();
2587                    }
2588                }
2589
2590                // (1) prefix, not equality.
2591                let batch: Vec<(usize, usize, Vec<String>)> =
2592                    batch_triples(&nfa, &skip, &full_rows[evicted..fed]).await;
2593                let starved = provisional_triples(&inc);
2594                if budget.hit {
2595                    starved_ops += 1;
2596                }
2597                if starved.len() < batch.len() {
2598                    strictly_shorter += 1;
2599                }
2600                assert!(
2601                    batch.starts_with(&starved),
2602                    "a starved matcher must hold a PREFIX of the batch answer {ctx}\n  starved: {starved:?}\n  batch:   {batch:?}"
2603                );
2604
2605                // (2) one budgeted re-derive restores the exact invariant.
2606                inc.refresh(&matcher, &mut ScanBudget::unlimited(), false)
2607                    .await
2608                    .unwrap();
2609                assert_matches_batch(&inc, &nfa, &skip, &full_rows, evicted, fed, &ctx).await;
2610            }
2611        }
2612
2613        assert!(
2614            starved_ops > 0,
2615            "no operation exhausted its budget — the sweep never reached the truncation paths it \
2616             exists to cover"
2617        );
2618        assert!(
2619            strictly_shorter > 0,
2620            "every starved op still matched the batch answer exactly — truncation never actually \
2621             withheld a match, so the prefix property was asserted vacuously"
2622        );
2623    }
2624
2625    /// A chain pattern with a cycle behind it (`a{600} b*`), so the acyclic shortcut does not
2626    /// apply and freezing a match of `L` rows really runs one liveness walk per position of its
2627    /// region, each walking up to `L` rows — Θ(L²) steps, which exceeds one visit's budget
2628    /// (600 × ~1800 > 2^20). The freeze must carry its proven-dead prefix across visits so a
2629    /// bounded number of refreshes converges; restarting it at `next_pos` every visit never would
2630    /// (the budget died at the same position each time, and the region never froze — the
2631    /// permanent, non-self-healing degradation the depth cap used to cause by other means).
2632    #[tokio::test]
2633    async fn freeze_resumes_across_budget_exhausted_visits() {
2634        const N: usize = 600;
2635        const ROWS: usize = 1300;
2636        const BUDGET: usize = 1 << 20;
2637        let a_n = Pattern::Quantified(
2638            Box::new(Pattern::Var("a".into())),
2639            Quantifier::Range {
2640                min: N as u32,
2641                max: Some(N as u32),
2642            },
2643            false,
2644        );
2645        let b_star =
2646            Pattern::Quantified(Box::new(Pattern::Var("b".into())), Quantifier::Star, false);
2647        let nfa = Nfa::compile(&Pattern::Concat(vec![a_n, b_star]));
2648        assert_eq!(
2649            nfa.max_match_rows(),
2650            None,
2651            "the test needs a cyclic automaton"
2652        );
2653        let matcher = SetMatcher::new(vec![BTreeSet::from(["a".to_owned()]); ROWS]);
2654        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa), SkipMode::PastLastRow);
2655
2656        let seqs: Vec<Seq> = (0..ROWS as i64).map(Seq).collect();
2657        let mut budget = ScanBudget::new(BUDGET);
2658        inc.advance(&seqs, &matcher, &mut budget, true)
2659            .await
2660            .unwrap();
2661        assert!(
2662            budget.hit,
2663            "the test needs a freeze that outruns one visit's budget"
2664        );
2665        assert_eq!(inc.frozen(), 0, "the first region did not finish freezing");
2666        assert!(
2667            !inc.is_incomplete(),
2668            "the tail scan itself completed; only the freeze was cut short"
2669        );
2670        assert!(inc.needs_refresh(), "a truncated freeze asks for a refresh");
2671        let proven_after_first_visit = inc.dead_prefix_end();
2672        assert!(
2673            proven_after_first_visit > 0,
2674            "the first visit proved nothing dead"
2675        );
2676
2677        let mut visits = 1;
2678        while inc.needs_refresh() {
2679            assert!(visits < 5, "the freeze did not converge in {visits} visits");
2680            budget = ScanBudget::new(BUDGET);
2681            inc.refresh(&matcher, &mut budget, true).await.unwrap();
2682            visits += 1;
2683        }
2684        assert!(visits >= 2, "convergence must have needed the resume");
2685        assert!(inc.dead_prefix_end() > proven_after_first_visit);
2686
2687        // (0,600) froze: every position of its region is dead. (600,1200) is provisional and
2688        // stays so while every row is an `a`: from 700 on, `a{600}` reaches the boundary before
2689        // it can accept — alive, not frozen — so the proven-dead prefix ends exactly there.
2690        assert_eq!(inc.frozen(), 1);
2691        assert_eq!(inc.resume_pos(), N);
2692        assert_eq!(inc.dead_prefix_end(), 700);
2693        let spans: Vec<(i64, i64)> = inc
2694            .provisional()
2695            .iter()
2696            .map(|m| (m.start_seq.0, m.end_seq.0))
2697            .collect();
2698        assert_eq!(spans, vec![(0, 600), (600, 1200)]);
2699    }
2700
2701    /// A run one row short of a match, then a non-matching row: under `a{1000}` (the binder's
2702    /// limit) every start in the run walks to the break and dies, Θ(r²) per rescan — for r = 999
2703    /// that is ~1.5M steps, more than one visit's budget. The finder must remember the starts it
2704    /// proved matchless so the next rescan begins past them; without that memory every visit
2705    /// re-walks the same dead starts, exhausts at the same place, and the partition never
2706    /// completes a rescan again.
2707    #[tokio::test]
2708    async fn finder_resumes_past_starts_proven_matchless() {
2709        const N: usize = 1000;
2710        const RUN: usize = N - 1;
2711        const BUDGET: usize = 1 << 20;
2712        let nfa = Nfa::compile(&Pattern::Quantified(
2713            Box::new(Pattern::Var("a".into())),
2714            Quantifier::Range {
2715                min: N as u32,
2716                max: Some(N as u32),
2717            },
2718            false,
2719        ));
2720        let a = BTreeSet::from(["a".to_owned()]);
2721        let x = BTreeSet::from(["x".to_owned()]);
2722        let rows: Vec<BTreeSet<String>> = std::iter::repeat_n(a.clone(), RUN)
2723            .chain(std::iter::once(x))
2724            .chain(std::iter::repeat_n(a, N))
2725            .collect();
2726        let n_rows = rows.len();
2727        let matcher = SetMatcher::new(rows);
2728        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa), SkipMode::PastLastRow);
2729
2730        let seqs: Vec<Seq> = (0..n_rows as i64).map(Seq).collect();
2731        let mut budget = ScanBudget::new(BUDGET);
2732        inc.advance(&seqs, &matcher, &mut budget, true)
2733            .await
2734            .unwrap();
2735        assert!(
2736            budget.hit,
2737            "the test needs a rescan that outruns one visit's budget"
2738        );
2739        assert!(inc.is_incomplete());
2740        assert!(
2741            inc.provisional().is_empty(),
2742            "the finder never got past the break"
2743        );
2744        let proven_after_first_visit = inc.dead_prefix_end();
2745        assert!(
2746            proven_after_first_visit > 0,
2747            "the truncated rescan must still have proved a prefix of starts matchless"
2748        );
2749
2750        let mut visits = 1;
2751        while inc.needs_refresh() {
2752            assert!(visits < 4, "the rescan did not converge in {visits} visits");
2753            budget = ScanBudget::new(BUDGET);
2754            inc.refresh(&matcher, &mut budget, true).await.unwrap();
2755            visits += 1;
2756        }
2757        assert_eq!(visits, 2, "one resume should finish the run");
2758
2759        // The match after the break is found; everything before it is proven dead (the run and
2760        // the break row are matchless), and the acyclic shortcut proves the match's own region
2761        // dead up to the first position that can still reach the boundary.
2762        let spans: Vec<(i64, i64)> = inc
2763            .provisional()
2764            .iter()
2765            .map(|m| (m.start_seq.0, m.end_seq.0))
2766            .collect();
2767        assert_eq!(spans, vec![((RUN + 1) as i64, (RUN + 1 + N) as i64)]);
2768        assert_eq!(inc.dead_prefix_end(), n_rows - N);
2769        assert_eq!(
2770            inc.frozen(),
2771            0,
2772            "positions from {} on are alive",
2773            n_rows - N
2774        );
2775        assert!(!inc.needs_refresh());
2776    }
2777
2778    /// A successful match advances `MatchScan::next_start` without advancing `matchless_upto`.
2779    /// With overlapping matches, a visit can therefore spend its whole budget after returning a
2780    /// long prefix while proving no start matchless. Refreshes must retain that prefix and resume
2781    /// at the saved scan cursor; restarting at `matchless_upto == 0` repeats the same matches
2782    /// forever and leaves the partition permanently incomplete.
2783    #[tokio::test]
2784    async fn finder_resumes_after_budget_truncated_overlapping_matches() {
2785        const RUN: usize = 80;
2786        const BUDGET: usize = 1024;
2787        let nfa = Nfa::compile(&quant(Pattern::Var("a".into()), Quantifier::Plus, false));
2788        let rows: Vec<BTreeSet<String>> =
2789            std::iter::repeat_n(BTreeSet::from(["a".to_owned()]), RUN)
2790                .chain(std::iter::once(BTreeSet::from(["x".to_owned()])))
2791                .collect();
2792        let matcher = SetMatcher::new(rows);
2793        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa), SkipMode::ToNextRow);
2794        let seqs: Vec<Seq> = (0..=RUN as i64).map(Seq).collect();
2795
2796        let mut budget = ScanBudget::new(BUDGET);
2797        inc.advance(&seqs, &matcher, &mut budget, true)
2798            .await
2799            .unwrap();
2800        assert!(budget.hit, "the first scan must be truncated");
2801        assert!(inc.is_incomplete());
2802        let first_prefix_len = inc.provisional().len();
2803        assert!(
2804            first_prefix_len > 0 && first_prefix_len < RUN,
2805            "the first visit must find a strict non-empty prefix"
2806        );
2807
2808        let mut visits = 1;
2809        while inc.needs_refresh() {
2810            assert!(visits < 32, "the scan/freeze did not converge");
2811            budget = ScanBudget::new(BUDGET);
2812            inc.refresh(&matcher, &mut budget, true).await.unwrap();
2813            visits += 1;
2814        }
2815
2816        assert!(visits > 1, "the test must exercise cursor resumption");
2817        assert_eq!(inc.provisional().len(), RUN);
2818        assert_eq!(inc.frozen(), RUN);
2819        assert_eq!(inc.resume_pos(), RUN);
2820    }
2821
2822    #[tokio::test]
2823    async fn randomized_operation_sequence_oracle() {
2824        // ~200 seeds × ~30 ops. All in-memory over ≤7-row buffers, so the whole sweep is a few ms.
2825        const SEEDS: u64 = 200;
2826        const OPS: usize = 30;
2827        let var_pool: [&[&str]; 3] = [&["a", "b"], &["a", "b", "c"], &["a", "b", "c", "d"]];
2828
2829        for seed in 0..SEEDS {
2830            let mut rng = SmallRng::seed_from_u64(seed);
2831            let vars = var_pool[rng.random_range(0..var_pool.len())];
2832            let pattern = gen_pattern(&mut rng, vars, 3);
2833            let nfa = Nfa::compile(&pattern);
2834            let skip = gen_skip(&mut rng, vars);
2835
2836            // Random satisfied-set rows: each var present with prob ~0.6 (empty rows allowed).
2837            let n_rows = rng.random_range(3..=7);
2838            let full_rows: Vec<BTreeSet<String>> = (0..n_rows)
2839                .map(|_| {
2840                    vars.iter()
2841                        .filter(|_| rng.random_bool(0.6))
2842                        .map(|v| (*v).to_owned())
2843                        .collect()
2844                })
2845                .collect();
2846
2847            let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2848            let mut fed = 0usize; // rows fed so far (== next seq to mint, since seq == position)
2849            let mut evicted = 0usize; // rows finalized off the front of the live buffer
2850
2851            for op in 0..OPS {
2852                // The candidate matcher over the currently-live rows (positions are 0-based from the
2853                // evicted boundary, exactly as the executor's post-eviction buffer is).
2854                let matcher = SetMatcher::new(full_rows[evicted..].to_vec());
2855                let n_live = fed - evicted;
2856                let ctx = format!("seed {seed} op {op}");
2857
2858                match rng.random_range(0..3) {
2859                    // advance: feed the next in-order chunk (possibly empty).
2860                    0 => {
2861                        let remaining = full_rows.len() - fed;
2862                        let chunk = if remaining == 0 {
2863                            0
2864                        } else {
2865                            rng.random_range(0..=remaining.min(3))
2866                        };
2867                        let seqs: Vec<Seq> = (fed..fed + chunk).map(|i| Seq(i as i64)).collect();
2868                        inc.advance(&seqs, &matcher, &mut ScanBudget::unlimited(), false)
2869                            .await
2870                            .unwrap();
2871                        fed += chunk;
2872                    }
2873                    // truncate + rescan: roll back at a random seq (valid fed, already-evicted, or
2874                    // never-fed), then rescan to re-derive the retained tail in place — the executor's
2875                    // out-of-order / over-feed rollback shape. A never-fed seq is a no-op.
2876                    1 => {
2877                        let lo = evicted.saturating_sub(1);
2878                        let hi = fed + 2;
2879                        let k = rng.random_range(lo..=hi);
2880                        inc.truncate_from_seq(
2881                            Seq(k as i64),
2882                            &matcher,
2883                            &mut ScanBudget::unlimited(),
2884                            false,
2885                        )
2886                        .await
2887                        .unwrap();
2888                        if (evicted..fed).contains(&k) {
2889                            fed = k; // rolled the live buffer back to the truncation point
2890                        }
2891                        inc.rescan(&matcher, &mut ScanBudget::unlimited(), false)
2892                            .await
2893                            .unwrap();
2894                    }
2895                    // finalize: mirror the executor's eviction gate. Retain from the first row that is
2896                    // still live at the safe boundary; evict the dead prefix before it. Only attempt
2897                    // when there is a dead prefix and a surviving suffix (`0 < retain_from < n_live`).
2898                    _ => {
2899                        if n_live >= 2 {
2900                            let mut retain_from = n_live;
2901                            for p in 0..n_live {
2902                                if nfa
2903                                    .reaches_boundary_alive(
2904                                        p,
2905                                        n_live,
2906                                        &matcher,
2907                                        &mut ScanBudget::unlimited(),
2908                                        false,
2909                                    )
2910                                    .await
2911                                    .unwrap()
2912                                {
2913                                    retain_from = p;
2914                                    break;
2915                                }
2916                            }
2917                            if retain_from > 0 && retain_from < n_live {
2918                                let boundary = Seq((evicted + retain_from) as i64);
2919                                match inc.finalize_evicted_prefix(boundary) {
2920                                    Finalized::Rebased => evicted += retain_from,
2921                                    // The executor drops the matcher and lets the next visit rebuild
2922                                    // lazily; model that with a fresh matcher fed the surviving rows,
2923                                    // then continue the sequence.
2924                                    Finalized::MustRebuild => {
2925                                        evicted += retain_from;
2926                                        inc = IncrementalMatcher::new(
2927                                            std::sync::Arc::new(nfa.clone()),
2928                                            skip.clone(),
2929                                        );
2930                                        let surv = SetMatcher::new(full_rows[evicted..].to_vec());
2931                                        let seqs: Vec<Seq> =
2932                                            (evicted..fed).map(|i| Seq(i as i64)).collect();
2933                                        inc.advance(
2934                                            &seqs,
2935                                            &surv,
2936                                            &mut ScanBudget::unlimited(),
2937                                            false,
2938                                        )
2939                                        .await
2940                                        .unwrap();
2941                                    }
2942                                }
2943                            }
2944                        }
2945                    }
2946                }
2947
2948                // Invariant after EVERY op.
2949                assert_matches_batch(&inc, &nfa, &skip, &full_rows, evicted, fed, &ctx).await;
2950            }
2951        }
2952    }
2953
2954    /// Shared driver for the "incomplete scan survives an eviction" regressions (the P1
2955    /// follow-up from the review of #26584). Pattern `a{n} b*`, `PAST LAST ROW`, seq == position:
2956    ///
2957    /// ```text
2958    ///   [a × 2n]                    visit 1: (0,n) freezes, (n,2n) stays provisional
2959    ///   [a × (n-1), x] × k          visit 2: every start in a run walks to its `x` and dies —
2960    ///   [a × n]                     Θ(n²) per run, more than two visits' budgets can cover, so the
2961    ///                               match `h` at the very end is never reached
2962    /// ```
2963    ///
2964    /// Drives the exact executor sequence: the truncated `advance`, the watermark visit's
2965    /// `refresh` (fresh budget, still truncated), then the emission of the frozen match through
2966    /// `finalize_evicted_prefix` at `final_pos == next_pos` — the only boundary the executor
2967    /// produces. Returns the rebased matcher, the automaton, and the rows that survive eviction.
2968    async fn truncated_tail_then_consume_frozen(
2969        n: usize,
2970        k: usize,
2971        budget_per_visit: usize,
2972    ) -> (IncrementalMatcher, Nfa, Vec<BTreeSet<String>>) {
2973        let a_n = quant(
2974            Pattern::Var("a".into()),
2975            Quantifier::Range {
2976                min: n as u32,
2977                max: Some(n as u32),
2978            },
2979            false,
2980        );
2981        let b_star = quant(Pattern::Var("b".into()), Quantifier::Star, false);
2982        let nfa = Nfa::compile(&Pattern::Concat(vec![a_n, b_star]));
2983        assert_eq!(
2984            nfa.max_match_rows(),
2985            None,
2986            "the test needs a cyclic automaton"
2987        );
2988
2989        let a = || sets(&["a"]);
2990        let x = || sets(&["x"]);
2991        let mut rows: Vec<BTreeSet<String>> = vec![a(); 2 * n];
2992        for _ in 0..k {
2993            rows.extend(std::iter::repeat_n(a(), n - 1));
2994            rows.push(x());
2995        }
2996        let h_start = rows.len();
2997        rows.extend(std::iter::repeat_n(a(), n));
2998        let skip = SkipMode::PastLastRow;
2999        let matcher = SetMatcher::new(rows.clone());
3000        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip);
3001
3002        // Visit 1: feed the two blocks; let the freeze of (0,n) converge across as many
3003        // budget-truncated visits as it needs (the freeze walks alone cost Θ(n²)).
3004        let seqs: Vec<Seq> = (0..2 * n as i64).map(Seq).collect();
3005        let mut budget = ScanBudget::new(budget_per_visit);
3006        inc.advance(&seqs, &matcher, &mut budget, true)
3007            .await
3008            .unwrap();
3009        let mut visits = 1;
3010        while inc.needs_refresh() {
3011            assert!(visits < 16, "setup: the first freeze did not converge");
3012            budget = ScanBudget::new(budget_per_visit);
3013            inc.refresh(&matcher, &mut budget, true).await.unwrap();
3014            visits += 1;
3015        }
3016        assert_eq!(inc.frozen(), 1, "setup: (0,n) must be frozen");
3017        assert_eq!(inc.resume_pos(), n);
3018        assert!(!inc.is_incomplete());
3019
3020        // Visit 2: the near-miss runs and `h` arrive. The scan resumes at `n`, re-finds (n,2n),
3021        // then burns the budget inside the runs.
3022        let seqs: Vec<Seq> = (2 * n as i64..rows.len() as i64).map(Seq).collect();
3023        budget = ScanBudget::new(budget_per_visit);
3024        inc.advance(&seqs, &matcher, &mut budget, true)
3025            .await
3026            .unwrap();
3027        assert!(
3028            budget.hit && inc.is_incomplete(),
3029            "setup: k too small to truncate"
3030        );
3031        assert_eq!(
3032            inc.frozen(),
3033            1,
3034            "a truncated scan must not unfreeze anything"
3035        );
3036
3037        // The watermark visit refreshes with a fresh budget before deciding anything — and is
3038        // still cut short inside the runs.
3039        budget = ScanBudget::new(budget_per_visit);
3040        inc.refresh(&matcher, &mut budget, true).await.unwrap();
3041        assert!(
3042            inc.is_incomplete(),
3043            "setup: k too small — the refresh completed the scan"
3044        );
3045        let found: Vec<i64> = inc.provisional().iter().map(|m| m.start_seq.0).collect();
3046        assert!(
3047            !found.contains(&(h_start as i64)),
3048            "setup: `h` must still be undiscovered, found starts {found:?}"
3049        );
3050
3051        // `emit_ready` emits the frozen (0,n) and `consume_prefix` evicts `[0, n)`; the boundary is
3052        // the resume position, so this is the executor's `final_pos == next_pos` rebase.
3053        assert!(matches!(
3054            inc.finalize_evicted_prefix(Seq(n as i64)),
3055            Finalized::Rebased
3056        ));
3057        (inc, nfa, rows[n..].to_vec())
3058    }
3059
3060    /// After the rebase the unscanned suffix survives untouched, so the matcher must still ask
3061    /// for a refresh: `needs_refresh()` is the only thing standing between `prune_dead_prefix`
3062    /// (which treats a missing match as decided-absent once the tail counts as complete) and the
3063    /// rows of `h`. On the buggy code the rebase clears `incomplete`, the executor skips the
3064    /// refresh, and a WITHIN prune can delete `h`'s rows before any visit ever scans them.
3065    ///
3066    /// The regression pins the GATE, not the deletion: the state-table loss happens in
3067    /// `prune_dead_prefix`, which acts on `is_incomplete()` — an executor-level reproduction
3068    /// under the production budget is not reliably constructible, because per-chunk budgets and
3069    /// the matchless-forever memory spread the scan work across arrivals.
3070    async fn assert_incomplete_survives_rebase(n: usize, k: usize, budget_per_visit: usize) {
3071        let (mut inc, nfa, surviving) =
3072            truncated_tail_then_consume_frozen(n, k, budget_per_visit).await;
3073        let matcher = SetMatcher::new(surviving.clone());
3074
3075        // A from-scratch scan over exactly the rows the executor still holds: the ground truth
3076        // the deadline prune assumes `provisional()` to equal.
3077        let truth = batch_triples(&nfa, &SkipMode::PastLastRow, &surviving).await;
3078        let h_new_start = surviving.len() - n;
3079        assert!(
3080            truth.iter().any(|(s, _, _)| *s == h_new_start),
3081            "oracle sanity: `h` is a match over the surviving rows"
3082        );
3083        let offset = n as i64;
3084        let held: Vec<(usize, usize)> = inc
3085            .provisional()
3086            .iter()
3087            .map(|m| {
3088                (
3089                    (m.start_seq.0 - offset) as usize,
3090                    (m.end_seq.0 - offset) as usize,
3091                )
3092            })
3093            .collect();
3094        assert!(
3095            !held.iter().any(|(s, _)| *s == h_new_start),
3096            "the matcher does not know `h` yet: {held:?}"
3097        );
3098
3099        // THE BUG: partial information must not become a completed-scan verdict.
3100        assert!(
3101            inc.needs_refresh(),
3102            "an eviction must not clear a truncated scan: the matcher holds {} of {} matches \
3103             over the surviving rows yet reports no refresh needed",
3104            held.len(),
3105            truth.len()
3106        );
3107
3108        // And the recovery valve must still converge to the truth under fresh budgets.
3109        let mut visits = 0;
3110        while inc.needs_refresh() {
3111            assert!(visits < 64, "the resumed scan did not converge");
3112            let mut budget = ScanBudget::new(budget_per_visit);
3113            inc.refresh(&matcher, &mut budget, true).await.unwrap();
3114            visits += 1;
3115        }
3116        let final_triples: Vec<(usize, usize, Vec<String>)> = inc
3117            .provisional()
3118            .iter()
3119            .map(|m| {
3120                (
3121                    (m.start_seq.0 - offset) as usize,
3122                    (m.end_seq.0 - offset) as usize,
3123                    m.labels.clone(),
3124                )
3125            })
3126            .collect();
3127        assert_eq!(final_triples, truth);
3128    }
3129
3130    /// Small, fast shape of the P1 regression: `a{8} b*`, a 400-step budget.
3131    #[tokio::test]
3132    async fn incomplete_scan_survives_eviction_of_frozen_prefix() {
3133        assert_incomplete_survives_rebase(8, 24, 400).await;
3134    }
3135
3136    /// The zero boundary (`final_pos == 0`) evicts nothing and must change nothing — under a
3137    /// truncated scan too, where the rebase's shift is by zero and the found prefix, cursor and
3138    /// flags all stay. `finalize_unknown_or_zero_seq_leaves_state_intact` covers the complete
3139    /// state; this covers the incomplete one.
3140    #[tokio::test]
3141    async fn zero_boundary_rebase_is_a_no_op_while_incomplete() {
3142        let (n, k, budget_per_visit) = (8usize, 24usize, 400usize);
3143        let (mut inc, _nfa, surviving) =
3144            truncated_tail_then_consume_frozen(n, k, budget_per_visit).await;
3145        let matcher = SetMatcher::new(surviving);
3146        // One resumed visit: re-finds a prefix of the surviving suffix and is cut short again.
3147        let mut budget = ScanBudget::new(budget_per_visit);
3148        inc.refresh(&matcher, &mut budget, true).await.unwrap();
3149        assert!(
3150            inc.is_incomplete() && !inc.provisional().is_empty(),
3151            "setup: the resumed scan must hold a found prefix and still be truncated"
3152        );
3153        let before = inc.provisional().to_vec();
3154        let (frozen, resume, refresh) = (inc.frozen(), inc.resume_pos(), inc.needs_refresh());
3155
3156        // The first surviving row is the boundary: position 0.
3157        assert!(matches!(
3158            inc.finalize_evicted_prefix(Seq(n as i64)),
3159            Finalized::Rebased
3160        ));
3161        assert_eq!(inc.provisional(), before.as_slice(), "found prefix kept");
3162        assert_eq!(
3163            (inc.frozen(), inc.resume_pos(), inc.needs_refresh()),
3164            (frozen, resume, refresh)
3165        );
3166    }
3167
3168    /// The reviewer's shape: the legal `a{834} b*` under the production budget (`2^20`
3169    /// evaluations per visit). Each near-miss run costs Θ(834²) — about one visit's budget at
3170    /// today's charging (one unit per predicate, consume edge and ε-edge; three per row) — so six
3171    /// runs outlast the truncated `advance` and the refresh that follows with a 2× margin even if
3172    /// the charging ever drops to one unit per row. Only the O(1) invariant is asserted here: the
3173    /// oracle and the convergence loop cost ~15M budget units at this scale and prove nothing the
3174    /// small shape above does not.
3175    #[tokio::test]
3176    async fn incomplete_scan_survives_eviction_under_production_budget() {
3177        let (mut inc, _nfa, surviving) = truncated_tail_then_consume_frozen(834, 6, 1 << 20).await;
3178        assert!(
3179            inc.needs_refresh() && inc.is_incomplete(),
3180            "an eviction must not clear a truncated scan"
3181        );
3182        let kept = inc.provisional().to_vec();
3183        assert!(
3184            !kept.is_empty(),
3185            "the found prefix survives the rebase with its cursor"
3186        );
3187        // One fresh-budget refresh resumes past the kept prefix: it neither re-finds nor drops it.
3188        let matcher = SetMatcher::new(surviving);
3189        let mut budget = ScanBudget::new(1 << 20);
3190        inc.refresh(&matcher, &mut budget, true).await.unwrap();
3191        assert_eq!(&inc.provisional()[..kept.len()], kept.as_slice());
3192        // Provisional matches are in scan order, so their starts are strictly increasing; a
3193        // re-found prefix would break that (`Vec::dedup` would miss a repeated multi-match prefix).
3194        let starts: Vec<i64> = inc.provisional().iter().map(|m| m.start_seq.0).collect();
3195        assert!(
3196            starts.windows(2).all(|w| w[0] < w[1]),
3197            "a resumed scan must not duplicate the kept prefix: starts {starts:?}"
3198        );
3199    }
3200
3201    /// The rescan's runtime cursor guards, in the non-strict mode where they take their
3202    /// conservative branch instead of panicking.
3203    mod cursor_guards {
3204        use super::*;
3205
3206        async fn non_strict<F: std::future::Future>(f: F) -> F::Output {
3207            let config = risingwave_common::config::StreamingConfig {
3208                unsafe_disable_strict_consistency: true,
3209                ..Default::default()
3210            };
3211            crate::CONFIG.scope(std::sync::Arc::new(config), f).await
3212        }
3213
3214        fn loaded(rows: &str) -> (IncrementalMatcher, Nfa, SetMatcher, Vec<BTreeSet<String>>) {
3215            let pat = Pattern::Concat(vec![
3216                quant(Pattern::Var("a".into()), Quantifier::Plus, false),
3217                Pattern::Var("b".into()),
3218            ]);
3219            let nfa = Nfa::compile(&pat);
3220            let rows = from_str(rows);
3221            let matcher = SetMatcher::new(rows.clone());
3222            let inc =
3223                IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), SkipMode::PastLastRow);
3224            (inc, nfa, matcher, rows)
3225        }
3226
3227        /// A stray cursor is forgotten together with the truncation flags, and the rescan from
3228        /// the frozen boundary reproduces the batch oracle exactly — in particular without
3229        /// duplicating the provisional matches a kept `incomplete` would have re-attached.
3230        #[tokio::test]
3231        async fn stray_cursor_rescans_from_the_frozen_boundary_without_duplicates() {
3232            non_strict(async {
3233                let (mut inc, nfa, matcher, rows) = loaded("aabaab");
3234                let seqs: Vec<Seq> = (0..rows.len() as i64).map(Seq).collect();
3235                inc.advance(&seqs, &matcher, &mut ScanBudget::unlimited(), false)
3236                    .await
3237                    .unwrap();
3238                let truth = batch_triples(&nfa, &SkipMode::PastLastRow, &rows).await;
3239                assert_eq!(provisional_triples(&inc), truth);
3240
3241                // Corrupt the resumable state: a cursor past the buffer, flagged as a truncated
3242                // scan that would resume there.
3243                inc.scan_cursor = 99;
3244                inc.incomplete = true;
3245                inc.refresh(&matcher, &mut ScanBudget::unlimited(), false)
3246                    .await
3247                    .unwrap();
3248                assert_eq!(
3249                    provisional_triples(&inc),
3250                    truth,
3251                    "no lost and no duplicated match"
3252                );
3253                assert!(!inc.is_incomplete());
3254                assert!(inc.scan_cursor <= rows.len());
3255            })
3256            .await;
3257        }
3258
3259        /// A cursor inside the buffer but before the retained provisional tail's resume point
3260        /// passes the range check; the resumed scan would re-find the tail and duplicate it. It
3261        /// is rejected like a stray cursor: the tail is dropped and the rescan from the frozen
3262        /// boundary reproduces the batch oracle exactly.
3263        #[tokio::test]
3264        async fn cursor_before_the_retained_tail_is_rejected() {
3265            non_strict(async {
3266                // `(a+ b+)`: over "aabaab" the first match freezes (a following `a` ends its
3267                // `b+`), the second ends at the boundary with `b+` still open — a retained
3268                // provisional match with a resume position of 6.
3269                let pat = Pattern::Concat(vec![
3270                    quant(Pattern::Var("a".into()), Quantifier::Plus, false),
3271                    quant(Pattern::Var("b".into()), Quantifier::Plus, false),
3272                ]);
3273                let nfa = Nfa::compile(&pat);
3274                let rows = from_str("aabaab");
3275                let matcher = SetMatcher::new(rows.clone());
3276                let mut inc = IncrementalMatcher::new(
3277                    std::sync::Arc::new(nfa.clone()),
3278                    SkipMode::PastLastRow,
3279                );
3280                let seqs: Vec<Seq> = (0..rows.len() as i64).map(Seq).collect();
3281                inc.advance(&seqs, &matcher, &mut ScanBudget::unlimited(), false)
3282                    .await
3283                    .unwrap();
3284                let truth = batch_triples(&nfa, &SkipMode::PastLastRow, &rows).await;
3285                assert_eq!(provisional_triples(&inc), truth);
3286                assert!(
3287                    inc.provisional().len() > inc.frozen(),
3288                    "setup: a provisional match must be retained past the frozen prefix"
3289                );
3290
3291                // Corrupt: a "truncated" scan whose cursor sits at the frozen boundary, inside the
3292                // buffer, before the retained match's resume point.
3293                inc.incomplete = true;
3294                inc.scan_cursor = inc.resume_pos();
3295                inc.refresh(&matcher, &mut ScanBudget::unlimited(), false)
3296                    .await
3297                    .unwrap();
3298                assert_eq!(provisional_triples(&inc), truth, "no duplicated match");
3299                assert!(!inc.is_incomplete());
3300            })
3301            .await;
3302        }
3303
3304        /// A frozen prefix longer than the buffer has no in-place recovery: the rescan fails so
3305        /// the actor restarts and rebuilds from state.
3306        #[tokio::test]
3307        async fn frozen_prefix_past_the_buffer_is_an_error() {
3308            non_strict(async {
3309                let (mut inc, _nfa, matcher, rows) = loaded("aab");
3310                let seqs: Vec<Seq> = (0..rows.len() as i64).map(Seq).collect();
3311                inc.advance(&seqs, &matcher, &mut ScanBudget::unlimited(), false)
3312                    .await
3313                    .unwrap();
3314                inc.next_pos = rows.len() + 1;
3315                assert!(
3316                    inc.refresh(&matcher, &mut ScanBudget::unlimited(), false)
3317                        .await
3318                        .is_err()
3319                );
3320            })
3321            .await;
3322        }
3323
3324        /// A stray cursor at the eviction rebase declines the in-place shift before mutating
3325        /// anything; the caller then rebuilds from the surviving rows.
3326        #[tokio::test]
3327        async fn stray_cursor_at_rebase_declines_in_place() {
3328            non_strict(async {
3329                let (mut inc, _nfa, matcher, rows) = loaded("aabaab");
3330                let seqs: Vec<Seq> = (0..rows.len() as i64).map(Seq).collect();
3331                inc.advance(&seqs, &matcher, &mut ScanBudget::unlimited(), false)
3332                    .await
3333                    .unwrap();
3334                let resume = inc.resume_pos();
3335                assert!(resume > 0, "setup: the first match must have frozen");
3336                let before = inc.provisional().to_vec();
3337                inc.scan_cursor = resume - 1;
3338                assert!(matches!(
3339                    inc.finalize_evicted_prefix(Seq(resume as i64)),
3340                    Finalized::MustRebuild
3341                ));
3342                assert_eq!(
3343                    inc.provisional(),
3344                    before.as_slice(),
3345                    "declined without mutating"
3346                );
3347            })
3348            .await;
3349        }
3350    }
3351}