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.
242    incomplete: bool,
243    /// Absolute buffer position where a budget-truncated match scan will resume. Unlike
244    /// `matchless_upto`, this may follow successful matches: the corresponding leftmost-prefix of
245    /// matches remains in `matched`, and the next refresh appends matches found from this cursor.
246    /// Reset whenever rows are appended or invalidated, because those changes can alter previously
247    /// provisional matches and their skip-resume positions.
248    scan_cursor: usize,
249    /// Positions `[next_pos, dead_upto)` proven dead at the boundary by the freeze walks of this
250    /// and earlier visits (`next_pos <= matchless_upto <= dead_upto` always). Deadness is monotone
251    /// under appends — a walk reads only rows at or before its position (there is no forward
252    /// navigation: `NEXT` inside `DEFINE` is rejected at bind and decode time), so a position no
253    /// path can carry to the boundary stays that way as rows arrive — which lets a freeze that ran
254    /// out of budget resume where it stopped instead of restarting at `next_pos`. Without this,
255    /// freezing a match of `L` rows costs Θ(L²) steps in ONE visit (each of its `L` positions
256    /// walks up to `L` rows), and once that exceeds the per-visit budget the region never freezes:
257    /// the permanent, non-self-healing shape a long chain pattern (`a{600}`) otherwise degrades
258    /// into. Reset to `next_pos` whenever the rows a verdict was computed over can change
259    /// (truncation, eviction rebase).
260    dead_upto: usize,
261    /// Starts `[next_pos, matchless_upto)` proven MATCHLESS FOREVER by the finder: their walks found
262    /// no accept and never reached the boundary, so they died entirely on immutable rows (see
263    /// [`MatchScan::matchless_upto`]). The next rescan begins past them. This is the finder's
264    /// counterpart of `dead_upto` — and feeds it, since such a start is dead too: without it, a
265    /// long run broken by one non-matching row costs Θ(r²) on EVERY rescan (each start walks to
266    /// the break and dies), past the budget from `r ≈ 840`, and a partition in that state never
267    /// completes a rescan again. Same resets as `dead_upto`.
268    matchless_upto: usize,
269    /// Whether the last freeze loop stopped on a spent budget with its region unfinished. The
270    /// executor refreshes on this like on `incomplete`, so a truncated freeze resumes on the next
271    /// watermark visit rather than only on the next arrival.
272    freeze_truncated: bool,
273}
274
275/// Adapts a [`CandidateMatcher`] so that a scan over the suffix `[offset, ..)` sees suffix-relative
276/// positions `0, 1, ...` while the underlying matcher still resolves absolute buffer positions. This
277/// lets us drive [`Nfa::find_matches_dynamic`] over just the mutable suffix using the *same* matcher
278/// the batch path uses, without adding a start-offset parameter to the NFA.
279struct OffsetMatcher<'a, M> {
280    inner: &'a M,
281    offset: usize,
282}
283
284impl<M: CandidateMatcher + Sync> CandidateMatcher for OffsetMatcher<'_, M> {
285    fn matches(
286        &self,
287        var: &str,
288        pos: usize,
289        labels: &[String],
290    ) -> impl std::future::Future<Output = StreamExecutorResult<bool>> + Send {
291        self.inner.matches(var, pos + self.offset, labels)
292    }
293}
294
295impl IncrementalMatcher {
296    pub fn new(nfa: std::sync::Arc<Nfa>, skip: SkipMode) -> Self {
297        Self {
298            nfa,
299            skip,
300            matched: Vec::new(),
301            frozen_count: 0,
302            next_pos: 0,
303            seq_index: Vec::new(),
304            incomplete: false,
305            scan_cursor: 0,
306            dead_upto: 0,
307            matchless_upto: 0,
308            freeze_truncated: false,
309        }
310    }
311
312    /// Reset to the freshly-constructed state, keeping the (shared) automaton and skip mode and
313    /// reusing the collections' allocations. Equivalent to a new matcher: used when a consumed
314    /// prefix swallows the whole buffer, where reconstructing would re-clone the skip mode for
315    /// nothing.
316    pub fn reset(&mut self) {
317        self.matched.clear();
318        self.frozen_count = 0;
319        self.next_pos = 0;
320        self.seq_index.clear();
321        self.incomplete = false;
322        self.scan_cursor = 0;
323        self.dead_upto = 0;
324        self.matchless_upto = 0;
325        self.freeze_truncated = false;
326    }
327
328    /// Whether the last rescan was truncated by a spent budget — see the field doc. While true,
329    /// `provisional()` is a leftmost-prefix under-approximation.
330    pub fn is_incomplete(&self) -> bool {
331        self.incomplete
332    }
333
334    /// Whether a visit's rescan should be re-run with a fresh budget before deciding anything:
335    /// the provisional tail is incomplete ([`IncrementalMatcher::is_incomplete`]), or the freeze
336    /// stopped on a spent budget and has proven-dead progress to resume from.
337    pub fn needs_refresh(&self) -> bool {
338        self.incomplete || self.freeze_truncated
339    }
340
341    /// Re-derive the provisional tail with a fresh budget, without feeding rows: the executor's
342    /// recovery valve for [`IncrementalMatcher::is_incomplete`]. Delegates to the same rescan
343    /// `advance` performs.
344    pub async fn refresh(
345        &mut self,
346        matcher: &(impl CandidateMatcher + Sync),
347        budget: &mut ScanBudget,
348        memoize: bool,
349    ) -> StreamExecutorResult<()> {
350        self.rescan(matcher, budget, memoize).await
351    }
352
353    /// The skip-resume position after the last frozen match — the left edge of the still-revisable
354    /// region, in fed-position (= the executor's buffer-index) terms. The executor's emission gate
355    /// re-checks gap liveness from here: a position in `[resume_pos, match start)` that is still
356    /// alive at the boundary can produce an earlier, leftmost-preferred match and must hold the
357    /// emission.
358    pub fn resume_pos(&self) -> usize {
359        self.next_pos
360    }
361
362    /// End of the prefix of fed positions proven dead at the boundary — every position below it
363    /// can never again start a match (see the `dead_upto` field). The executor's own liveness
364    /// walks (the dead-prefix prune, the emission gate's gap check) skip these positions instead
365    /// of re-deriving a verdict the freeze already paid for.
366    pub fn dead_prefix_end(&self) -> usize {
367        self.dead_upto
368    }
369
370    /// Feed rows appended in `ORDER BY` order. `new_row_seqs` are the seqs of the newly appended rows;
371    /// their buffer positions are the next positions after the rows fed so far. An empty call is a
372    /// no-op.
373    ///
374    /// Rescans only the mutable suffix `[next_pos, n_rows)` via the budgeted, memoized pull scan
375    /// ([`Nfa::next_match`] through an [`OffsetMatcher`]), replacing the provisional tail of
376    /// `matched`. It then freezes the leading
377    /// run of suffix matches whose entire scan region `[cursor, skip-resume)` is dead at the boundary
378    /// per [`Nfa::reaches_boundary_alive`], advancing `next_pos` to the last frozen match's resume
379    /// position. Checking the *whole region* — not just the match's start — matters: a gap position
380    /// before the match can be alive (a longer, higher-preference alternative still in flight) and a
381    /// future row could then produce a match there that consumes past this one, so nothing behind
382    /// that gap may freeze. Liveness is checked with the raw `matcher` at absolute positions (only
383    /// the finder needs the offset adapter, because it always scans from 0).
384    pub async fn advance(
385        &mut self,
386        new_row_seqs: &[Seq],
387        matcher: &(impl CandidateMatcher + Sync),
388        budget: &mut ScanBudget,
389        memoize: bool,
390    ) -> StreamExecutorResult<()> {
391        if new_row_seqs.is_empty() {
392            return Ok(());
393        }
394        // `advance` only ever appends genuinely new rows. Re-feeding an already-fed seq (a late row
395        // landing before fed rows, or an over-feed narrowing back) must instead go through
396        // `truncate_from_seq` / `refresh_matcher`, which roll `seq_index` back first; appending a
397        // duplicate here would corrupt the seq→position mapping.
398        debug_assert!(
399            new_row_seqs.iter().all(|s| !self.seq_index.contains(s)),
400            "re-feeds must go through refresh_matcher/truncate, not advance"
401        );
402        // Appended rows can extend a greedy match or make a formerly boundary-blocked start match,
403        // so a saved scan prefix is no longer reusable. Matchless-forever starts remain valid.
404        self.incomplete = false;
405        self.scan_cursor = self.matchless_upto;
406        self.freeze_truncated = false;
407        self.seq_index.extend_from_slice(new_row_seqs);
408        self.rescan(matcher, budget, memoize).await
409    }
410
411    /// Re-derive the provisional tail over the already-fed mutable suffix `[next_pos, n_rows)`
412    /// without feeding new rows — the rescan half of [`IncrementalMatcher::advance`], exposed for
413    /// the one caller shape where a rescan must happen with nothing to feed: an **over-feed
414    /// rollback**. When rows beyond the caller's current window were fed (e.g. an emit-on-update
415    /// whole-buffer feed followed by a watermark visit over just the safe prefix),
416    /// `IncrementalMatcher::truncate_from_seq` at the first over-fed row rolls the tail back but
417    /// also drops every provisional match over the *retained* fed suffix; those rows are still fed,
418    /// so there is nothing to `advance` (re-feeding them would double-enter `seq_index`) and the
419    /// dropped matches must be re-derived in place. After this call `provisional()` equals a
420    /// from-scratch batch scan of the fed rows.
421    async fn rescan(
422        &mut self,
423        matcher: &(impl CandidateMatcher + Sync),
424        budget: &mut ScanBudget,
425        memoize: bool,
426    ) -> StreamExecutorResult<()> {
427        let n_rows = self.seq_index.len();
428
429        // Rescan the mutable suffix only. The offset matcher maps suffix-relative positions produced
430        // by the scan back onto absolute buffer positions the real matcher understands.
431        let offset = self.next_pos;
432        let offset_matcher = OffsetMatcher {
433            inner: matcher,
434            offset,
435        };
436        // The finder is the exponential walk the scan budget and the failure memo exist for —
437        // drive the pull-based scan directly so both actually meter it. On a spent budget the
438        // loop stops early and the tail is INCOMPLETE: the freeze loop below holds (it treats
439        // `budget.hit` as "alive"), the executor's emission gate holds, and the next visit
440        // rescans with a fresh budget — degraded latency, never a wrong or lost match.
441        debug_assert!(
442            self.next_pos <= self.matchless_upto && self.matchless_upto <= self.dead_upto,
443            "next_pos {} <= matchless_upto {} <= dead_upto {}",
444            self.next_pos,
445            self.matchless_upto,
446            self.dead_upto
447        );
448        let continuing_scan = self.incomplete;
449        let resume_freeze = !continuing_scan && self.freeze_truncated;
450        let mut tail_abs: Vec<LabeledMatch> = if continuing_scan || resume_freeze {
451            // Preserve the already-scanned leftmost prefix. Matches are seq-anchored in storage, so
452            // recover their current buffer positions before appending the resumed scan's suffix.
453            let mut search_from = self.next_pos;
454            self.matched[self.frozen_count..]
455                .iter()
456                .map(|m| {
457                    let start = search_from
458                        + self.seq_index[search_from..]
459                            .iter()
460                            .position(|&seq| seq == m.start_seq)
461                            .expect("provisional match start seq must still be fed");
462                    search_from = start + 1;
463                    LabeledMatch {
464                        start,
465                        end: start + m.labels.len(),
466                        labels: m.labels.clone(),
467                    }
468                })
469                .collect()
470        } else {
471            Vec::new()
472        };
473        let mut scan_truncated = false;
474        if !resume_freeze {
475            // A truncated refresh resumes after the successful matches already retained above.
476            // A fresh scan starts after the starts proven matchless forever.
477            let scan_start = if continuing_scan {
478                self.scan_cursor
479            } else {
480                self.matchless_upto
481            };
482            debug_assert!(self.next_pos <= scan_start && scan_start <= n_rows);
483            let mut scan = MatchScan::starting_at(scan_start - offset);
484            while let Some(m) = self
485                .nfa
486                .next_match(
487                    &mut scan,
488                    n_rows - offset,
489                    &offset_matcher,
490                    &self.skip,
491                    budget,
492                    memoize,
493                )
494                .await?
495            {
496                tail_abs.push(LabeledMatch {
497                    start: m.start + offset,
498                    end: m.end + offset,
499                    labels: m.labels,
500                });
501            }
502            self.scan_cursor = offset + scan.next_start();
503            // Extend the matchless prefix only when this pull began exactly at its end. Once a
504            // successful match creates a gap, the later cursor is resumable but not matchless.
505            if scan_start == self.matchless_upto {
506                self.matchless_upto = offset + scan.matchless_upto();
507            }
508            self.dead_upto = self.dead_upto.max(self.matchless_upto);
509            // Capture this before freeze walks spend more budget. A completed scan followed by a
510            // truncated freeze keeps its complete tail and resumes only the freeze next visit.
511            scan_truncated = budget.hit;
512        }
513
514        // Freeze the leading run of matches whose scan region `[cursor, resume)` is entirely dead at
515        // the boundary. A dead position's scan outcome is final — no path from it can consume past
516        // `n_rows - 1`, so appended rows can never be reached from it and the greedy attempt there
517        // returns the same result over any future buffer. Matches freeze strictly in order (there is
518        // a single cursor), so stop at the first region containing a live position. A match ending
519        // at the boundary is covered without a special case: its own accepting path reaches the
520        // boundary, so its start is alive and the region check fails.
521        let mut newly_frozen = 0usize;
522        let mut cursor = self.next_pos;
523        let mut freeze_truncated = false;
524        debug_assert!(
525            self.dead_upto >= cursor,
526            "dead_upto {} < next_pos {cursor}",
527            self.dead_upto
528        );
529        'freeze: for m in &tail_abs {
530            // The skip-degradation diagnostic is dropped here for the same reason
531            // `Nfa::find_matches_dynamic` drops it: this is freeze-cursor bookkeeping, not an
532            // emission site — the executor recomputes the resume position for the matches it
533            // actually emits and reports from there.
534            let (resume, _) = self.skip.next_pos(m.start, m.end, &m.labels);
535            // `dead_upto >= cursor` throughout: it starts at or past `next_pos`, and a region only
536            // freezes once every position in it is proven dead, so `cursor = resume` keeps it. An
537            // EMPTY range here is the resumed freeze paying off — the whole region was proven dead
538            // by earlier visits, and the match freezes without a walk.
539            for p in self.dead_upto..resume {
540                let alive = self
541                    .nfa
542                    .reaches_boundary_alive(p, n_rows, matcher, budget, memoize)
543                    .await?;
544                // A spent budget is NOT a deadness verdict: the liveness walk returns `false`
545                // when it stops early, and freezing on that fabricated answer advances the cursor
546                // past positions that are genuinely alive — their matches are then lost forever
547                // (the cursor never rewinds) while the prune pass, which guards correctly,
548                // retains their rows forever. Exhaustion holds the freeze; the next rescan resumes
549                // it from `dead_upto` with a fresh budget, so each one makes progress.
550                if budget.hit {
551                    freeze_truncated = true;
552                    break 'freeze;
553                }
554                if alive {
555                    break 'freeze;
556                }
557                // Proven dead, and deadness is monotone under appends: never walk `p` again.
558                self.dead_upto = p + 1;
559            }
560            cursor = resume;
561            newly_frozen += 1;
562        }
563        self.next_pos = cursor;
564        // Freezing moves the resume point past matches, including past starts the finder never
565        // proved anything about; the matchless prefix begins at the resume point by definition.
566        self.matchless_upto = self.matchless_upto.max(cursor);
567        self.scan_cursor = self.scan_cursor.max(cursor);
568        self.freeze_truncated = freeze_truncated;
569
570        // Drop the previous provisional tail and reattach the freshly scanned suffix, moving each
571        // match's labels (this runs per arriving row — a clone here would copy every provisional
572        // label vector once per row for nothing).
573        self.matched.truncate(self.frozen_count);
574        for m in tail_abs {
575            let sm = SeqMatch {
576                start_seq: self.seq_index[m.start],
577                end_seq: Seq(self.seq_index[m.end - 1].0 + 1),
578                labels: m.labels,
579            };
580            self.matched.push(sm);
581        }
582        self.frozen_count += newly_frozen;
583        self.incomplete = scan_truncated;
584
585        Ok(())
586    }
587
588    /// Invalidate everything at and after the fed row identified by `seq`, so the caller can re-feed
589    /// a corrected sorted suffix (an out-of-order row landing before rows already fed). `seq` is the
590    /// stable identity of the first buffered row whose sorted position changes; the executor computes
591    /// it (the first buffered order key `>=` the late row's), and here we only map it back to a fed
592    /// position via `seq_index`.
593    ///
594    /// A seq never fed (e.g. an order key beyond everything buffered) is a no-op; truncating at the
595    /// first fed row (position 0) is a full reset. After this call `provisional()` never returns a
596    /// match overlapping the truncated region.
597    ///
598    /// Why this needs the `matcher` (and so mirrors [`IncrementalMatcher::advance`]'s freezing gate
599    /// rather than a purely positional rule): a match froze against a *later* boundary, and freezing
600    /// only requires every region position to be dead at *that* boundary — a position may still hold
601    /// a path that stays alive *through* the rows now being truncated (a longer, higher-preference
602    /// alternative that only died past the truncation point). Such a frozen match is not final once
603    /// those rows change, even when its own span ends before the truncation point. So we recompute
604    /// the surviving frozen prefix with the exact gate `advance` uses — region-wide
605    /// [`Nfa::reaches_boundary_alive`] — but against the truncation boundary. Only a region entirely
606    /// dead at that boundary is independent of the truncated/re-fed rows and may be kept; the rest
607    /// (and every provisional match) is dropped and re-derived by the following `advance`, which
608    /// rescans from the rewound `next_pos`.
609    /// Test-only on this executor: the emit-on-update mode that drives it is not part of this
610    /// operator; the differential oracle exercises it so the contract stays proven.
611    #[cfg(test)]
612    pub async fn truncate_from_seq(
613        &mut self,
614        seq: Seq,
615        matcher: &(impl CandidateMatcher + Sync),
616        budget: &mut ScanBudget,
617        memoize: bool,
618    ) -> StreamExecutorResult<()> {
619        // Seqs are stable row identities, not sort keys, so `seq_index` is not ordered by value; find
620        // the exact entry. A missing seq means nothing buffered at/after it changed — leave state as
621        // is.
622        let Some(trunc_pos) = self.seq_index.iter().position(|&s| s == seq) else {
623            return Ok(());
624        };
625
626        let mut kept = 0usize;
627        let mut cursor = 0usize;
628        'keep: for m in &self.matched[..self.frozen_count] {
629            // Frozen matches are stored in scan order, so each start is at or after the cursor; search
630            // forward from there to recover its buffer position (seqs are not positions).
631            let start_pos = cursor
632                + self.seq_index[cursor..]
633                    .iter()
634                    .position(|&s| s == m.start_seq)
635                    .expect("frozen match start seq must still be fed at truncation");
636            let end_pos = start_pos + m.labels.len();
637            // A match reaching into (or across) the truncated region cannot survive: the re-fed rows
638            // may change its greedy extent or its skip-resume point.
639            if end_pos > trunc_pos {
640                break;
641            }
642            // `resume <= end_pos <= trunc_pos`, so every checked position is in the retained region.
643            // Diagnostic dropped: truncation bookkeeping, not an emission site (see
644            // `Nfa::find_matches_dynamic` for the policy).
645            let (resume, _) = self.skip.next_pos(start_pos, end_pos, &m.labels);
646            for p in cursor..resume {
647                let alive = self
648                    .nfa
649                    .reaches_boundary_alive(p, trunc_pos, matcher, budget, memoize)
650                    .await?;
651                // As in `rescan`'s freeze loop: a spent budget is not a deadness verdict — keep
652                // fewer matches frozen rather than freeze on a fabricated "dead".
653                if budget.hit || alive {
654                    break 'keep;
655                }
656            }
657            cursor = resume;
658            kept += 1;
659        }
660
661        self.next_pos = cursor;
662        // The re-fed rows may differ, and a dead or matchless verdict for any position could have
663        // been decided by a path that died on one of them; forget every verdict beyond the kept
664        // frozen prefix.
665        self.dead_upto = cursor;
666        self.matchless_upto = cursor;
667        self.scan_cursor = cursor;
668        self.incomplete = false;
669        self.freeze_truncated = false;
670        self.frozen_count = kept;
671        self.matched.truncate(kept);
672        self.seq_index.truncate(trunc_pos);
673        Ok(())
674    }
675
676    /// Finalize the evicted prefix in place, or report that the matcher must be rebuilt. The caller
677    /// is evicting `[.., boundary)` rows from its buffer; `boundary` is the first row seq that is
678    /// *not* evicted (an exclusive upper bound). On success the matches lying wholly within the
679    /// evicted prefix leave the diffable set, the surviving matcher is rebased onto the surviving
680    /// buffer, and [`IncrementalMatcher::provisional`] then returns only still-revisable matches.
681    /// The finalized matches are not returned (see [`Finalized::Rebased`]); tests derive them by
682    /// diffing `provisional()` before/after.
683    ///
684    /// This owns the finalize-vs-rebuild decision the executor used to make itself. It returns
685    /// [`Finalized::MustRebuild`] — leaving the matcher untouched — in the shapes where an in-place
686    /// rebase is unsound, so the executor never needs to pre-check (and no debug assertion can trip
687    /// nor `next_pos -= final_pos` underflow):
688    /// - **Boundary never fed** (only unfed/unsafe rows survive) or **past the frozen prefix**
689    ///   (`final_pos > next_pos`): finalization would reach into the open, still-revisable region.
690    /// - **A frozen match straddles the boundary strictly inside the frozen prefix** — it starts
691    ///   before `final_pos` and ends after, while `final_pos < next_pos`. This arises only under the
692    ///   overlapping skip modes (`TO NEXT ROW`/`TO FIRST`/`TO LAST`), whose resume precedes the match
693    ///   end so a frozen span can outrun its own resume; see the consume/keep walk below for why it
694    ///   is declined. It is not reachable through the executor's eviction (which always lands the
695    ///   boundary at `final_pos == next_pos`, see below), only through a direct API call.
696    ///
697    /// **Why the overlapping skip modes now rebase** (they previously always rebuilt): the executor
698    /// evicts from the first row still live at the safe boundary. Every position in `[0, next_pos)`
699    /// was liveness-checked dead when its region froze, and deadness is monotone in the boundary, so
700    /// at the (same-or-later) eviction boundary `[0, next_pos)` is still dead and the first live row
701    /// is `>= next_pos`. The check above bounds `final_pos <= next_pos`, so through the executor
702    /// `final_pos == next_pos` exactly. At that boundary every frozen match starts before `next_pos`
703    /// and is therefore consumed — none is retained — so `next_pos` rebases to `0` and the entire
704    /// surviving suffix is re-derived from scratch as the provisional tail. `provisional()` then
705    /// trivially equals a fresh scan over the survivors, regardless of skip mode, and no rebased scan
706    /// cursor can skip a start a fresh matcher would find. `PAST LAST ROW` additionally tiles
707    /// `[0, next_pos)` with non-overlapping spans (`resume == end`), so *any* boundary within the
708    /// frozen prefix retains a suffix of frozen matches soundly.
709    ///
710    /// On [`Finalized::Rebased`] the evicted rows physically leave the front of the logical buffer:
711    /// `seq_index` drains its prefix and `next_pos`/`frozen_count` shift down. Only rows at positions
712    /// `>= final_pos` survive, and rebasing is a uniform downward shift of those rows; paths *forward*
713    /// from a surviving position consume only surviving (unchanged) rows, so the freezing-soundness
714    /// argument (a frozen region is dead at its boundary) is preserved. Match spans are anchored by
715    /// seq, so retained and returned [`SeqMatch`]es keep their identities without adjustment.
716    pub fn finalize_evicted_prefix(&mut self, boundary: Seq) -> Finalized {
717        // Map the boundary seq to a fed position. Absent means the boundary row was never fed — the
718        // whole fed prefix is being evicted and only unfed/unsafe rows survive — which cannot be
719        // rebased against.
720        let Some(final_pos) = self.seq_index.iter().position(|&s| s == boundary) else {
721            return Finalized::MustRebuild;
722        };
723        // The boundary must lie within the frozen prefix; past it would reach the open region.
724        if final_pos > self.next_pos {
725            return Finalized::MustRebuild;
726        }
727
728        // Finalized matches are the leading run of frozen matches whose *start* is being evicted
729        // (`start_pos < final_pos`): their first row leaves the buffer, so they are consumed — final,
730        // already emitted — and drop from the diffable set. Only frozen matches can start within
731        // `[0, next_pos)` (a provisional match starts at `>= next_pos`) and they are stored in scan
732        // order, so this is a single leading run; stop at the first match that starts at/after the
733        // boundary (it survives). Recover each start position from `seq_index` (seqs are identities,
734        // not positions).
735        let mut finalized = 0usize;
736        let mut cursor = 0usize;
737        for m in &self.matched[..self.frozen_count] {
738            let start_pos = cursor
739                + self.seq_index[cursor..]
740                    .iter()
741                    .position(|&s| s == m.start_seq)
742                    .expect("finalized match start seq must still be fed");
743            if start_pos >= final_pos {
744                // Starts at/after the boundary: wholly retained (and so is everything after it).
745                break;
746            }
747            let end_pos = start_pos + m.labels.len();
748            // A consumed match whose span straddles the boundary (`end_pos > final_pos`, possible
749            // only under the overlapping modes) orphans its surviving rows `[final_pos, end_pos)`.
750            // Dropping it is sound only when the boundary sits exactly at the scan cursor
751            // (`final_pos == next_pos`): then no frozen match is retained, so the whole surviving
752            // suffix is re-scanned from scratch and `provisional()` still equals a fresh scan. That
753            // is the only boundary the executor produces; decline a mid-frozen straddle (a direct-API
754            // shape) rather than corrupt the rebase by skipping a start a fresh scan would revisit.
755            if end_pos > final_pos && final_pos != self.next_pos {
756                return Finalized::MustRebuild;
757            }
758            finalized += 1;
759            // Later matches start strictly after this one (`resume > start`), so search forward.
760            cursor = start_pos + 1;
761        }
762
763        // Drop the finalized matches and rebase the buffer.
764        self.matched.drain(..finalized);
765        self.frozen_count -= finalized;
766        self.next_pos -= final_pos;
767        // Through the executor `final_pos == next_pos`, so this is `0`: every frozen match was
768        // emitted and the surviving suffix is re-derived from scratch. Verdicts beyond the frozen
769        // prefix are forgotten rather than shifted: a `PREV` slot at the new buffer start reads
770        // past it where it read an evicted row before, so a verdict computed over the old buffer
771        // need not hold — conservative, and resumable.
772        self.dead_upto = self.next_pos;
773        self.matchless_upto = self.next_pos;
774        self.scan_cursor = self.next_pos;
775        self.incomplete = false;
776        self.freeze_truncated = false;
777        self.seq_index.drain(..final_pos);
778        Finalized::Rebased
779    }
780
781    /// Current provisional matches over everything fed so far, as if input ended now.
782    pub fn provisional(&self) -> &[SeqMatch] {
783        &self.matched
784    }
785
786    /// Seqs of the rows fed so far, in feed (buffer-position) order — i.e. `seq_index`. The executor
787    /// reads this to align the matcher with the freshly-scanned state-table buffer each visit and to
788    /// detect an out-of-order safe row (one whose sorted position precedes an already-fed row).
789    /// Test-only on this executor: the emit-on-update mode that drives it is not part of this
790    /// operator; the differential oracle exercises it so the contract stays proven.
791    #[cfg(test)]
792    pub fn fed_seqs(&self) -> &[Seq] {
793        &self.seq_index
794    }
795
796    /// Number of leading buffer positions that are frozen (immutable under future appends) — i.e.
797    /// `next_pos`, the scan-resume point. The production caller was absorbed into
798    /// [`IncrementalMatcher::finalize_evicted_prefix`]'s internal boundary checks, so this is
799    /// test-only observability (like [`IncrementalMatcher::frozen`]). Distinct from `frozen_count`,
800    /// which counts frozen *matches*, not positions.
801    #[cfg(test)]
802    fn frozen_prefix_len(&self) -> usize {
803        self.next_pos
804    }
805
806    /// Number of leading `provisional()` entries that are frozen (final under future appends).
807    /// Test-only observability for asserting freezing behavior directly.
808    #[cfg(test)]
809    fn frozen(&self) -> usize {
810        self.frozen_count
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use std::collections::BTreeSet;
817
818    use rand::rngs::SmallRng;
819    use rand::{Rng, SeedableRng};
820    use risingwave_common::array::Op;
821    use risingwave_common::row::OwnedRow;
822    use risingwave_common::types::ScalarImpl;
823
824    use super::{
825        Finalized, IncrementalMatcher, Seq, SeqMatch, diff_provisional, plan_provisional_rows,
826    };
827    use crate::executor::error::StreamExecutorResult;
828    use crate::executor::match_recognize::nfa::{
829        CandidateMatcher, Nfa, Pattern, Quantifier, ScanBudget, SetMatcher, SkipMode,
830    };
831
832    /// A `SeqMatch` with the given extent and labels (identity is `start`).
833    fn dm(start: i64, end: i64, ls: &[&str]) -> SeqMatch {
834        SeqMatch {
835            start_seq: Seq(start),
836            end_seq: Seq(end),
837            labels: labels(ls),
838        }
839    }
840
841    /// Wrap raw seq literals for the matcher feed API (`advance` takes `&[Seq]`); tests use bare ints
842    /// (seqs equal final sorted positions in these tests, so the ints read naturally).
843    fn ss(xs: &[i64]) -> Vec<Seq> {
844        xs.iter().map(|&x| Seq(x)).collect()
845    }
846
847    /// Run an in-place finalization and return the matches it removed, derived by diffing
848    /// `provisional()` before/after — sound because finalization removes exactly a leading run of
849    /// the stored matches, so the removed ones are the vanished prefix (scan order, seqs intact).
850    /// Fails the test on the `MustRebuild` outcome (the caller-drops-and-rebuilds path is asserted
851    /// separately).
852    fn finalize_rebased(inc: &mut IncrementalMatcher, boundary: Seq) -> Vec<SeqMatch> {
853        let before = inc.provisional().to_vec();
854        match inc.finalize_evicted_prefix(boundary) {
855            Finalized::Rebased => before[..before.len() - inc.provisional().len()].to_vec(),
856            Finalized::MustRebuild => panic!("expected Finalized::Rebased, got MustRebuild"),
857        }
858    }
859
860    /// A one-column output row carrying `v`, so distinct rows are easy to assert on.
861    fn orow(v: i64) -> OwnedRow {
862        OwnedRow::new(vec![Some(ScalarImpl::Int64(v))])
863    }
864
865    #[test]
866    fn diff_emits_delete_insert_for_revision() {
867        // start 5 revised: end 7 (row A) -> end 9 (row B). Delete(A) then Insert(B).
868        let old = vec![(dm(5, 7, &["a", "b"]), orow(100))];
869        let new = vec![(dm(5, 9, &["a", "b", "b"]), orow(200))];
870        assert_eq!(
871            diff_provisional(&old, &new),
872            vec![(Op::Delete, orow(100)), (Op::Insert, orow(200))]
873        );
874    }
875
876    #[test]
877    fn diff_emits_delete_for_vanished_match() {
878        let old = vec![(dm(5, 7, &["a", "b"]), orow(100))];
879        let new: Vec<(SeqMatch, OwnedRow)> = vec![];
880        assert_eq!(diff_provisional(&old, &new), vec![(Op::Delete, orow(100))]);
881    }
882
883    #[test]
884    fn diff_emits_insert_for_brand_new_match() {
885        let old: Vec<(SeqMatch, OwnedRow)> = vec![];
886        let new = vec![(dm(5, 7, &["a", "b"]), orow(200))];
887        assert_eq!(diff_provisional(&old, &new), vec![(Op::Insert, orow(200))]);
888    }
889
890    #[test]
891    fn diff_unchanged_match_emits_nothing() {
892        let old = vec![(dm(5, 7, &["a", "b"]), orow(100))];
893        let new = vec![(dm(5, 7, &["a", "b"]), orow(100))];
894        assert_eq!(diff_provisional(&old, &new), vec![]);
895    }
896
897    /// Recovery-rebuild seam (Task 8). On recovery the executor reseeds its emit-on-update diff
898    /// base by feeding the recovered buffer to a FRESH matcher (`rebuild_last_emitted` →
899    /// `compute_partition_emitted`) and emitting nothing. That silent reseed is sound only if the
900    /// from-scratch recomputation reproduces the pre-crash matcher's provisional set exactly — the
901    /// set downstream already holds. This test exercises that property at the matcher+diff seam:
902    /// the pre-crash side reaches its state through multi-visit incremental advances, a
903    /// finalize-eviction (the finalized match left the base without a retract, its rows left the
904    /// buffer), and a further advance; the recovery side is one whole-buffer feed of the surviving
905    /// rows into a fresh matcher — exactly what the rebuild does. Both must agree on full
906    /// `(start, end, labels)` triples, and a diff between output rows synthesized deterministically
907    /// from each match (`build_match_row` is deterministic in the same way: pure in buffer content)
908    /// must be empty — the reseed leaves nothing to re-emit at the next barrier.
909    #[tokio::test]
910    async fn recovery_rebuild_reproduces_pre_crash_provisional_set() {
911        let pat = Pattern::Concat(vec![
912            Pattern::Var("a".into()),
913            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
914        ]);
915        let nfa = Nfa::compile(&pat);
916        let skip = SkipMode::PastLastRow;
917        // Deterministic stand-in for `build_match_row`: a pure function of the match content.
918        let out_row = |m: &SeqMatch| orow(m.start_seq.0 * 1000 + m.end_seq.0);
919
920        // Pre-crash: rows 0:a 1:b 2:a 3:b arrive across two visits; the first `a b` = (0,2)
921        // freezes once the `a` at position 2 breaks the greedy `b+`.
922        let pre = from_str("abab");
923        let m_pre = SetMatcher::new(pre.clone());
924        let mut pre_inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
925        pre_inc
926            .advance(&ss(&[0, 1]), &m_pre, &mut ScanBudget::unlimited(), false)
927            .await
928            .unwrap();
929        pre_inc
930            .advance(&ss(&[2, 3]), &m_pre, &mut ScanBudget::unlimited(), false)
931            .await
932            .unwrap();
933        // A watermark finalizes and evicts the frozen (0,2): its rows leave the buffer and it
934        // leaves the diff base without a retraction (a permanent result downstream).
935        let removed = finalize_rebased(&mut pre_inc, Seq(2));
936        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "b"]))]);
937        // One more row arrives; the last pre-crash barrier emitted this provisional set, so the
938        // MV's provisional portion == this base. Surviving buffer: seqs 2,3,4 = {a},{b},{b}.
939        let tail = from_str("abb");
940        let m_tail = SetMatcher::new(tail.clone());
941        pre_inc
942            .advance(&ss(&[4]), &m_tail, &mut ScanBudget::unlimited(), false)
943            .await
944            .unwrap();
945        let pre_base: Vec<(SeqMatch, OwnedRow)> = pre_inc
946            .provisional()
947            .iter()
948            .map(|m| (m.clone(), out_row(m)))
949            .collect();
950        assert!(!pre_base.is_empty());
951
952        // Crash. Recovery: the restored state table holds only the surviving rows; the rebuild
953        // feeds them, whole-buffer, into a FRESH matcher.
954        let mut rec_inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
955        rec_inc
956            .advance(
957                &ss(&[2, 3, 4]),
958                &m_tail,
959                &mut ScanBudget::unlimited(),
960                false,
961            )
962            .await
963            .unwrap();
964        let rec_set: Vec<(SeqMatch, OwnedRow)> = rec_inc
965            .provisional()
966            .iter()
967            .map(|m| (m.clone(), out_row(m)))
968            .collect();
969
970        // The from-scratch recomputation reproduces the pre-crash provisional set exactly...
971        assert_eq!(provisional_triples(&rec_inc), provisional_triples(&pre_inc));
972        // ...so seeding `last_emitted` with it and re-diffing (as the next barrier would over an
973        // unchanged buffer) re-emits nothing — the silent rebuild is exact, not approximate.
974        assert_eq!(diff_provisional(&pre_base, &rec_set), vec![]);
975    }
976
977    /// The row-independent classification the executor runs before building any output row. An
978    /// unchanged provisional set must reuse every base row (all `Some`, zero rebuilds — the
979    /// steady-state win); a same-`start_seq` match with a changed extent or changed labels, and a
980    /// brand-new start, must each rebuild (`None`).
981    #[test]
982    fn plan_reuses_unchanged_and_rebuilds_changed() {
983        let base = vec![
984            (dm(5, 7, &["a", "b"]), orow(100)),
985            (dm(10, 12, &["a", "b"]), orow(200)),
986        ];
987
988        // Identical provisional set (order need not match the base): every row reused, none rebuilt.
989        let same = vec![dm(10, 12, &["a", "b"]), dm(5, 7, &["a", "b"])];
990        let plan = plan_provisional_rows(&base, &same);
991        assert_eq!(plan, vec![Some(1), Some(0)]);
992        assert!(
993            plan.iter().all(Option::is_some),
994            "an unchanged provisional set must rebuild no output rows"
995        );
996
997        // Changed extent (same start), changed labels (same start+extent), and a brand-new start:
998        // each must be rebuilt.
999        let changed = vec![
1000            dm(5, 9, &["a", "b", "b"]), // start 5: extent 7 -> 9
1001            dm(10, 12, &["b", "b"]),    // start 10: labels [a,b] -> [b,b]
1002            dm(20, 22, &["a", "b"]),    // brand-new start
1003        ];
1004        assert_eq!(
1005            plan_provisional_rows(&base, &changed),
1006            vec![None, None, None]
1007        );
1008    }
1009
1010    /// Same extent and labels, but the evaluated output row changed — still a revision (the diff
1011    /// compares the output row, not just the span/labels).
1012    #[test]
1013    fn diff_same_extent_different_row_is_revision() {
1014        let old = vec![(dm(5, 7, &["a", "b"]), orow(100))];
1015        let new = vec![(dm(5, 7, &["a", "b"]), orow(101))];
1016        assert_eq!(
1017            diff_provisional(&old, &new),
1018            vec![(Op::Delete, orow(100)), (Op::Insert, orow(101))]
1019        );
1020    }
1021
1022    /// Regression: the inputs are NOT start-seq sorted. `provisional()` yields matches in the
1023    /// matcher's scan (buffer-position) order, and seqs are minted at *arrival* — so a late row that
1024    /// sorts earlier carries a higher seq at an earlier position, and position order diverges from
1025    /// `start_seq` order. Here `old` holds (start 30, X) at the earlier position before (start 10, K);
1026    /// `new` still holds the unchanged (start 10, K). A merge that trusted the input order would
1027    /// emit Insert(K), Delete(X), Delete(K) — net-removing K downstream even though it is still
1028    /// live. The internal sort must yield exactly Delete(X), keeping K present.
1029    #[test]
1030    fn diff_position_ordered_inputs_from_out_of_order_arrival() {
1031        let old = vec![
1032            (dm(30, 32, &["a", "b"]), orow(300)), // X: earlier position, later-minted seq
1033            (dm(10, 12, &["a", "b"]), orow(100)), // K: later position, earlier-minted seq
1034        ];
1035        let new = vec![(dm(10, 12, &["a", "b"]), orow(100))];
1036        assert_eq!(diff_provisional(&old, &new), vec![(Op::Delete, orow(300))]);
1037
1038        // Same provenance with both sides unsorted and K revised: the pairing must still be by
1039        // identity, so X vanishes and K is retract-updated — never net-deleted.
1040        let old = vec![
1041            (dm(30, 32, &["a", "b"]), orow(300)),
1042            (dm(10, 12, &["a", "b"]), orow(100)),
1043        ];
1044        let new = vec![
1045            (dm(40, 42, &["a", "b"]), orow(400)),
1046            (dm(10, 13, &["a", "b", "b"]), orow(101)),
1047        ];
1048        assert_eq!(
1049            diff_provisional(&old, &new),
1050            vec![
1051                (Op::Delete, orow(100)),
1052                (Op::Insert, orow(101)),
1053                (Op::Delete, orow(300)),
1054                (Op::Insert, orow(400)),
1055            ]
1056        );
1057    }
1058
1059    /// Mixed sequence in one pass: start 1 unchanged, start 5 revised, start 9 vanished, start 11
1060    /// brand new. Ops must come out start-seq ascending, Delete-before-Insert per revised identity.
1061    #[test]
1062    fn diff_mixed_sequence_orders_by_start_seq() {
1063        let old = vec![
1064            (dm(1, 3, &["a", "b"]), orow(10)),
1065            (dm(5, 7, &["a", "b"]), orow(50)),
1066            (dm(9, 10, &["a"]), orow(90)),
1067        ];
1068        let new = vec![
1069            (dm(1, 3, &["a", "b"]), orow(10)),
1070            (dm(5, 9, &["a", "b", "b"]), orow(59)),
1071            (dm(11, 12, &["a"]), orow(110)),
1072        ];
1073        assert_eq!(
1074            diff_provisional(&old, &new),
1075            vec![
1076                (Op::Delete, orow(50)),
1077                (Op::Insert, orow(59)),
1078                (Op::Delete, orow(90)),
1079                (Op::Insert, orow(110)),
1080            ]
1081        );
1082    }
1083
1084    /// Oracle: feeding rows incrementally (in any split) must equal one batch
1085    /// `find_matches_dynamic` over the same rows — compared as full `(start, end, labels)` triples
1086    /// (like [`assert_equiv_with`]), so a *steal* (a row rebinding to a different variable under an
1087    /// unchanged span) is caught, not just span changes.
1088    async fn assert_equiv(
1089        nfa: &Nfa,
1090        skip: SkipMode,
1091        rows: &[BTreeSet<String>],
1092        split_at: &[usize],
1093    ) {
1094        let matcher = SetMatcher::new(rows.to_vec());
1095        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1096        let mut fed = 0usize;
1097        for &cut in split_at.iter().chain(std::iter::once(&rows.len())) {
1098            let seqs: Vec<Seq> = (fed..cut).map(|i| Seq(i as i64)).collect();
1099            inc.advance(&seqs, &matcher, &mut ScanBudget::unlimited(), false)
1100                .await
1101                .unwrap();
1102            fed = cut;
1103        }
1104        assert_eq!(
1105            provisional_triples(&inc),
1106            batch_triples(nfa, &skip, rows).await
1107        );
1108    }
1109
1110    fn sets(labels: &[&str]) -> BTreeSet<String> {
1111        labels.iter().map(|s| s.to_string()).collect()
1112    }
1113
1114    /// One row per non-whitespace char, each satisfying the single variable named by that char.
1115    fn from_str(s: &str) -> Vec<BTreeSet<String>> {
1116        s.chars()
1117            .filter(|c| !c.is_whitespace())
1118            .map(|c| BTreeSet::from([c.to_string()]))
1119            .collect()
1120    }
1121
1122    /// `n` rows that each satisfy both `a` and `b`, so quantifier preference (not the predicate)
1123    /// decides the split — mirrors `nfa`'s own `ab_rows` helper.
1124    fn ab_rows(n: usize) -> Vec<BTreeSet<String>> {
1125        vec![BTreeSet::from(["a".to_owned(), "b".to_owned()]); n]
1126    }
1127
1128    fn quant(inner: Pattern, q: Quantifier, reluctant: bool) -> Pattern {
1129        Pattern::Quantified(Box::new(inner), q, reluctant)
1130    }
1131
1132    fn labels(ls: &[&str]) -> Vec<String> {
1133        ls.iter().map(|s| s.to_string()).collect()
1134    }
1135
1136    /// Provisional matches as `(start_pos, end_pos, labels)`. In the truncation tests seqs are
1137    /// assigned equal to final sorted position, so a seq is directly its position and these triples
1138    /// line up with the batch oracle's position-anchored spans.
1139    fn provisional_triples(inc: &IncrementalMatcher) -> Vec<(usize, usize, Vec<String>)> {
1140        inc.provisional()
1141            .iter()
1142            .map(|m| {
1143                (
1144                    m.start_seq.0 as usize,
1145                    m.end_seq.0 as usize,
1146                    m.labels.clone(),
1147                )
1148            })
1149            .collect()
1150    }
1151
1152    /// Batch oracle over `rows` as `(start, end, labels)` triples (labels included so a *steal* —
1153    /// a row rebinding to a different variable — is caught, not just span changes).
1154    async fn batch_triples(
1155        nfa: &Nfa,
1156        skip: &SkipMode,
1157        rows: &[BTreeSet<String>],
1158    ) -> Vec<(usize, usize, Vec<String>)> {
1159        let matcher = SetMatcher::new(rows.to_vec());
1160        nfa.find_matches_dynamic(rows.len(), &matcher, skip)
1161            .await
1162            .unwrap()
1163            .iter()
1164            .map(|m| (m.start, m.end, m.labels.clone()))
1165            .collect()
1166    }
1167
1168    #[tokio::test]
1169    async fn incremental_equals_batch_in_order() {
1170        // pattern (a b+) — greedy trailing quantifier exercises the "still-open trailing match must
1171        // re-scan" rule.
1172        let pat = Pattern::Concat(vec![
1173            Pattern::Var("a".into()),
1174            Pattern::Quantified(Box::new(Pattern::Var("b".into())), Quantifier::Plus, false),
1175        ]);
1176        let nfa = Nfa::compile(&pat);
1177        let rows = vec![
1178            sets(&["a"]),
1179            sets(&["b"]),
1180            sets(&["b"]),
1181            sets(&["a"]),
1182            sets(&["b"]),
1183        ];
1184        // every split point, including feeding one row at a time
1185        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1]).await;
1186        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1, 2, 3, 4]).await;
1187        assert_equiv(&nfa, SkipMode::ToNextRow, &rows, &[2]).await;
1188    }
1189
1190    #[tokio::test]
1191    async fn alternation_incremental_equals_batch() {
1192        // (a | b) c — the standard alternation shape from nfa.rs's own tests. The trailing `c`
1193        // makes a match ending at the buffer boundary re-attempt until the `c` row arrives.
1194        let pat = Pattern::Concat(vec![
1195            Pattern::Alt(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]),
1196            Pattern::Var("c".into()),
1197        ]);
1198        let nfa = Nfa::compile(&pat);
1199        let rows = from_str("acbc");
1200        for split in [&[1][..], &[2][..], &[1, 2, 3][..], &[3][..]] {
1201            assert_equiv(&nfa, SkipMode::PastLastRow, &rows, split).await;
1202        }
1203    }
1204
1205    #[tokio::test]
1206    async fn range_quantifier_incremental_equals_batch() {
1207        // a{2,3} — a bounded range that greedily takes up to three, with the optional third copy
1208        // ending at the boundary (open) until the next row disambiguates it.
1209        let pat = quant(
1210            Pattern::Var("a".into()),
1211            Quantifier::Range {
1212                min: 2,
1213                max: Some(3),
1214            },
1215            false,
1216        );
1217        let nfa = Nfa::compile(&pat);
1218        let rows = from_str("aaxaa");
1219        for split in [&[1][..], &[2][..], &[1, 2, 3, 4][..], &[3][..]] {
1220            assert_equiv(&nfa, SkipMode::PastLastRow, &rows, split).await;
1221        }
1222    }
1223
1224    #[tokio::test]
1225    async fn reluctant_quantifier_incremental_equals_batch() {
1226        // a+? b over rows that each satisfy both a and b: the reluctant `a+?` takes the fewest `a`,
1227        // so each match is exactly "ab" and the split between them is preference-, not predicate-,
1228        // driven.
1229        let pat = Pattern::Concat(vec![
1230            quant(Pattern::Var("a".into()), Quantifier::Plus, true),
1231            Pattern::Var("b".into()),
1232        ]);
1233        let nfa = Nfa::compile(&pat);
1234        let rows = ab_rows(4);
1235        for split in [&[1][..], &[2][..], &[1, 2, 3][..]] {
1236            assert_equiv(&nfa, SkipMode::PastLastRow, &rows, split).await;
1237        }
1238    }
1239
1240    #[tokio::test]
1241    async fn to_next_row_overlap_incremental_equals_batch() {
1242        // a+ with SKIP TO NEXT ROW: overlapping matches (0,3),(1,3),(2,3) over "aaa" — the exact
1243        // overlap case from nfa.rs's `find_matches_skip_to_next_row_overlaps`. Every open match
1244        // ends at the boundary, so none freezes until a non-`a` row (or nothing) follows.
1245        let pat = quant(Pattern::Var("a".into()), Quantifier::Plus, false);
1246        let nfa = Nfa::compile(&pat);
1247        let rows = from_str("aaa");
1248        for split in [&[1][..], &[2][..], &[1, 2][..]] {
1249            assert_equiv(&nfa, SkipMode::ToNextRow, &rows, split).await;
1250        }
1251        // and with a trailing non-`a` row that closes all three matches strictly before the boundary
1252        let rows = from_str("aaab");
1253        assert_equiv(&nfa, SkipMode::ToNextRow, &rows, &[1, 2, 3]).await;
1254    }
1255
1256    #[tokio::test]
1257    async fn empty_advances_are_noops() {
1258        // A repeated split point feeds an empty `advance(&[])`; a leading `0` feeds one before any
1259        // real row. Both must be no-ops, so the final matches still equal the batch answer.
1260        let pat = Pattern::Concat(vec![
1261            Pattern::Var("a".into()),
1262            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1263        ]);
1264        let nfa = Nfa::compile(&pat);
1265        let rows = from_str("abbab");
1266        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[2, 2, 3]).await; // empty in the middle
1267        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[0, 1, 3]).await; // empty at the very start
1268        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1, 3, 3, 3]).await; // empties at the end
1269    }
1270
1271    #[tokio::test]
1272    async fn single_row_feeds_over_twelve_rows() {
1273        // Feed 12-row inputs one row at a time (split = [1..=11]) for several patterns.
1274        let one_at_a_time: Vec<usize> = (1..12).collect();
1275
1276        let ab_plus = Nfa::compile(&Pattern::Concat(vec![
1277            Pattern::Var("a".into()),
1278            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1279        ]));
1280        assert_equiv(
1281            &ab_plus,
1282            SkipMode::PastLastRow,
1283            &from_str("abbabbaabbab"),
1284            &one_at_a_time,
1285        )
1286        .await;
1287
1288        let alt_c = Nfa::compile(&Pattern::Concat(vec![
1289            Pattern::Alt(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]),
1290            Pattern::Var("c".into()),
1291        ]));
1292        assert_equiv(
1293            &alt_c,
1294            SkipMode::PastLastRow,
1295            &from_str("acbcacxbcacb"),
1296            &one_at_a_time,
1297        )
1298        .await;
1299        assert_equiv(
1300            &alt_c,
1301            SkipMode::PastLastRow,
1302            &from_str("acbcbcacacbc"),
1303            &one_at_a_time,
1304        )
1305        .await;
1306
1307        // overlapping matches under ToNextRow, single-fed
1308        let a_plus = Nfa::compile(&quant(Pattern::Var("a".into()), Quantifier::Plus, false));
1309        assert_equiv(
1310            &a_plus,
1311            SkipMode::ToNextRow,
1312            &from_str("aaxaaaxaaaax"),
1313            &one_at_a_time,
1314        )
1315        .await;
1316    }
1317
1318    /// The freezing gate must consult boundary liveness, not just "match ended before the boundary".
1319    /// `(a b c) | a` over `[a, b]` returns the fallback `a` match `(0,1)` while the longer,
1320    /// higher-preference `a b c` branch is still alive *at* the boundary (waiting for `c`). The
1321    /// naive `end < n_rows` rule would freeze `(0,1)`; the liveness gate sees position 0 alive and
1322    /// defers, so when `c` arrives the rescan finds the batch answer `(0,3)` ("abc").
1323    #[tokio::test]
1324    async fn alternation_alive_at_boundary_defers_freezing() {
1325        let pat = Pattern::Alt(vec![
1326            Pattern::Concat(vec![
1327                Pattern::Var("a".into()),
1328                Pattern::Var("b".into()),
1329                Pattern::Var("c".into()),
1330            ]),
1331            Pattern::Var("a".into()),
1332        ]);
1333        let nfa = Nfa::compile(&pat);
1334        let rows = from_str("abc");
1335        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[2]).await;
1336        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1, 2]).await;
1337    }
1338
1339    /// A *gap* position (no match there yet) can be the live one: `(a n n n) | n` over `[a, n, n]`
1340    /// finds only `n` matches, but position 0's `a n n n` branch is alive at the boundary — one more
1341    /// `n` turns the batch answer into the single match `(0,4)`. Freezing the early `n` matches
1342    /// (whose own starts are dead) would lose it, so the gate must check every position in the
1343    /// would-be-frozen region, not just match starts.
1344    #[tokio::test]
1345    async fn live_gap_position_defers_freezing() {
1346        let pat = Pattern::Alt(vec![
1347            Pattern::Concat(vec![
1348                Pattern::Var("a".into()),
1349                Pattern::Var("n".into()),
1350                Pattern::Var("n".into()),
1351                Pattern::Var("n".into()),
1352            ]),
1353            Pattern::Var("n".into()),
1354        ]);
1355        let nfa = Nfa::compile(&pat);
1356        let rows = from_str("annn");
1357        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[3]).await;
1358        assert_equiv(&nfa, SkipMode::PastLastRow, &rows, &[1, 2, 3]).await;
1359    }
1360
1361    /// A greedy trailing quantifier keeps the last match alive at the buffer end forever: `(a b+)`
1362    /// fed one row at a time never freezes its trailing match, and the match keeps extending as
1363    /// each `b` arrives.
1364    #[tokio::test]
1365    async fn trailing_quantified_match_extends_without_freezing() {
1366        let pat = Pattern::Concat(vec![
1367            Pattern::Var("a".into()),
1368            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1369        ]);
1370        let nfa = Nfa::compile(&pat);
1371        let rows = from_str("abbb");
1372        let matcher = SetMatcher::new(rows.clone());
1373        let mut inc =
1374            IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), SkipMode::PastLastRow);
1375
1376        // [a]: `a` alone doesn't satisfy `a b+`, but it is alive (a `b` may arrive) — no match yet,
1377        // nothing frozen.
1378        inc.advance(&ss(&[0]), &matcher, &mut ScanBudget::unlimited(), false)
1379            .await
1380            .unwrap();
1381        assert_eq!(inc.provisional(), &[]);
1382        assert_eq!(inc.frozen(), 0);
1383
1384        // Each appended `b` extends the same match by one row; it always ends at the buffer end, so
1385        // it stays alive and never freezes.
1386        for (seq, expected_end) in [(1i64, 2i64), (2, 3), (3, 4)] {
1387            inc.advance(&ss(&[seq]), &matcher, &mut ScanBudget::unlimited(), false)
1388                .await
1389                .unwrap();
1390            assert_eq!(
1391                inc.provisional(),
1392                &[SeqMatch {
1393                    start_seq: Seq(0),
1394                    end_seq: Seq(expected_end),
1395                    labels: std::iter::once("a".to_owned())
1396                        .chain(std::iter::repeat_n(
1397                            "b".to_owned(),
1398                            expected_end as usize - 1
1399                        ))
1400                        .collect(),
1401                }]
1402            );
1403            assert_eq!(inc.frozen(), 0);
1404        }
1405    }
1406
1407    /// Step 1 out-of-order reinsert with a *steal*. Rows arrive `[r0, r1, r3, r4]`; a late `r2`
1408    /// lands between `r1` and `r3`. Pattern `a+ b` over the pre-insert rows `[{a}, {a,b}, {x}, {x}]`
1409    /// freezes the match `a b` = `(0,2)` with `r1` bound as the closing `b`. The late `r2 = {b}`
1410    /// inserted at position 2 lets the greedy `a+` swallow `r1` as an extra `a` and bind `r2` as the
1411    /// `b`, so the batch answer over `[{a}, {a,b}, {b}, {x}, {x}]` is the longer `(0,3)` = `a a b`
1412    /// (r1 stolen from `b` to `a`). Truncating at `r3`'s seq must invalidate the frozen `(0,2)` — its
1413    /// end reaches the truncation point — and rewind so the re-feed re-derives `(0,3)`.
1414    #[tokio::test]
1415    async fn out_of_order_reinsert_equals_batch() {
1416        let pat = Pattern::Concat(vec![
1417            quant(Pattern::Var("a".into()), Quantifier::Plus, false),
1418            Pattern::Var("b".into()),
1419        ]);
1420        let nfa = Nfa::compile(&pat);
1421        let skip = SkipMode::PastLastRow;
1422
1423        // Buffer before the late arrival (sorted positions 0..4).
1424        let pre_rows = vec![sets(&["a"]), sets(&["a", "b"]), sets(&["x"]), sets(&["x"])];
1425        let pre_matcher = SetMatcher::new(pre_rows.clone());
1426
1427        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1428        inc.advance(
1429            &ss(&[0, 1, 2, 3]),
1430            &pre_matcher,
1431            &mut ScanBudget::unlimited(),
1432            false,
1433        )
1434        .await
1435        .unwrap();
1436        // `a b` = (0,2) freezes with r1 bound `b`.
1437        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
1438        assert_eq!(inc.frozen(), 1);
1439
1440        // r2 = {b} sorts between r1 (pos 1) and r3 (pos 2). r3 is the first buffered row whose sorted
1441        // position changes, so the caller truncates at r3's seq (2).
1442        inc.truncate_from_seq(Seq(2), &pre_matcher, &mut ScanBudget::unlimited(), false)
1443            .await
1444            .unwrap();
1445        // The frozen match reached the truncation point, so nothing survives.
1446        assert_eq!(provisional_triples(&inc), vec![]);
1447        assert_eq!(inc.frozen(), 0);
1448
1449        // Re-feed the sorted suffix [r2, r3, r4] with their final positions as seqs.
1450        let final_rows = vec![
1451            sets(&["a"]),
1452            sets(&["a", "b"]),
1453            sets(&["b"]),
1454            sets(&["x"]),
1455            sets(&["x"]),
1456        ];
1457        let final_matcher = SetMatcher::new(final_rows.clone());
1458        inc.advance(
1459            &ss(&[2, 3, 4]),
1460            &final_matcher,
1461            &mut ScanBudget::unlimited(),
1462            false,
1463        )
1464        .await
1465        .unwrap();
1466
1467        assert_eq!(
1468            provisional_triples(&inc),
1469            batch_triples(&nfa, &skip, &final_rows).await
1470        );
1471        assert_eq!(
1472            provisional_triples(&inc),
1473            vec![(0, 3, labels(&["a", "a", "b"]))]
1474        );
1475    }
1476
1477    /// Truncation landing in the *middle* of the frozen region: an earlier frozen match survives
1478    /// while a later one is dropped, so `next_pos` rewinds to the survivor's resume point (not 0).
1479    /// Pattern `a b` over `[{a},{b},{x},{a},{b},{x}]` freezes both `(0,2)` and `(3,5)`. A late `{a}`
1480    /// sorts at position 3 (before the second match): truncating at that row's seq keeps `(0,2)` and
1481    /// invalidates `(3,5)`, and the re-feed re-derives the shifted second match `(4,6)`.
1482    #[tokio::test]
1483    async fn truncate_inside_frozen_region_keeps_earlier_matches() {
1484        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
1485        let nfa = Nfa::compile(&pat);
1486        let skip = SkipMode::PastLastRow;
1487
1488        let pre_rows = vec![
1489            sets(&["a"]),
1490            sets(&["b"]),
1491            sets(&["x"]),
1492            sets(&["a"]),
1493            sets(&["b"]),
1494            sets(&["x"]),
1495        ];
1496        let pre_matcher = SetMatcher::new(pre_rows.clone());
1497
1498        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1499        inc.advance(
1500            &ss(&[0, 1, 2, 3, 4, 5]),
1501            &pre_matcher,
1502            &mut ScanBudget::unlimited(),
1503            false,
1504        )
1505        .await
1506        .unwrap();
1507        assert_eq!(
1508            provisional_triples(&inc),
1509            vec![(0, 2, labels(&["a", "b"])), (3, 5, labels(&["a", "b"]))]
1510        );
1511        assert_eq!(inc.frozen(), 2);
1512
1513        // A late {a} sorts at position 3; the old row at position 3 is the first whose sorted position
1514        // changes, so the caller truncates at its seq (3).
1515        inc.truncate_from_seq(Seq(3), &pre_matcher, &mut ScanBudget::unlimited(), false)
1516            .await
1517            .unwrap();
1518        // (0,2) survives (its region is dead at boundary 3); (3,5) reaches past it and is dropped.
1519        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
1520        assert_eq!(inc.frozen(), 1);
1521
1522        // Re-feed the sorted suffix [late {a}, old rows] from position 3, with final positions as seqs.
1523        let final_rows = vec![
1524            sets(&["a"]),
1525            sets(&["b"]),
1526            sets(&["x"]),
1527            sets(&["a"]),
1528            sets(&["a"]),
1529            sets(&["b"]),
1530            sets(&["x"]),
1531        ];
1532        let final_matcher = SetMatcher::new(final_rows.clone());
1533        inc.advance(
1534            &ss(&[3, 4, 5, 6]),
1535            &final_matcher,
1536            &mut ScanBudget::unlimited(),
1537            false,
1538        )
1539        .await
1540        .unwrap();
1541
1542        assert_eq!(
1543            provisional_triples(&inc),
1544            batch_triples(&nfa, &skip, &final_rows).await
1545        );
1546        assert_eq!(
1547            provisional_triples(&inc),
1548            vec![(0, 2, labels(&["a", "b"])), (4, 6, labels(&["a", "b"]))]
1549        );
1550    }
1551
1552    /// Truncating at the first fed row is a full reset. A late `{a}` sorting before everything shifts
1553    /// all positions, so the caller truncates at seq 0; state must clear entirely, and re-feeding the
1554    /// whole corrected sequence must equal the batch answer.
1555    #[tokio::test]
1556    async fn truncate_to_zero_resets_and_refeeds() {
1557        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
1558        let nfa = Nfa::compile(&pat);
1559        let skip = SkipMode::PastLastRow;
1560
1561        let pre_rows = vec![sets(&["a"]), sets(&["b"]), sets(&["x"])];
1562        let pre_matcher = SetMatcher::new(pre_rows.clone());
1563
1564        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1565        inc.advance(
1566            &ss(&[0, 1, 2]),
1567            &pre_matcher,
1568            &mut ScanBudget::unlimited(),
1569            false,
1570        )
1571        .await
1572        .unwrap();
1573        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
1574        assert_eq!(inc.frozen(), 1);
1575
1576        // A late {a} sorts before r0, so r0 (seq 0) is the first row whose position changes.
1577        inc.truncate_from_seq(Seq(0), &pre_matcher, &mut ScanBudget::unlimited(), false)
1578            .await
1579            .unwrap();
1580        assert_eq!(provisional_triples(&inc), vec![]);
1581        assert_eq!(inc.frozen(), 0);
1582
1583        let final_rows = vec![sets(&["a"]), sets(&["a"]), sets(&["b"]), sets(&["x"])];
1584        let final_matcher = SetMatcher::new(final_rows.clone());
1585        inc.advance(
1586            &ss(&[0, 1, 2, 3]),
1587            &final_matcher,
1588            &mut ScanBudget::unlimited(),
1589            false,
1590        )
1591        .await
1592        .unwrap();
1593
1594        assert_eq!(
1595            provisional_triples(&inc),
1596            batch_triples(&nfa, &skip, &final_rows).await
1597        );
1598        assert_eq!(provisional_triples(&inc), vec![(1, 3, labels(&["a", "b"]))]);
1599    }
1600
1601    /// THE case a positional (matcher-free) truncation rule gets wrong — do not simplify
1602    /// `truncate_from_seq` back to "drop frozen matches whose end position >= `trunc_pos`".
1603    ///
1604    /// Pattern `(a b c d) | (a b)` (long branch preferred) over `[{a},{b},{c},{x}]`: the short
1605    /// branch matches `(0,2)`, and it freezes only once the `x` at position 3 kills the long branch
1606    /// (at boundary 3 the long branch is still alive — `a b c` reaches the boundary inside the
1607    /// automaton — so no freeze happens there). A late `{d}` then sorts at position 3, displacing
1608    /// the `x`. Truncating at the x-row's seq re-checks the frozen region against boundary 3, where
1609    /// position 0 is alive again, so `(0,2)` must be dropped even though its end (2) lies strictly
1610    /// before the truncation position (3); the re-feed then derives the long match `(0,4)`. The
1611    /// positional rule keeps `(0,2)` and rewinds to its resume point 2 — no match can start at
1612    /// `{c}`/`{d}`/`{x}`, so it would wrongly answer `(0,2)` forever.
1613    #[tokio::test]
1614    async fn truncation_recheck_drops_frozen_match_alive_at_new_boundary() {
1615        let pat = Pattern::Alt(vec![
1616            Pattern::Concat(vec![
1617                Pattern::Var("a".into()),
1618                Pattern::Var("b".into()),
1619                Pattern::Var("c".into()),
1620                Pattern::Var("d".into()),
1621            ]),
1622            Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]),
1623        ]);
1624        let nfa = Nfa::compile(&pat);
1625        let skip = SkipMode::PastLastRow;
1626
1627        let pre_rows = vec![sets(&["a"]), sets(&["b"]), sets(&["c"]), sets(&["x"])];
1628        let pre_matcher = SetMatcher::new(pre_rows.clone());
1629
1630        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1631        inc.advance(
1632            &ss(&[0, 1, 2, 3]),
1633            &pre_matcher,
1634            &mut ScanBudget::unlimited(),
1635            false,
1636        )
1637        .await
1638        .unwrap();
1639        // The `x` kills the long branch at position 3, so the short `(0,2)` freezes.
1640        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
1641        assert_eq!(inc.frozen(), 1);
1642
1643        // The late {d} sorts at position 3; the old {x} row (seq 3) is the first buffered row whose
1644        // sorted position changes, so the caller truncates at its seq.
1645        inc.truncate_from_seq(Seq(3), &pre_matcher, &mut ScanBudget::unlimited(), false)
1646            .await
1647            .unwrap();
1648        // Discriminator: the frozen (0,2) ends *before* the truncation position, yet position 0 is
1649        // alive at the new boundary — the liveness re-check must drop it. The positional rule keeps
1650        // it here, and these two assertions (and the batch check below) fail under that rule.
1651        assert_eq!(provisional_triples(&inc), vec![]);
1652        assert_eq!(inc.frozen(), 0);
1653
1654        // Re-feed the sorted suffix [late {d}, old {x}] with final positions as seqs.
1655        let final_rows = vec![
1656            sets(&["a"]),
1657            sets(&["b"]),
1658            sets(&["c"]),
1659            sets(&["d"]),
1660            sets(&["x"]),
1661        ];
1662        let final_matcher = SetMatcher::new(final_rows.clone());
1663        inc.advance(
1664            &ss(&[3, 4]),
1665            &final_matcher,
1666            &mut ScanBudget::unlimited(),
1667            false,
1668        )
1669        .await
1670        .unwrap();
1671
1672        assert_eq!(
1673            provisional_triples(&inc),
1674            batch_triples(&nfa, &skip, &final_rows).await
1675        );
1676        assert_eq!(
1677            provisional_triples(&inc),
1678            vec![(0, 4, labels(&["a", "b", "c", "d"]))]
1679        );
1680    }
1681
1682    /// Truncating at a seq that was never fed is a no-op: neither a seq past everything buffered nor
1683    /// one exactly one-past-the-end may touch state, and later appends must still equal the batch.
1684    #[tokio::test]
1685    async fn truncate_unknown_seq_is_noop() {
1686        let pat = Pattern::Concat(vec![
1687            Pattern::Var("a".into()),
1688            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1689        ]);
1690        let nfa = Nfa::compile(&pat);
1691        let skip = SkipMode::PastLastRow;
1692
1693        let rows = from_str("abbc");
1694        let matcher = SetMatcher::new(rows.clone());
1695
1696        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1697        inc.advance(
1698            &ss(&[0, 1, 2]),
1699            &matcher,
1700            &mut ScanBudget::unlimited(),
1701            false,
1702        )
1703        .await
1704        .unwrap();
1705        let before = inc.provisional().to_vec();
1706        let before_frozen = inc.frozen();
1707
1708        // A seq far past everything buffered, and the seq exactly one past the last fed row: both are
1709        // absent from `seq_index`, so both leave state untouched.
1710        inc.truncate_from_seq(Seq(99), &matcher, &mut ScanBudget::unlimited(), false)
1711            .await
1712            .unwrap();
1713        inc.truncate_from_seq(Seq(3), &matcher, &mut ScanBudget::unlimited(), false)
1714            .await
1715            .unwrap();
1716        assert_eq!(inc.provisional(), before.as_slice());
1717        assert_eq!(inc.frozen(), before_frozen);
1718
1719        // Appending really does append (nothing corrupted): the final answer equals the batch.
1720        inc.advance(&ss(&[3]), &matcher, &mut ScanBudget::unlimited(), false)
1721            .await
1722            .unwrap();
1723        assert_eq!(
1724            provisional_triples(&inc),
1725            batch_triples(&nfa, &skip, &rows).await
1726        );
1727    }
1728
1729    /// `SeqMatch`es (e.g. the finalized-and-returned ones) as `(start, end, labels)` triples, so
1730    /// they line up with the position-anchored batch oracle (seqs equal final sorted positions in
1731    /// these tests).
1732    fn seq_triples(ms: &[SeqMatch]) -> Vec<(usize, usize, Vec<String>)> {
1733        ms.iter()
1734            .map(|m| {
1735                (
1736                    m.start_seq.0 as usize,
1737                    m.end_seq.0 as usize,
1738                    m.labels.clone(),
1739                )
1740            })
1741            .collect()
1742    }
1743
1744    /// Generic oracle: feeding rows incrementally (in any split) through `matcher` must equal one
1745    /// batch `find_matches_dynamic` with the *same* `matcher`. Unlike [`assert_equiv`] this takes an
1746    /// arbitrary [`CandidateMatcher`] (not just [`SetMatcher`]), so a matcher that applies its own
1747    /// pruning — e.g. the `WITHIN` span prune — can be driven through the incremental path.
1748    async fn assert_equiv_with<M: CandidateMatcher + Sync>(
1749        nfa: &Nfa,
1750        skip: SkipMode,
1751        n_rows: usize,
1752        matcher: &M,
1753        split_at: &[usize],
1754    ) {
1755        let batch: Vec<(usize, usize, Vec<String>)> = nfa
1756            .find_matches_dynamic(n_rows, matcher, &skip)
1757            .await
1758            .unwrap()
1759            .iter()
1760            .map(|m| (m.start, m.end, m.labels.clone()))
1761            .collect();
1762        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1763        let mut fed = 0usize;
1764        for &cut in split_at.iter().chain(std::iter::once(&n_rows)) {
1765            let seqs: Vec<Seq> = (fed..cut).map(|i| Seq(i as i64)).collect();
1766            inc.advance(&seqs, matcher, &mut ScanBudget::unlimited(), false)
1767                .await
1768                .unwrap();
1769            fed = cut;
1770        }
1771        assert_eq!(provisional_triples(&inc), batch);
1772    }
1773
1774    /// A [`CandidateMatcher`] that models the `WITHIN` span prune the executor applies inside
1775    /// `DefineMatcher::matches` (see `executor.rs`): binding a candidate at `pos` extends the match
1776    /// to span `[match_start, pos]`, and the executor rejects the candidate when that span exceeds
1777    /// the bound, so the NFA backtracks to the longest match that fits the window. `WITHIN` lives
1778    /// entirely inside the `CandidateMatcher`; this module has no `WITHIN` logic of its own — it
1779    /// hands the matcher straight to `find_matches_dynamic` and `reaches_boundary_alive` — so
1780    /// driving a span-pruning matcher through the incremental path and checking equality with the
1781    /// batch path proves the pass-through. (`nfa.rs`'s `SetMatcher` has no `WITHIN`, and the real
1782    /// `DefineMatcher` needs the executor's expression/row machinery, so we model the prune here.)
1783    struct WithinSetMatcher {
1784        rows: Vec<BTreeSet<String>>,
1785        /// Max span in order-key units. Seqs equal positions here, so the span of a candidate at
1786        /// `pos` is `pos - match_start == labels.len()`.
1787        max_span: usize,
1788    }
1789
1790    impl CandidateMatcher for WithinSetMatcher {
1791        async fn matches(
1792            &self,
1793            var: &str,
1794            pos: usize,
1795            labels: &[String],
1796        ) -> StreamExecutorResult<bool> {
1797            if !self.rows[pos].contains(var) {
1798                return Ok(false);
1799            }
1800            let match_start = pos - labels.len();
1801            Ok(pos - match_start <= self.max_span)
1802        }
1803    }
1804
1805    /// (a) Finalize mid-stream, then keep feeding: the finalized prefix is removed from and returned
1806    /// out of the diffable set, `provisional()` keeps only the still-revisable matches, and the
1807    /// union `provisional() ∪ returned` equals the batch oracle over all rows — with the rebased
1808    /// bookkeeping proven by advancing further after the finalization and still matching the oracle.
1809    #[tokio::test]
1810    async fn finalize_removes_prefix_and_rebases_bookkeeping() {
1811        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
1812        let nfa = Nfa::compile(&pat);
1813        let skip = SkipMode::PastLastRow;
1814
1815        // 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).
1816        let full = from_str("abxabxabx");
1817        let m_full = SetMatcher::new(full.clone());
1818
1819        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1820        inc.advance(
1821            &ss(&[0, 1, 2, 3, 4, 5]),
1822            &m_full,
1823            &mut ScanBudget::unlimited(),
1824            false,
1825        )
1826        .await
1827        .unwrap();
1828        assert_eq!(
1829            provisional_triples(&inc),
1830            vec![(0, 2, labels(&["a", "b"])), (3, 5, labels(&["a", "b"]))]
1831        );
1832        assert_eq!(inc.frozen(), 2);
1833
1834        // Finalize everything before seq 3 (evict sorted positions [0,3)): removes the wholly-inside
1835        // match (0,2); (3,5) starts at the boundary and is kept.
1836        let removed = finalize_rebased(&mut inc, Seq(3));
1837        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "b"]))]);
1838        assert_eq!(provisional_triples(&inc), vec![(3, 5, labels(&["a", "b"]))]);
1839        assert_eq!(inc.frozen(), 1);
1840
1841        // Keep feeding rows 6,7,8. Their buffer positions are now rebased (row 3 sits at position 0),
1842        // so the matcher indexes the surviving buffer `full[3..]`.
1843        let m_tail = SetMatcher::new(full[3..].to_vec());
1844        inc.advance(
1845            &ss(&[6, 7, 8]),
1846            &m_tail,
1847            &mut ScanBudget::unlimited(),
1848            false,
1849        )
1850        .await
1851        .unwrap();
1852        assert_eq!(
1853            provisional_triples(&inc),
1854            vec![(3, 5, labels(&["a", "b"])), (6, 8, labels(&["a", "b"]))]
1855        );
1856
1857        // Union of the returned finalized match and the surviving provisional set equals the batch
1858        // oracle over the whole run.
1859        let mut union = seq_triples(&removed);
1860        union.extend(provisional_triples(&inc));
1861        assert_eq!(union, batch_triples(&nfa, &skip, &full).await);
1862    }
1863
1864    /// (a) Bookkeeping consistency across *all three* operations after a finalization: finalize a
1865    /// prefix, advance to derive more matches, then take a late (out-of-order) row that reinserts
1866    /// into the already-rebased tail — `truncate_from_seq` + re-feed — and the union still equals the
1867    /// batch oracle over the corrected full sequence. Exercises seq→position mapping against the
1868    /// rebased `seq_index` in both `advance` and `truncate_from_seq`.
1869    #[tokio::test]
1870    async fn finalize_then_truncate_and_advance_equals_batch() {
1871        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
1872        let nfa = Nfa::compile(&pat);
1873        let skip = SkipMode::PastLastRow;
1874
1875        // 0:a 1:b 2:x 3:a 4:b 5:x 6:a 7:b 8:x
1876        let pre = from_str("abxabxabx");
1877        let m_pre = SetMatcher::new(pre.clone());
1878
1879        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1880        inc.advance(
1881            &ss(&[0, 1, 2, 3, 4, 5, 6, 7, 8]),
1882            &m_pre,
1883            &mut ScanBudget::unlimited(),
1884            false,
1885        )
1886        .await
1887        .unwrap();
1888        assert_eq!(
1889            provisional_triples(&inc),
1890            vec![
1891                (0, 2, labels(&["a", "b"])),
1892                (3, 5, labels(&["a", "b"])),
1893                (6, 8, labels(&["a", "b"])),
1894            ]
1895        );
1896        assert_eq!(inc.frozen(), 3);
1897
1898        // Finalize before seq 3 (evict [0,3)); returns (0,2), rebases so row 3 is now position 0.
1899        let removed = finalize_rebased(&mut inc, Seq(3));
1900        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "b"]))]);
1901        assert_eq!(
1902            provisional_triples(&inc),
1903            vec![(3, 5, labels(&["a", "b"])), (6, 8, labels(&["a", "b"]))]
1904        );
1905
1906        // A late {a} sorts at global position 6 (before old row 6): old row 6 (seq 6) is the first
1907        // buffered row whose sorted position changes, so the caller truncates at seq 6. The matcher
1908        // indexes the rebased surviving buffer `pre[3..]`.
1909        let m_pre_tail = SetMatcher::new(pre[3..].to_vec());
1910        inc.truncate_from_seq(Seq(6), &m_pre_tail, &mut ScanBudget::unlimited(), false)
1911            .await
1912            .unwrap();
1913        // (3,5) survives (its region is dead at the truncation boundary); (6,8) reaches past it and
1914        // is dropped, to be re-derived by the re-feed.
1915        assert_eq!(provisional_triples(&inc), vec![(3, 5, labels(&["a", "b"]))]);
1916
1917        // Corrected full sequence with the late {a} inserted at position 6:
1918        // 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).
1919        let corrected = from_str("abxabxaabx");
1920        // Re-feed the sorted suffix from global position 6 (seqs 6..=9), matcher over the rebased
1921        // surviving buffer `corrected[3..]`.
1922        let m_corr_tail = SetMatcher::new(corrected[3..].to_vec());
1923        inc.advance(
1924            &ss(&[6, 7, 8, 9]),
1925            &m_corr_tail,
1926            &mut ScanBudget::unlimited(),
1927            false,
1928        )
1929        .await
1930        .unwrap();
1931
1932        let mut union = seq_triples(&removed);
1933        union.extend(provisional_triples(&inc));
1934        assert_eq!(union, batch_triples(&nfa, &skip, &corrected).await);
1935    }
1936
1937    /// (a) Robustness: neither a never-fed boundary nor one at the very first row may touch state,
1938    /// and later appends still equal the batch. A never-fed boundary reports `MustRebuild` (the
1939    /// caller drops and rebuilds) without mutating; a boundary at the first row (`final_pos == 0`)
1940    /// evicts nothing (`Rebased` with an empty removed set).
1941    #[tokio::test]
1942    async fn finalize_unknown_or_zero_seq_leaves_state_intact() {
1943        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]);
1944        let nfa = Nfa::compile(&pat);
1945        let skip = SkipMode::PastLastRow;
1946
1947        let rows = from_str("abxab");
1948        let matcher = SetMatcher::new(rows.clone());
1949
1950        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
1951        inc.advance(
1952            &ss(&[0, 1, 2, 3]),
1953            &matcher,
1954            &mut ScanBudget::unlimited(),
1955            false,
1956        )
1957        .await
1958        .unwrap();
1959        let before = inc.provisional().to_vec();
1960        let before_frozen = inc.frozen();
1961
1962        // Never fed: decline (the executor drops + rebuilds), leaving state untouched.
1963        assert!(matches!(
1964            inc.finalize_evicted_prefix(Seq(99)),
1965            Finalized::MustRebuild
1966        ));
1967        // final_pos == 0: rebased, evicting nothing.
1968        assert_eq!(finalize_rebased(&mut inc, Seq(0)), vec![]);
1969        assert_eq!(inc.provisional(), before.as_slice());
1970        assert_eq!(inc.frozen(), before_frozen);
1971
1972        inc.advance(&ss(&[4]), &matcher, &mut ScanBudget::unlimited(), false)
1973            .await
1974            .unwrap();
1975        assert_eq!(
1976            provisional_triples(&inc),
1977            batch_triples(&nfa, &skip, &rows).await
1978        );
1979    }
1980
1981    /// (b) `WITHIN` parity: a matcher that applies the `WITHIN` span prune drives identically through
1982    /// the incremental path and the batch path. `a b+` with a max span of one row (so a match may
1983    /// span at most two rows, `a b`) over `abbabb`: without `WITHIN` the greedy `b+` swallows both
1984    /// `b`s per match; `WITHIN` caps each match at `ab`. The incremental path must track that through
1985    /// both matching and the freeze-gate liveness check — proving pass-through, since this module has
1986    /// no `WITHIN` logic of its own.
1987    #[tokio::test]
1988    async fn within_span_prune_incremental_equals_batch() {
1989        let pat = Pattern::Concat(vec![
1990            Pattern::Var("a".into()),
1991            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
1992        ]);
1993        let nfa = Nfa::compile(&pat);
1994        let rows = from_str("abbabb");
1995        let matcher = WithinSetMatcher {
1996            rows: rows.clone(),
1997            max_span: 1,
1998        };
1999        for split in [
2000            &[1][..],
2001            &[2][..],
2002            &[3][..],
2003            &[1, 2, 3, 4, 5][..],
2004            &[2, 4][..],
2005        ] {
2006            assert_equiv_with(&nfa, SkipMode::PastLastRow, rows.len(), &matcher, split).await;
2007        }
2008    }
2009
2010    /// (c) Finalization must never reach into the open (non-frozen) trailing region: the boundary
2011    /// has to lie within the frozen prefix. `a b+` fed one row at a time keeps its trailing greedy
2012    /// match alive at the buffer end forever, so nothing freezes (`next_pos == 0`). Finalizing before
2013    /// seq 2 (a position past the frozen prefix) must return `MustRebuild` rather than reach into the
2014    /// open region (formerly a debug-assert panic; now a matcher-owned decision).
2015    #[tokio::test]
2016    async fn finalize_into_open_region_must_rebuild() {
2017        let pat = Pattern::Concat(vec![
2018            Pattern::Var("a".into()),
2019            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
2020        ]);
2021        let nfa = Nfa::compile(&pat);
2022        let rows = from_str("abb");
2023        let matcher = SetMatcher::new(rows.clone());
2024
2025        let mut inc =
2026            IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), SkipMode::PastLastRow);
2027        inc.advance(
2028            &ss(&[0, 1, 2]),
2029            &matcher,
2030            &mut ScanBudget::unlimited(),
2031            false,
2032        )
2033        .await
2034        .unwrap();
2035        // Trailing greedy match (0,3) stays alive at the boundary: nothing frozen.
2036        assert_eq!(inc.frozen(), 0);
2037        // seq 2 sits at position 2, past the frozen prefix (next_pos == 0): decline to rebase.
2038        assert!(matches!(
2039            inc.finalize_evicted_prefix(Seq(2)),
2040            Finalized::MustRebuild
2041        ));
2042    }
2043
2044    /// (Task 7, Step 1) After `finalize_evicted_prefix` evicts a frozen match, the matcher never
2045    /// revisits its rows: subsequent `advance`s extend only the *surviving* matches, and a
2046    /// `diff_provisional` against the executor's pruned diff base (the finalized start dropped)
2047    /// emits no op touching that start.
2048    ///
2049    /// The pattern is the greedy `a b+`, whose trailing quantifier *would* keep swallowing later
2050    /// `b`s if a match were still open — the concrete "later rows would have extended it under
2051    /// `PastLastRow`" shape from the brief. It cannot re-extend the finalized `a b` here, and that is
2052    /// the freezing invariant, not luck: a match only freezes once its whole scan region is dead at
2053    /// the boundary, and a position dead at a boundary stays dead at every larger boundary, so no
2054    /// appended row can revive it. Finalization then drains the frozen match's rows from
2055    /// `seq_index` and rebases the scan cursor past them, so the later `b` attaches to the surviving
2056    /// second match instead. This locks in the property Task 7's watermark emit-before-finalize
2057    /// relies on: a finalized match is a permanent result the diff base must forget without a
2058    /// retraction, and later input can neither resurrect nor mutate it.
2059    #[tokio::test]
2060    async fn finalize_evicts_then_later_rows_never_revisit_finalized_match() {
2061        let pat = Pattern::Concat(vec![
2062            Pattern::Var("a".into()),
2063            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
2064        ]);
2065        let nfa = Nfa::compile(&pat);
2066        let skip = SkipMode::PastLastRow;
2067
2068        // 0:a 1:b 2:a 3:b -> the first `a b` = (0,2) freezes (its region goes dead at the boundary
2069        // once the second match's `a` at position 2 breaks the greedy `b+`); the trailing (2,4)
2070        // stays open at the boundary and does not freeze.
2071        let pre = from_str("abab");
2072        let m_pre = SetMatcher::new(pre.clone());
2073        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2074        inc.advance(
2075            &ss(&[0, 1, 2, 3]),
2076            &m_pre,
2077            &mut ScanBudget::unlimited(),
2078            false,
2079        )
2080        .await
2081        .unwrap();
2082        assert_eq!(
2083            provisional_triples(&inc),
2084            vec![(0, 2, labels(&["a", "b"])), (2, 4, labels(&["a", "b"]))]
2085        );
2086        assert_eq!(inc.frozen(), 1); // only the first match froze; the trailing one is still open
2087
2088        // Executor-side diff base: everything currently provisional has been emitted (one row per
2089        // match, keyed by its start seq so an op referencing the finalized start is detectable).
2090        let base: Vec<(SeqMatch, OwnedRow)> = inc
2091            .provisional()
2092            .iter()
2093            .map(|m| (m.clone(), orow(m.start_seq.0)))
2094            .collect();
2095
2096        // Finalize before seq 2: evict [0:a, 1:b], returning the frozen (0,2). The executor prunes
2097        // the finalized start from its base without a retraction (a finalized match is permanent).
2098        let removed = finalize_rebased(&mut inc, Seq(2));
2099        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "b"]))]);
2100        assert_eq!(provisional_triples(&inc), vec![(2, 4, labels(&["a", "b"]))]);
2101        let mut base_pruned = base.clone();
2102        base_pruned.retain(|(m, _)| !removed.iter().any(|fm| fm.start_seq == m.start_seq));
2103
2104        // Feed a later `b` (seq 4). If the matcher revisited the evicted `a b`, the greedy `b+`
2105        // would extend it to `a b b`; instead its rows are gone and the surviving second match
2106        // (seq 2) grows to `a b b` = (2,5). The matcher indexes the rebased surviving buffer: old
2107        // positions 2,3 sit at 0,1 (rows {a},{b}) and seq 4 lands at position 2.
2108        let tail = from_str("abb");
2109        let m_tail = SetMatcher::new(tail.clone());
2110        inc.advance(&ss(&[4]), &m_tail, &mut ScanBudget::unlimited(), false)
2111            .await
2112            .unwrap();
2113        assert_eq!(
2114            provisional_triples(&inc),
2115            vec![(2, 5, labels(&["a", "b", "b"]))]
2116        );
2117        assert!(
2118            inc.provisional().iter().all(|m| m.start_seq != Seq(0)),
2119            "finalized match start must never be revisited"
2120        );
2121
2122        // The diff against the pruned base is exactly the surviving match's revision — a
2123        // Delete/Insert pair of its row (orow(2), extent 4 -> 5) — and nothing else; in particular
2124        // no op references the finalized start (whose base row was orow(0)).
2125        let new_emitted: Vec<(SeqMatch, OwnedRow)> = inc
2126            .provisional()
2127            .iter()
2128            .map(|m| (m.clone(), orow(m.start_seq.0)))
2129            .collect();
2130        let ops = diff_provisional(&base_pruned, &new_emitted);
2131        assert_eq!(ops, vec![(Op::Delete, orow(2)), (Op::Insert, orow(2))]);
2132    }
2133
2134    /// The overlapping skip modes now rebase across a consumed straddling match instead of forcing a
2135    /// rebuild. With `SKIP TO NEXT ROW` the resume point precedes the match end (`resume == start + 1
2136    /// < end`), so a FROZEN match's span can extend past the frozen prefix. `pattern (a a)` over
2137    /// three qualifying rows: (0,2) freezes with a frozen prefix of 1 — position 0 is dead (the
2138    /// pattern is exactly two rows) — while (1,3) ends at the boundary and stays alive. The
2139    /// executor's eviction boundary is the first alive position (1, exactly the frozen prefix
2140    /// `next_pos`), and the frozen (0,2) straddles it: its start row (seq 0) is evicted, so (0,2) is
2141    /// consumed (final, already emitted) and dropped. Because the boundary sits at `next_pos`, no
2142    /// frozen match survives and `next_pos` rebases to 0, so the surviving suffix is re-derived from
2143    /// scratch — `provisional()` then equals a fresh scan over the survivors (checked here).
2144    #[tokio::test]
2145    async fn finalize_under_to_next_row_rebases_consuming_straddler() {
2146        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("a".into())]);
2147        let nfa = Nfa::compile(&pat);
2148        let skip = SkipMode::ToNextRow;
2149        let rows = from_str("aaa");
2150        let matcher = SetMatcher::new(rows.clone());
2151
2152        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2153        inc.advance(
2154            &ss(&[0, 1, 2]),
2155            &matcher,
2156            &mut ScanBudget::unlimited(),
2157            false,
2158        )
2159        .await
2160        .unwrap();
2161        // Overlapping matches (0,2) and (1,3); only (0,2) froze, and the frozen prefix (its resume
2162        // point, 1) sits strictly inside its span [0, 2).
2163        assert_eq!(
2164            provisional_triples(&inc),
2165            vec![(0, 2, labels(&["a", "a"])), (1, 3, labels(&["a", "a"]))]
2166        );
2167        assert_eq!(inc.frozen(), 1);
2168        assert_eq!(inc.frozen_prefix_len(), 1);
2169        // The executor-shaped call: evict before the first alive position (seq 1 == next_pos). The
2170        // frozen (0,2) straddles the boundary but its start is evicted, so it is consumed and the
2171        // matcher rebases in place rather than declining.
2172        let removed = finalize_rebased(&mut inc, Seq(1));
2173        assert_eq!(seq_triples(&removed), vec![(0, 2, labels(&["a", "a"]))]);
2174        // Post-finalize: only the surviving match (1,3) remains, and it equals a fresh batch scan
2175        // over the surviving rows `full[1..]` (positions shifted up by the one evicted row).
2176        assert_eq!(provisional_triples(&inc), vec![(1, 3, labels(&["a", "a"]))]);
2177        let fresh: Vec<(usize, usize, Vec<String>)> = batch_triples(&nfa, &skip, &rows[1..])
2178            .await
2179            .into_iter()
2180            .map(|(s, e, ls)| (s + 1, e + 1, ls))
2181            .collect();
2182        assert_eq!(provisional_triples(&inc), fresh);
2183    }
2184
2185    /// Dropping the matcher and rebuilding from the surviving rows — the [`Finalized::MustRebuild`]
2186    /// fallback the executor still takes when finalize declines (e.g. a whole-buffer drain, or a
2187    /// WITHIN-expired boundary past the frozen prefix) — remains oracle-correct: a FRESH matcher fed
2188    /// the surviving rows equals the batch answer over them, and its union with the evicted
2189    /// (already-emitted/finalized) match equals the batch answer over the full input. (The specific
2190    /// `TO NEXT ROW` shape here now *rebases* through the executor — see the test above — but the
2191    /// rebuild path this exercises is still reached on the declined shapes and must stay sound.)
2192    #[tokio::test]
2193    async fn to_next_row_drop_and_rebuild_across_eviction_equals_batch() {
2194        let pat = Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("a".into())]);
2195        let nfa = Nfa::compile(&pat);
2196        let skip = SkipMode::ToNextRow;
2197
2198        // Matches (0,2) frozen, (1,3) boundary-held.
2199        let full = from_str("aaa");
2200        let m_full = SetMatcher::new(full.clone());
2201        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2202        inc.advance(
2203            &ss(&[0, 1, 2]),
2204            &m_full,
2205            &mut ScanBudget::unlimited(),
2206            false,
2207        )
2208        .await
2209        .unwrap();
2210        assert_eq!(
2211            provisional_triples(&inc),
2212            vec![(0, 2, labels(&["a", "a"])), (1, 3, labels(&["a", "a"]))]
2213        );
2214        // Model the drop-and-rebuild fallback directly: take the evicted match, drop the matcher,
2215        // and rebuild from the surviving rows — exactly what the executor does when finalize returns
2216        // `MustRebuild`. The evicted (0,2) was already delivered (emitted under EOWC; pruned from the
2217        // diff base without a retract under EOU).
2218        let evicted = provisional_triples(&inc)[0].clone();
2219        drop(inc);
2220
2221        // Rebuild: a fresh matcher fed the surviving rows (seqs 1, 2), with the matcher indexing
2222        // the rebased surviving buffer `full[1..]` — exactly what the executor's next visit does.
2223        let m_tail = SetMatcher::new(full[1..].to_vec());
2224        let mut rebuilt = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2225        rebuilt
2226            .advance(&ss(&[1, 2]), &m_tail, &mut ScanBudget::unlimited(), false)
2227            .await
2228            .unwrap();
2229
2230        // Oracle over the surviving rows (batch positions shifted by the eviction offset to line up
2231        // with the surviving seqs).
2232        let shifted_batch: Vec<(usize, usize, Vec<String>)> =
2233            batch_triples(&nfa, &skip, &full[1..])
2234                .await
2235                .into_iter()
2236                .map(|(s, e, ls)| (s + 1, e + 1, ls))
2237                .collect();
2238        assert_eq!(provisional_triples(&rebuilt), shifted_batch);
2239        assert_eq!(
2240            provisional_triples(&rebuilt),
2241            vec![(1, 3, labels(&["a", "a"]))]
2242        );
2243
2244        // Union of the evicted match and the rebuilt provisional set equals the batch answer over
2245        // the full input — nothing lost, nothing duplicated across the eviction.
2246        let mut union = vec![evicted];
2247        union.extend(provisional_triples(&rebuilt));
2248        assert_eq!(union, batch_triples(&nfa, &skip, &full).await);
2249    }
2250
2251    /// The `refresh_matcher` over-feed rollback shape (see `executor.rs`): under emit-on-update the
2252    /// whole buffer is fed at a barrier, then the watermark's eviction visit narrows back to the
2253    /// safe prefix — `truncate_from_seq` at the first over-fed row rolls the tail back, but it also
2254    /// drops the provisional matches over the *retained* fed suffix, and with nothing left to
2255    /// re-feed no `advance` follows to re-derive them. `rescan` must restore the invariant:
2256    /// `provisional()` equals the batch answer over the safe prefix, labels included.
2257    #[tokio::test]
2258    async fn overfeed_rollback_rescan_equals_batch_over_safe_prefix() {
2259        let pat = Pattern::Concat(vec![
2260            Pattern::Var("a".into()),
2261            quant(Pattern::Var("b".into()), Quantifier::Plus, false),
2262        ]);
2263        let nfa = Nfa::compile(&pat);
2264        let skip = SkipMode::PastLastRow;
2265
2266        // 0:a 1:b 2:a 3:b 4:b — whole-buffer matches (0,2) (frozen: the `a` at 2 breaks the greedy
2267        // `b+`) and (2,5) (trailing, provisional).
2268        let full = from_str("ababb");
2269        let m_full = SetMatcher::new(full.clone());
2270        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2271        inc.advance(
2272            &ss(&[0, 1, 2, 3, 4]),
2273            &m_full,
2274            &mut ScanBudget::unlimited(),
2275            false,
2276        )
2277        .await
2278        .unwrap();
2279        assert_eq!(
2280            provisional_triples(&inc),
2281            vec![
2282                (0, 2, labels(&["a", "b"])),
2283                (2, 5, labels(&["a", "b", "b"]))
2284            ]
2285        );
2286        assert_eq!(inc.frozen(), 1);
2287
2288        // Roll back to the safe prefix [0, 4): truncate at the first over-fed row's seq. This drops
2289        // the provisional (2,5) even though rows 2 and 3 stay fed — the reason a rescan (and not a
2290        // re-feed, which would double-enter the retained rows in `seq_index`) must follow.
2291        let m_safe = SetMatcher::new(full[..4].to_vec());
2292        inc.truncate_from_seq(Seq(4), &m_safe, &mut ScanBudget::unlimited(), false)
2293            .await
2294            .unwrap();
2295        assert_eq!(provisional_triples(&inc), vec![(0, 2, labels(&["a", "b"]))]);
2296
2297        // Rescan re-derives the dropped suffix matches in place: `provisional()` now equals the
2298        // batch answer over the safe prefix — the invariant the executor's eviction pass reads.
2299        inc.rescan(&m_safe, &mut ScanBudget::unlimited(), false)
2300            .await
2301            .unwrap();
2302        assert_eq!(
2303            provisional_triples(&inc),
2304            batch_triples(&nfa, &skip, &full[..4]).await
2305        );
2306        assert_eq!(
2307            provisional_triples(&inc),
2308            vec![(0, 2, labels(&["a", "b"])), (2, 4, labels(&["a", "b"]))]
2309        );
2310
2311        // Re-feeding the rolled-back row (the next barrier's whole-buffer feed) still equals the
2312        // batch over the whole buffer — the rollback+rescan corrupted nothing.
2313        inc.advance(&ss(&[4]), &m_full, &mut ScanBudget::unlimited(), false)
2314            .await
2315            .unwrap();
2316        assert_eq!(
2317            provisional_triples(&inc),
2318            batch_triples(&nfa, &skip, &full).await
2319        );
2320    }
2321
2322    // ---- Randomized operation-sequence oracle (spec §8.1) ---------------------------------------
2323    //
2324    // The targeted tests above pin specific shapes; this property test closes the randomized-oracle
2325    // gap by driving *arbitrary* interleavings of the matcher's operations against the batch
2326    // reference. For each seed it draws a random pattern (over a small grammar), a random
2327    // `AFTER MATCH SKIP` mode (all four), random satisfied-set rows, and a random op sequence
2328    // (`advance` in in-order chunks incl. empty, `truncate_from_seq` at valid and never-fed seqs,
2329    // `finalize_evicted_prefix` at executor-reachable boundaries). After *every* op it asserts the
2330    // core invariant — `provisional()` (full `(start, end, labels)` triples) equals a from-scratch
2331    // batch `find_matches_dynamic` over the currently-live rows for the same skip mode. Seeds are
2332    // fixed (`0..N`), so CI is deterministic; everything is in-memory, so the sweep runs in seconds.
2333
2334    /// Build a random pattern over `vars`, shrinking toward a leaf as `budget` decreases so the
2335    /// compiled NFA stays small (≤64 states → bitmask visited-set) and the batch rescans stay cheap.
2336    fn gen_pattern(rng: &mut SmallRng, vars: &[&str], budget: usize) -> Pattern {
2337        let pick_var =
2338            |rng: &mut SmallRng| Pattern::Var(vars[rng.random_range(0..vars.len())].into());
2339        // Out of budget → leaf; otherwise choose a construct.
2340        let choice = if budget == 0 {
2341            0
2342        } else {
2343            rng.random_range(0..4)
2344        };
2345        match choice {
2346            // Concatenation of 2–3 sub-patterns.
2347            1 => Pattern::Concat(
2348                (0..rng.random_range(2..=3))
2349                    .map(|_| gen_pattern(rng, vars, budget - 1))
2350                    .collect(),
2351            ),
2352            // Alternation of 2–3 sub-patterns.
2353            2 => Pattern::Alt(
2354                (0..rng.random_range(2..=3))
2355                    .map(|_| gen_pattern(rng, vars, budget - 1))
2356                    .collect(),
2357            ),
2358            // A quantified sub-pattern (greedy or reluctant), all four quantifier shapes.
2359            3 => {
2360                let inner = gen_pattern(rng, vars, budget - 1);
2361                let q = match rng.random_range(0..4) {
2362                    0 => Quantifier::Star,
2363                    1 => Quantifier::Plus,
2364                    2 => Quantifier::Question,
2365                    _ => {
2366                        let min = rng.random_range(0..=2);
2367                        let max = rng.random_bool(0.5).then(|| min + rng.random_range(0..=2));
2368                        Quantifier::Range { min, max }
2369                    }
2370                };
2371                Pattern::Quantified(Box::new(inner), q, rng.random_bool(0.5))
2372            }
2373            // Leaf variable.
2374            _ => pick_var(rng),
2375        }
2376    }
2377
2378    /// A random `AFTER MATCH SKIP` mode; the variable-targeted modes bind a symbol from `vars` (a
2379    /// valid symbol name — `next_pos` degrades to `PAST LAST ROW` if that symbol is absent from a
2380    /// given match, which is itself a shape worth exercising).
2381    fn gen_skip(rng: &mut SmallRng, vars: &[&str]) -> SkipMode {
2382        match rng.random_range(0..4) {
2383            0 => SkipMode::PastLastRow,
2384            1 => SkipMode::ToNextRow,
2385            2 => SkipMode::ToFirst(vars[rng.random_range(0..vars.len())].into()),
2386            _ => SkipMode::ToLast(vars[rng.random_range(0..vars.len())].into()),
2387        }
2388    }
2389
2390    /// The core oracle assertion: the incremental matcher's provisional set equals a from-scratch
2391    /// batch scan over the currently-live rows `full_rows[evicted..fed]`. Seqs equal original
2392    /// positions, so a live match's seq-anchored triple is its batch (position-anchored) triple
2393    /// shifted up by the evicted prefix length.
2394    async fn assert_matches_batch(
2395        inc: &IncrementalMatcher,
2396        nfa: &Nfa,
2397        skip: &SkipMode,
2398        full_rows: &[BTreeSet<String>],
2399        evicted: usize,
2400        fed: usize,
2401        ctx: &str,
2402    ) {
2403        let batch: Vec<(usize, usize, Vec<String>)> =
2404            batch_triples(nfa, skip, &full_rows[evicted..fed])
2405                .await
2406                .into_iter()
2407                .map(|(s, e, ls)| (s + evicted, e + evicted, ls))
2408                .collect();
2409        assert_eq!(provisional_triples(inc), batch, "oracle divergence {ctx}");
2410    }
2411
2412    /// The same operation sweep under a STARVED budget. Two properties, neither of which the
2413    /// unlimited oracle can reach:
2414    ///
2415    /// 1. A truncated matcher is INCOMPLETE, never wrong — its provisional set is a leftmost
2416    ///    *prefix* of the batch answer. The scan pulls matches in preference order and a budget
2417    ///    abort inside a higher-preference subtree propagates out rather than falling through to a
2418    ///    lower-preference alternative, so starvation can drop a suffix but can never fabricate a
2419    ///    match, reorder two, or steal a row into a different variable.
2420    /// 2. Starvation is always RECOVERABLE: one re-derive with budget restores exact equality.
2421    ///    This is precisely the contract the executor's watermark arm relies on when it refreshes a
2422    ///    partition before deciding anything, and nothing else tests it.
2423    #[tokio::test]
2424    async fn randomized_operation_sequence_oracle_under_a_starved_budget() {
2425        const SEEDS: u64 = 200;
2426        const OPS: usize = 30;
2427        let var_pool: [&[&str]; 3] = [&["a", "b"], &["a", "b", "c"], &["a", "b", "c", "d"]];
2428        // Guards against the test passing for the wrong reason: budgets small enough to matter must
2429        // actually be exhausted, and a truncated state must actually differ from the batch answer at
2430        // least sometimes. Without these a future change to the budget sizing could silently turn
2431        // this into the unlimited oracle run twice.
2432        let mut starved_ops = 0usize;
2433        let mut strictly_shorter = 0usize;
2434
2435        for seed in 0..SEEDS {
2436            let mut rng = SmallRng::seed_from_u64(seed ^ 0x5741_2764_u64);
2437            let vars = var_pool[rng.random_range(0..var_pool.len())];
2438            let pattern = gen_pattern(&mut rng, vars, 3);
2439            let nfa = Nfa::compile(&pattern);
2440            let skip = gen_skip(&mut rng, vars);
2441
2442            let n_rows = rng.random_range(3..=7);
2443            let full_rows: Vec<BTreeSet<String>> = (0..n_rows)
2444                .map(|_| {
2445                    vars.iter()
2446                        .filter(|_| rng.random_bool(0.6))
2447                        .map(|v| (*v).to_owned())
2448                        .collect()
2449                })
2450                .collect();
2451
2452            let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2453            let mut fed = 0usize;
2454            let evicted = 0usize;
2455
2456            for op in 0..OPS {
2457                let matcher = SetMatcher::new(full_rows[evicted..].to_vec());
2458                let ctx = format!("seed {seed} op {op} (starved)");
2459                // 0 is included deliberately: a scan that dies on entry, having decided nothing.
2460                let mut budget = ScanBudget::new(rng.random_range(0..=8));
2461
2462                match rng.random_range(0..2) {
2463                    0 => {
2464                        let remaining = full_rows.len() - fed;
2465                        let chunk = if remaining == 0 {
2466                            0
2467                        } else {
2468                            rng.random_range(0..=remaining.min(3))
2469                        };
2470                        let seqs: Vec<Seq> = (fed..fed + chunk).map(|i| Seq(i as i64)).collect();
2471                        inc.advance(&seqs, &matcher, &mut budget, false)
2472                            .await
2473                            .unwrap();
2474                        fed += chunk;
2475                    }
2476                    _ => {
2477                        let k = rng.random_range(0..=fed + 2);
2478                        inc.truncate_from_seq(Seq(k as i64), &matcher, &mut budget, false)
2479                            .await
2480                            .unwrap();
2481                        if (evicted..fed).contains(&k) {
2482                            fed = k;
2483                        }
2484                        inc.rescan(&matcher, &mut budget, false).await.unwrap();
2485                    }
2486                }
2487
2488                // (1) prefix, not equality.
2489                let batch: Vec<(usize, usize, Vec<String>)> =
2490                    batch_triples(&nfa, &skip, &full_rows[evicted..fed]).await;
2491                let starved = provisional_triples(&inc);
2492                if budget.hit {
2493                    starved_ops += 1;
2494                }
2495                if starved.len() < batch.len() {
2496                    strictly_shorter += 1;
2497                }
2498                assert!(
2499                    batch.starts_with(&starved),
2500                    "a starved matcher must hold a PREFIX of the batch answer {ctx}\n  starved: {starved:?}\n  batch:   {batch:?}"
2501                );
2502
2503                // (2) one budgeted re-derive restores the exact invariant.
2504                inc.refresh(&matcher, &mut ScanBudget::unlimited(), false)
2505                    .await
2506                    .unwrap();
2507                assert_matches_batch(&inc, &nfa, &skip, &full_rows, evicted, fed, &ctx).await;
2508            }
2509        }
2510
2511        assert!(
2512            starved_ops > 0,
2513            "no operation exhausted its budget — the sweep never reached the truncation paths it \
2514             exists to cover"
2515        );
2516        assert!(
2517            strictly_shorter > 0,
2518            "every starved op still matched the batch answer exactly — truncation never actually \
2519             withheld a match, so the prefix property was asserted vacuously"
2520        );
2521    }
2522
2523    /// A chain pattern with a cycle behind it (`a{600} b*`), so the acyclic shortcut does not
2524    /// apply and freezing a match of `L` rows really runs one liveness walk per position of its
2525    /// region, each walking up to `L` rows — Θ(L²) steps, which exceeds one visit's budget
2526    /// (600 × ~1800 > 2^20). The freeze must carry its proven-dead prefix across visits so a
2527    /// bounded number of refreshes converges; restarting it at `next_pos` every visit never would
2528    /// (the budget died at the same position each time, and the region never froze — the
2529    /// permanent, non-self-healing degradation the depth cap used to cause by other means).
2530    #[tokio::test]
2531    async fn freeze_resumes_across_budget_exhausted_visits() {
2532        const N: usize = 600;
2533        const ROWS: usize = 1300;
2534        const BUDGET: usize = 1 << 20;
2535        let a_n = Pattern::Quantified(
2536            Box::new(Pattern::Var("a".into())),
2537            Quantifier::Range {
2538                min: N as u32,
2539                max: Some(N as u32),
2540            },
2541            false,
2542        );
2543        let b_star =
2544            Pattern::Quantified(Box::new(Pattern::Var("b".into())), Quantifier::Star, false);
2545        let nfa = Nfa::compile(&Pattern::Concat(vec![a_n, b_star]));
2546        assert_eq!(
2547            nfa.max_match_rows(),
2548            None,
2549            "the test needs a cyclic automaton"
2550        );
2551        let matcher = SetMatcher::new(vec![BTreeSet::from(["a".to_owned()]); ROWS]);
2552        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa), SkipMode::PastLastRow);
2553
2554        let seqs: Vec<Seq> = (0..ROWS as i64).map(Seq).collect();
2555        let mut budget = ScanBudget::new(BUDGET);
2556        inc.advance(&seqs, &matcher, &mut budget, true)
2557            .await
2558            .unwrap();
2559        assert!(
2560            budget.hit,
2561            "the test needs a freeze that outruns one visit's budget"
2562        );
2563        assert_eq!(inc.frozen(), 0, "the first region did not finish freezing");
2564        assert!(
2565            !inc.is_incomplete(),
2566            "the tail scan itself completed; only the freeze was cut short"
2567        );
2568        assert!(inc.needs_refresh(), "a truncated freeze asks for a refresh");
2569        let proven_after_first_visit = inc.dead_prefix_end();
2570        assert!(
2571            proven_after_first_visit > 0,
2572            "the first visit proved nothing dead"
2573        );
2574
2575        let mut visits = 1;
2576        while inc.needs_refresh() {
2577            assert!(visits < 5, "the freeze did not converge in {visits} visits");
2578            budget = ScanBudget::new(BUDGET);
2579            inc.refresh(&matcher, &mut budget, true).await.unwrap();
2580            visits += 1;
2581        }
2582        assert!(visits >= 2, "convergence must have needed the resume");
2583        assert!(inc.dead_prefix_end() > proven_after_first_visit);
2584
2585        // (0,600) froze: every position of its region is dead. (600,1200) is provisional and
2586        // stays so while every row is an `a`: from 700 on, `a{600}` reaches the boundary before
2587        // it can accept — alive, not frozen — so the proven-dead prefix ends exactly there.
2588        assert_eq!(inc.frozen(), 1);
2589        assert_eq!(inc.resume_pos(), N);
2590        assert_eq!(inc.dead_prefix_end(), 700);
2591        let spans: Vec<(i64, i64)> = inc
2592            .provisional()
2593            .iter()
2594            .map(|m| (m.start_seq.0, m.end_seq.0))
2595            .collect();
2596        assert_eq!(spans, vec![(0, 600), (600, 1200)]);
2597    }
2598
2599    /// A run one row short of a match, then a non-matching row: under `a{1000}` (the binder's
2600    /// limit) every start in the run walks to the break and dies, Θ(r²) per rescan — for r = 999
2601    /// that is ~1.5M steps, more than one visit's budget. The finder must remember the starts it
2602    /// proved matchless so the next rescan begins past them; without that memory every visit
2603    /// re-walks the same dead starts, exhausts at the same place, and the partition never
2604    /// completes a rescan again.
2605    #[tokio::test]
2606    async fn finder_resumes_past_starts_proven_matchless() {
2607        const N: usize = 1000;
2608        const RUN: usize = N - 1;
2609        const BUDGET: usize = 1 << 20;
2610        let nfa = Nfa::compile(&Pattern::Quantified(
2611            Box::new(Pattern::Var("a".into())),
2612            Quantifier::Range {
2613                min: N as u32,
2614                max: Some(N as u32),
2615            },
2616            false,
2617        ));
2618        let a = BTreeSet::from(["a".to_owned()]);
2619        let x = BTreeSet::from(["x".to_owned()]);
2620        let rows: Vec<BTreeSet<String>> = std::iter::repeat_n(a.clone(), RUN)
2621            .chain(std::iter::once(x))
2622            .chain(std::iter::repeat_n(a, N))
2623            .collect();
2624        let n_rows = rows.len();
2625        let matcher = SetMatcher::new(rows);
2626        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa), SkipMode::PastLastRow);
2627
2628        let seqs: Vec<Seq> = (0..n_rows as i64).map(Seq).collect();
2629        let mut budget = ScanBudget::new(BUDGET);
2630        inc.advance(&seqs, &matcher, &mut budget, true)
2631            .await
2632            .unwrap();
2633        assert!(
2634            budget.hit,
2635            "the test needs a rescan that outruns one visit's budget"
2636        );
2637        assert!(inc.is_incomplete());
2638        assert!(
2639            inc.provisional().is_empty(),
2640            "the finder never got past the break"
2641        );
2642        let proven_after_first_visit = inc.dead_prefix_end();
2643        assert!(
2644            proven_after_first_visit > 0,
2645            "the truncated rescan must still have proved a prefix of starts matchless"
2646        );
2647
2648        let mut visits = 1;
2649        while inc.needs_refresh() {
2650            assert!(visits < 4, "the rescan did not converge in {visits} visits");
2651            budget = ScanBudget::new(BUDGET);
2652            inc.refresh(&matcher, &mut budget, true).await.unwrap();
2653            visits += 1;
2654        }
2655        assert_eq!(visits, 2, "one resume should finish the run");
2656
2657        // The match after the break is found; everything before it is proven dead (the run and
2658        // the break row are matchless), and the acyclic shortcut proves the match's own region
2659        // dead up to the first position that can still reach the boundary.
2660        let spans: Vec<(i64, i64)> = inc
2661            .provisional()
2662            .iter()
2663            .map(|m| (m.start_seq.0, m.end_seq.0))
2664            .collect();
2665        assert_eq!(spans, vec![((RUN + 1) as i64, (RUN + 1 + N) as i64)]);
2666        assert_eq!(inc.dead_prefix_end(), n_rows - N);
2667        assert_eq!(
2668            inc.frozen(),
2669            0,
2670            "positions from {} on are alive",
2671            n_rows - N
2672        );
2673        assert!(!inc.needs_refresh());
2674    }
2675
2676    /// A successful match advances `MatchScan::next_start` without advancing `matchless_upto`.
2677    /// With overlapping matches, a visit can therefore spend its whole budget after returning a
2678    /// long prefix while proving no start matchless. Refreshes must retain that prefix and resume
2679    /// at the saved scan cursor; restarting at `matchless_upto == 0` repeats the same matches
2680    /// forever and leaves the partition permanently incomplete.
2681    #[tokio::test]
2682    async fn finder_resumes_after_budget_truncated_overlapping_matches() {
2683        const RUN: usize = 80;
2684        const BUDGET: usize = 1024;
2685        let nfa = Nfa::compile(&quant(Pattern::Var("a".into()), Quantifier::Plus, false));
2686        let rows: Vec<BTreeSet<String>> =
2687            std::iter::repeat_n(BTreeSet::from(["a".to_owned()]), RUN)
2688                .chain(std::iter::once(BTreeSet::from(["x".to_owned()])))
2689                .collect();
2690        let matcher = SetMatcher::new(rows);
2691        let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa), SkipMode::ToNextRow);
2692        let seqs: Vec<Seq> = (0..=RUN as i64).map(Seq).collect();
2693
2694        let mut budget = ScanBudget::new(BUDGET);
2695        inc.advance(&seqs, &matcher, &mut budget, true)
2696            .await
2697            .unwrap();
2698        assert!(budget.hit, "the first scan must be truncated");
2699        assert!(inc.is_incomplete());
2700        let first_prefix_len = inc.provisional().len();
2701        assert!(
2702            first_prefix_len > 0 && first_prefix_len < RUN,
2703            "the first visit must find a strict non-empty prefix"
2704        );
2705
2706        let mut visits = 1;
2707        while inc.needs_refresh() {
2708            assert!(visits < 32, "the scan/freeze did not converge");
2709            budget = ScanBudget::new(BUDGET);
2710            inc.refresh(&matcher, &mut budget, true).await.unwrap();
2711            visits += 1;
2712        }
2713
2714        assert!(visits > 1, "the test must exercise cursor resumption");
2715        assert_eq!(inc.provisional().len(), RUN);
2716        assert_eq!(inc.frozen(), RUN);
2717        assert_eq!(inc.resume_pos(), RUN);
2718    }
2719
2720    #[tokio::test]
2721    async fn randomized_operation_sequence_oracle() {
2722        // ~200 seeds × ~30 ops. All in-memory over ≤7-row buffers, so the whole sweep is a few ms.
2723        const SEEDS: u64 = 200;
2724        const OPS: usize = 30;
2725        let var_pool: [&[&str]; 3] = [&["a", "b"], &["a", "b", "c"], &["a", "b", "c", "d"]];
2726
2727        for seed in 0..SEEDS {
2728            let mut rng = SmallRng::seed_from_u64(seed);
2729            let vars = var_pool[rng.random_range(0..var_pool.len())];
2730            let pattern = gen_pattern(&mut rng, vars, 3);
2731            let nfa = Nfa::compile(&pattern);
2732            let skip = gen_skip(&mut rng, vars);
2733
2734            // Random satisfied-set rows: each var present with prob ~0.6 (empty rows allowed).
2735            let n_rows = rng.random_range(3..=7);
2736            let full_rows: Vec<BTreeSet<String>> = (0..n_rows)
2737                .map(|_| {
2738                    vars.iter()
2739                        .filter(|_| rng.random_bool(0.6))
2740                        .map(|v| (*v).to_owned())
2741                        .collect()
2742                })
2743                .collect();
2744
2745            let mut inc = IncrementalMatcher::new(std::sync::Arc::new(nfa.clone()), skip.clone());
2746            let mut fed = 0usize; // rows fed so far (== next seq to mint, since seq == position)
2747            let mut evicted = 0usize; // rows finalized off the front of the live buffer
2748
2749            for op in 0..OPS {
2750                // The candidate matcher over the currently-live rows (positions are 0-based from the
2751                // evicted boundary, exactly as the executor's post-eviction buffer is).
2752                let matcher = SetMatcher::new(full_rows[evicted..].to_vec());
2753                let n_live = fed - evicted;
2754                let ctx = format!("seed {seed} op {op}");
2755
2756                match rng.random_range(0..3) {
2757                    // advance: feed the next in-order chunk (possibly empty).
2758                    0 => {
2759                        let remaining = full_rows.len() - fed;
2760                        let chunk = if remaining == 0 {
2761                            0
2762                        } else {
2763                            rng.random_range(0..=remaining.min(3))
2764                        };
2765                        let seqs: Vec<Seq> = (fed..fed + chunk).map(|i| Seq(i as i64)).collect();
2766                        inc.advance(&seqs, &matcher, &mut ScanBudget::unlimited(), false)
2767                            .await
2768                            .unwrap();
2769                        fed += chunk;
2770                    }
2771                    // truncate + rescan: roll back at a random seq (valid fed, already-evicted, or
2772                    // never-fed), then rescan to re-derive the retained tail in place — the executor's
2773                    // out-of-order / over-feed rollback shape. A never-fed seq is a no-op.
2774                    1 => {
2775                        let lo = evicted.saturating_sub(1);
2776                        let hi = fed + 2;
2777                        let k = rng.random_range(lo..=hi);
2778                        inc.truncate_from_seq(
2779                            Seq(k as i64),
2780                            &matcher,
2781                            &mut ScanBudget::unlimited(),
2782                            false,
2783                        )
2784                        .await
2785                        .unwrap();
2786                        if (evicted..fed).contains(&k) {
2787                            fed = k; // rolled the live buffer back to the truncation point
2788                        }
2789                        inc.rescan(&matcher, &mut ScanBudget::unlimited(), false)
2790                            .await
2791                            .unwrap();
2792                    }
2793                    // finalize: mirror the executor's eviction gate. Retain from the first row that is
2794                    // still live at the safe boundary; evict the dead prefix before it. Only attempt
2795                    // when there is a dead prefix and a surviving suffix (`0 < retain_from < n_live`).
2796                    _ => {
2797                        if n_live >= 2 {
2798                            let mut retain_from = n_live;
2799                            for p in 0..n_live {
2800                                if nfa
2801                                    .reaches_boundary_alive(
2802                                        p,
2803                                        n_live,
2804                                        &matcher,
2805                                        &mut ScanBudget::unlimited(),
2806                                        false,
2807                                    )
2808                                    .await
2809                                    .unwrap()
2810                                {
2811                                    retain_from = p;
2812                                    break;
2813                                }
2814                            }
2815                            if retain_from > 0 && retain_from < n_live {
2816                                let boundary = Seq((evicted + retain_from) as i64);
2817                                match inc.finalize_evicted_prefix(boundary) {
2818                                    Finalized::Rebased => evicted += retain_from,
2819                                    // The executor drops the matcher and lets the next visit rebuild
2820                                    // lazily; model that with a fresh matcher fed the surviving rows,
2821                                    // then continue the sequence.
2822                                    Finalized::MustRebuild => {
2823                                        evicted += retain_from;
2824                                        inc = IncrementalMatcher::new(
2825                                            std::sync::Arc::new(nfa.clone()),
2826                                            skip.clone(),
2827                                        );
2828                                        let surv = SetMatcher::new(full_rows[evicted..].to_vec());
2829                                        let seqs: Vec<Seq> =
2830                                            (evicted..fed).map(|i| Seq(i as i64)).collect();
2831                                        inc.advance(
2832                                            &seqs,
2833                                            &surv,
2834                                            &mut ScanBudget::unlimited(),
2835                                            false,
2836                                        )
2837                                        .await
2838                                        .unwrap();
2839                                    }
2840                                }
2841                            }
2842                        }
2843                    }
2844                }
2845
2846                // Invariant after EVERY op.
2847                assert_matches_batch(&inc, &nfa, &skip, &full_rows, evicted, fed, &ctx).await;
2848            }
2849        }
2850    }
2851}