Skip to main content

risingwave_stream/executor/match_recognize/
nfa.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//! Row-pattern NFA for `MATCH_RECOGNIZE`.
16//!
17//! A `Pattern` (the supported v1 subset of the SQL `PATTERN` clause) is compiled to a
18//! Thompson-construction NFA whose labelled transitions are pattern variables. The simulation
19//! consumes a sequence of rows, where each row is summarised by the set of pattern variables whose
20//! `DEFINE` predicate it satisfies, and finds the greedy longest match from a start position
21//! (`ONE ROW PER MATCH` + `AFTER MATCH SKIP PAST LAST ROW`).
22//!
23//! Variable→predicate evaluation and the streaming/state layer live elsewhere; this module is pure
24//! and deterministic so it can be unit-tested without a cluster.
25
26// `BTreeSet` is used only by the test-only reference matchers and the unit tests; the streaming
27// matcher walks transitions directly with [`Visited`] guards (a `u64` bitmask for automata of at
28// most 64 states, a `HashSet` fallback beyond that).
29#[cfg(test)]
30use std::collections::BTreeSet;
31use std::collections::HashSet;
32
33use crate::executor::error::StreamExecutorResult;
34
35/// Cursor for [`Nfa::next_match`]: the next start position the scan will try. A fresh scan starts
36/// at 0; [`Nfa::next_match`] advances it by the `AFTER MATCH SKIP` mode on every match returned
37/// (or by one row past a non-matching start), so pulling repeatedly enumerates exactly the match
38/// sequence [`Nfa::find_matches_dynamic`] would collect.
39#[derive(Debug, Default)]
40pub struct MatchScan {
41    next_start: usize,
42    /// End of the contiguous run of starts, from where this scan began, proven MATCHLESS FOREVER:
43    /// their walks found no accept and never reached the boundary, so every path from them died
44    /// on a row below it — and those rows are immutable, so no arrival can revive them. The
45    /// incremental matcher reads this back and begins its next rescan past them.
46    matchless_upto: usize,
47}
48
49impl MatchScan {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// A scan beginning at `start`, with nothing proven yet.
55    pub fn starting_at(start: usize) -> Self {
56        Self {
57            next_start: start,
58            matchless_upto: start,
59        }
60    }
61
62    /// See the `matchless_upto` field.
63    pub fn matchless_upto(&self) -> usize {
64        self.matchless_upto
65    }
66
67    /// The next start the pull loop would explore.
68    ///
69    /// This is NOT a "fully scanned prefix" marker, and must not be used as one: on a hit the cursor
70    /// jumps to `skip.next_pos`, so under `PAST LAST ROW` every start strictly inside the match it
71    /// just returned was never evaluated. What it does guarantee, since the budget fix below, is the
72    /// narrower property that a start whose walk the budget ABORTED is not advanced past — so the
73    /// cursor never claims a verdict the walk did not reach.
74    ///
75    /// No production caller reads this today; it exists for the emit-on-update port, which needs to
76    /// resume a pull, and for the test that pins the abort behaviour.
77    pub fn next_start(&self) -> usize {
78        self.next_start
79    }
80}
81
82/// Per-visit budget on NFA walk steps — predicate evaluations AND edges taken (row consumptions
83/// and ε-transitions) — shared by every walk of one partition visit (matching, eviction liveness,
84/// extension probing). ε-edges are charged deliberately: metering only predicate evaluations left
85/// ε-traversal free, so a large NFA could spend arbitrary CPU per metered evaluation and the budget
86/// was not actually a CPU bound.
87///
88/// The matcher is a backtracking DFS whose worst case is exponential in the pattern for
89/// pathological shapes (`(a? a? … a? b)` over a run of `a`-rows — the classic catastrophic
90/// regex-backtracking family), and [`MAX_PATTERN_NFA_STATES`-style caps bound *space*, not time.
91/// [`Memo`] removes the blowup entirely for path-independent patterns; for the rest, this budget
92/// is the hard backstop: when it runs out, the walk STOPS — it never fakes a verdict (a fabricated
93/// `false` in the liveness walker would evict rows of a live match, the exact bug class the
94/// decision-wait semantics exist to prevent). The caller must treat everything undecided
95/// conservatively (emit nothing more, evict nothing more, report once, retry next watermark), so a
96/// pathological pattern degrades to bounded CPU per visit and an observable report instead of
97/// pinning a compute node.
98#[derive(Debug)]
99pub struct ScanBudget {
100    remaining: usize,
101    /// Set once the budget runs out; sticky for the rest of the visit.
102    pub hit: bool,
103}
104
105impl ScanBudget {
106    pub fn new(evaluations: usize) -> Self {
107        Self {
108            remaining: evaluations,
109            hit: false,
110        }
111    }
112
113    /// Account one edge taken by a walk (an ε-transition or a row consumption). Returns `false` —
114    /// and latches `hit` — once the budget is spent.
115    #[must_use]
116    pub fn step(&mut self) -> bool {
117        self.charge()
118    }
119
120    /// No practical limit — for callers (tests, the collect wrapper) that need the historical
121    /// unbounded behaviour.
122    pub fn unlimited() -> Self {
123        Self::new(usize::MAX)
124    }
125
126    /// Account one predicate evaluation. Returns `false` — and latches `hit` — once exhausted.
127    fn charge(&mut self) -> bool {
128        if self.remaining == 0 {
129            self.hit = true;
130            return false;
131        }
132        self.remaining -= 1;
133        true
134    }
135}
136
137/// Per-start `(state, position)` failure memo for the backtracking walkers.
138///
139/// Soundness: an entry is recorded ONLY for recursion entered through a *consuming* transition —
140/// that recursion always starts with a fresh visited-set, so its outcome is context-free — and
141/// only when the pattern's verdicts are path-independent (no `DEFINE` slot reads the running label
142/// assignment; see the executor's `memoizable` flag). Within one start, a verdict then depends
143/// only on `(var, pos)` (`labels.len()` is `pos - start`, so `WITHIN` and the match-start offset
144/// are position-determined), so a failed `(state, pos)` fails identically on every re-entry. That
145/// re-entry via different consumption prefixes is exactly the exponential blowup; memoizing it
146/// makes a start's scan polynomial. ε-level failures are NOT recorded: they can be artifacts of
147/// the cycle-cutting visited-set and are not context-free.
148struct Memo {
149    /// Failure sets indexed by position offset from the memo's start; one [`Visited`] per position
150    /// (bitmask for small automata, set fallback beyond).
151    failed: Vec<Visited>,
152    n_states: usize,
153    base: usize,
154}
155
156impl Memo {
157    fn new(base: usize, _n_rows: usize, n_states: usize) -> Self {
158        Self {
159            // `slot()` grows on demand; an eager reservation over the whole suffix would malloc
160            // O(suffix) per walk INSTANCE — quadratic traffic per rescan — for walks that mostly
161            // die within a few positions.
162            failed: Vec::new(),
163            n_states,
164            base,
165        }
166    }
167
168    /// The failure set at `pos`, or `None` for a position before the memo's start — which no
169    /// walk can reach (a walk only moves forward from its start). Checked rather than assumed:
170    /// without overflow checks a position below `base` would wrap the index and the growth loop
171    /// below would allocate until the process died, which is worse than either verdict.
172    fn slot(&mut self, pos: usize) -> Option<&mut Visited> {
173        let Some(idx) = pos.checked_sub(self.base) else {
174            crate::consistency::consistency_panic!(
175                pos,
176                base = self.base,
177                "MATCH_RECOGNIZE walk memo asked about a position before its start",
178            );
179            return None;
180        };
181        while self.failed.len() <= idx {
182            self.failed.push(Visited::new(self.n_states));
183        }
184        Some(&mut self.failed[idx])
185    }
186
187    /// Unknown positions are not failed: the walk proceeds as without a memo.
188    fn is_failed(&mut self, state: StateId, pos: usize) -> bool {
189        self.slot(pos).is_some_and(|v| v.contains(state))
190    }
191
192    fn record_failure(&mut self, state: StateId, pos: usize) {
193        if let Some(v) = self.slot(pos) {
194            v.insert(state);
195        }
196    }
197}
198
199/// Decides whether the row at a physical position can be bound to a pattern variable, given the
200/// variables already bound to the earlier rows of the in-progress match. This is how `DEFINE`
201/// predicates are evaluated during matching: a predicate may reference the current row, its physical
202/// neighbours (`PREV`/`NEXT`), and the running values of other pattern variables (e.g. `A.price`),
203/// so membership cannot be precomputed independently of the match path.
204pub trait CandidateMatcher {
205    /// `labels[k]` is the variable bound to the match's `k`-th row; the candidate is the row at
206    /// `pos = match_start + labels.len()`. The returned future is `Send` so the matcher composes
207    /// with the (boxed, `Send`) executor stream.
208    ///
209    /// **Contract for callers:** membership MUST be queried *before* `var` is appended to `labels`,
210    /// so `labels` covers only the already-bound rows and never the candidate. Consequently a matcher
211    /// that resolves running navigation over `var` itself must treat `var` as the implicit trailing
212    /// label. Every caller MUST follow the same rule: [the finder] and [the eviction walker] share one
213    /// matcher, so a caller that pushed first would make the two disagree about which rows satisfy a
214    /// variable — and eviction would then delete rows the matcher still needs.
215    ///
216    /// [the finder]: Nfa::find_matches_dynamic
217    /// [the eviction walker]: Nfa::reaches_boundary_alive
218    fn matches(
219        &self,
220        var: &str,
221        pos: usize,
222        labels: &[String],
223    ) -> impl std::future::Future<Output = StreamExecutorResult<bool>> + Send;
224}
225
226/// A quantifier applied to a sub-pattern. Greedy semantics only (v1).
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum Quantifier {
229    /// `*`
230    Star,
231    /// `+`
232    Plus,
233    /// `?`
234    Question,
235    /// `{n}`, `{n,}`, `{n,m}`, `{,m}`. `min` defaults to 0, `max` is `None` for unbounded.
236    Range { min: u32, max: Option<u32> },
237}
238
239/// The supported v1 subset of a row pattern.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub enum Pattern {
242    /// A pattern variable, e.g. `A`.
243    Var(String),
244    /// Concatenation, e.g. `A B C`.
245    Concat(Vec<Pattern>),
246    /// Alternation, e.g. `A | B`.
247    Alt(Vec<Pattern>),
248    /// A quantified sub-pattern, e.g. `A+`. The bool is `reluctant` (`A+?` prefers fewer matches).
249    Quantified(Box<Pattern>, Quantifier, bool),
250    /// `PERMUTE(a, b, ...)` — expanded to the alternation of all orderings.
251    Permute(Vec<String>),
252}
253
254type StateId = usize;
255
256#[derive(Debug, Clone)]
257enum Transition {
258    /// An ε-transition (consumes no row).
259    Epsilon(StateId),
260    /// Consume a row that satisfies pattern variable `var`, moving to `target`.
261    OnVar { var: String, target: StateId },
262}
263
264/// A Thompson-construction NFA with a single start and single accept state.
265#[derive(Debug, Clone)]
266pub struct Nfa {
267    states: Vec<Vec<Transition>>,
268    start: StateId,
269    accept: StateId,
270    /// Per state: whether `accept` is reachable from it, with every predicate assumed satisfiable.
271    /// This is the static half of [`Nfa::may_extend`]: a consuming transition at the row boundary
272    /// whose target cannot reach `accept` can never contribute a longer match, no matter what rows
273    /// arrive. Computed once at compile with one reverse BFS.
274    reach_accept: Vec<bool>,
275    /// Fewest rows any accepting path consumes, with every predicate assumed satisfiable — the
276    /// shortest match the pattern admits (`0` when it accepts the empty match). A start with fewer
277    /// rows than this before the boundary cannot complete, so the finder skips it without a walk
278    /// (see [`Nfa::next_match`]). Computed once at compile with one 0-1 BFS.
279    min_match_rows: usize,
280    /// Most rows any path from `start` consumes, when the automaton is acyclic (no `*`, `+` or
281    /// unbounded range) — `None` when it has a cycle and a path can consume without bound. From a
282    /// position with MORE rows than this before the boundary, no path can reach the boundary while
283    /// still inside the automaton: the position is dead without a walk (see
284    /// [`Nfa::reaches_boundary_alive`]). Computed once at compile with one DFS.
285    max_match_rows: Option<usize>,
286}
287
288impl Nfa {
289    /// Compile a [`Pattern`] into an NFA.
290    pub fn compile(pattern: &Pattern) -> Self {
291        let mut builder = NfaBuilder { states: Vec::new() };
292        let frag = builder.build(pattern);
293        let reach_accept = Self::compute_reach_accept(&builder.states, frag.accept);
294        let max_match_rows = Self::compute_max_match_rows(&builder.states, frag.start);
295        let min_match_rows = Self::compute_min_match_rows(&builder.states, frag.start, frag.accept);
296        Nfa {
297            states: builder.states,
298            start: frag.start,
299            accept: frag.accept,
300            reach_accept,
301            min_match_rows,
302            max_match_rows,
303        }
304    }
305
306    /// See the `max_match_rows` field.
307    pub fn max_match_rows(&self) -> Option<usize> {
308        self.max_match_rows
309    }
310
311    /// See the `max_match_rows` field: the longest path from `start` counting consuming edges,
312    /// over ANY path (not only accepting ones — a path that dies still consumed its rows), or
313    /// `None` if a cycle is reachable. One iterative DFS with a tri-state mark, so a 100k-state
314    /// automaton does not recurse.
315    fn compute_max_match_rows(states: &[Vec<Transition>], start: StateId) -> Option<usize> {
316        #[derive(Clone, Copy, PartialEq, Eq)]
317        enum Mark {
318            New,
319            Active,
320            Done,
321        }
322        let mut mark = vec![Mark::New; states.len()];
323        // Longest consumption from each finished state.
324        let mut longest = vec![0usize; states.len()];
325        let mut stack: Vec<(StateId, usize)> = vec![(start, 0)];
326        mark[start] = Mark::Active;
327        while let Some(&(s, edge)) = stack.last() {
328            if let Some(t) = states[s].get(edge) {
329                stack.last_mut().expect("just peeked").1 += 1;
330                let next = match t {
331                    Transition::Epsilon(next) => *next,
332                    Transition::OnVar { target, .. } => *target,
333                };
334                match mark[next] {
335                    // A state still on the DFS path is reachable from itself: a cycle.
336                    Mark::Active => return None,
337                    Mark::New => {
338                        mark[next] = Mark::Active;
339                        stack.push((next, 0));
340                    }
341                    Mark::Done => {}
342                }
343            } else {
344                stack.pop();
345                mark[s] = Mark::Done;
346                longest[s] = states[s]
347                    .iter()
348                    .map(|t| match t {
349                        Transition::Epsilon(next) => longest[*next],
350                        Transition::OnVar { target, .. } => longest[*target] + 1,
351                    })
352                    .max()
353                    .unwrap_or(0);
354            }
355        }
356        Some(longest[start])
357    }
358
359    /// See the `min_match_rows` field.
360    pub fn min_match_rows(&self) -> usize {
361        self.min_match_rows
362    }
363
364    /// See the `min_match_rows` field: shortest path from `start` to `accept` where ε-edges cost
365    /// nothing and consuming edges cost one row (0-1 BFS, linear in states + transitions).
366    fn compute_min_match_rows(
367        states: &[Vec<Transition>],
368        start: StateId,
369        accept: StateId,
370    ) -> usize {
371        let mut dist = vec![usize::MAX; states.len()];
372        let mut queue = std::collections::VecDeque::from([start]);
373        dist[start] = 0;
374        while let Some(s) = queue.pop_front() {
375            let d = dist[s];
376            for t in &states[s] {
377                let (next, cost) = match t {
378                    Transition::Epsilon(next) => (*next, 0),
379                    Transition::OnVar { target, .. } => (*target, 1),
380                };
381                if d + cost < dist[next] {
382                    dist[next] = d + cost;
383                    if cost == 0 {
384                        queue.push_front(next);
385                    } else {
386                        queue.push_back(next);
387                    }
388                }
389            }
390        }
391        debug_assert_ne!(
392            dist[accept],
393            usize::MAX,
394            "accept must be reachable by construction"
395        );
396        dist[accept]
397    }
398
399    /// See the `reach_accept` field. Linear in states + transitions (one reverse BFS).
400    fn compute_reach_accept(states: &[Vec<Transition>], accept: StateId) -> Vec<bool> {
401        let n = states.len();
402        let mut rev: Vec<Vec<StateId>> = vec![Vec::new(); n];
403        for (s, ts) in states.iter().enumerate() {
404            for t in ts {
405                match t {
406                    Transition::Epsilon(next) => rev[*next].push(s),
407                    Transition::OnVar { target, .. } => rev[*target].push(s),
408                }
409            }
410        }
411        let mut reach = vec![false; n];
412        let mut stack = vec![accept];
413        reach[accept] = true;
414        while let Some(s) = stack.pop() {
415            for &p in &rev[s] {
416                if !reach[p] {
417                    reach[p] = true;
418                    stack.push(p);
419                }
420            }
421        }
422        reach
423    }
424
425    /// The set of states reachable from `states` via ε-transitions (inclusive). Only the test-only
426    /// reference matchers (e.g. [`Nfa::longest_match`]) use the explicit closure; the streaming
427    /// matcher walks transitions directly, so this is gated out of the release binary.
428    #[cfg(test)]
429    fn epsilon_closure(&self, states: impl IntoIterator<Item = StateId>) -> BTreeSet<StateId> {
430        let mut closure: BTreeSet<StateId> = BTreeSet::new();
431        let mut stack: Vec<StateId> = states.into_iter().collect();
432        while let Some(s) = stack.pop() {
433            if !closure.insert(s) {
434                continue;
435            }
436            for t in &self.states[s] {
437                if let Transition::Epsilon(next) = t {
438                    stack.push(*next);
439                }
440            }
441        }
442        closure
443    }
444
445    /// Greedy longest match starting at `rows[start]`. `rows[i]` is the set of pattern variables
446    /// whose `DEFINE` predicate row `i` satisfies. Returns the exclusive end index of the longest
447    /// match (so `start..end` are the matched rows), or `None` if no match starts at `start`.
448    ///
449    /// An empty match (the pattern accepts zero rows, e.g. `A*`) returns `Some(start)`.
450    ///
451    /// Test-only: the streaming executor matches via [`Nfa::find_matches_dynamic`]. This precomputed
452    /// satisfied-set variant is kept as the simple reference the dynamic matcher is checked against,
453    /// and to unit-test NFA construction directly. Gated out of the release binary.
454    #[cfg(test)]
455    pub fn longest_match(&self, rows: &[BTreeSet<String>], start: usize) -> Option<usize> {
456        let mut current = self.epsilon_closure([self.start]);
457        let mut longest = current.contains(&self.accept).then_some(start);
458
459        let mut pos = start;
460        while pos < rows.len() && !current.is_empty() {
461            let row = &rows[pos];
462            let mut next: BTreeSet<StateId> = BTreeSet::new();
463            for &s in &current {
464                for t in &self.states[s] {
465                    if let Transition::OnVar { var, target } = t
466                        && row.contains(var)
467                    {
468                        next.insert(*target);
469                    }
470                }
471            }
472            if next.is_empty() {
473                break;
474            }
475            current = self.epsilon_closure(next);
476            pos += 1;
477            if current.contains(&self.accept) {
478                longest = Some(pos);
479            }
480        }
481        longest
482    }
483}
484
485/// A single match span over the row sequence: `start..end` (end exclusive) are the matched rows.
486/// Test-only: produced by the reference matcher [`Nfa::find_matches`].
487#[cfg(test)]
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489pub struct MatchSpan {
490    pub start: usize,
491    pub end: usize,
492}
493
494/// Where the scan resumes after a match (the `AFTER MATCH SKIP` strategy).
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum SkipMode {
497    /// `AFTER MATCH SKIP PAST LAST ROW`: resume past the match's last row (non-overlapping).
498    PastLastRow,
499    /// `AFTER MATCH SKIP TO NEXT ROW`: resume at the row after the match's first row (overlapping).
500    ToNextRow,
501    /// `AFTER MATCH SKIP TO FIRST <var>`: resume at the first row labeled `var`.
502    ToFirst(String),
503    /// `AFTER MATCH SKIP TO LAST <var>`: resume at the last row labeled `var`.
504    ToLast(String),
505}
506
507/// Why a variable-targeted `AFTER MATCH SKIP` could not resume where the query asked, and which
508/// weaker strategy the resume position fell back to. Returned by [`SkipMode::next_pos`] next to the
509/// position instead of being reported here: this module stays pure (no error reporter, no executor
510/// types), and the executor — which owns the actor's `EvalErrorReport` — decides what to do with it.
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub enum SkipDegradation {
513    /// The target variable is bound to no row of the match, so there is no row to resume at. The
514    /// scan resumed past the match's last row, i.e. as `SKIP PAST LAST ROW`.
515    TargetAbsent,
516    /// The target resolves to the match's own first row, so resuming there would re-find the same
517    /// match forever. The scan resumed one row later, i.e. as `SKIP TO NEXT ROW`.
518    TargetAtMatchStart,
519}
520
521impl SkipDegradation {
522    /// The user-facing diagnostic for this degradation under `skip`: the target variable, what could
523    /// not be resolved about it, and the strategy actually applied. The *clause* is not repeated here
524    /// — the caller supplies it separately via [`SkipMode::clause_name`], so the rendered message
525    /// names the mode once (see `report_skip_degradation_once` in `executor.rs`).
526    ///
527    /// Deliberately carries no row, match or partition identity: the cause is a property of the
528    /// query, not of one row, which is what lets the executor report it once per watermark pass
529    /// instead of once per match.
530    pub fn describe(&self, skip: &SkipMode) -> String {
531        // Unreachable for the variable-less modes: only the targeted arms of `next_pos` can produce a
532        // degradation. A placeholder rather than an `unwrap` — a diagnostic path must not panic.
533        let target = skip.target_var().unwrap_or("?");
534        match self {
535            SkipDegradation::TargetAbsent => format!(
536                "target variable `{target}` is bound to no row of the match, so there is no row to \
537                 resume at; the scan resumed past the match's last row instead (degraded to SKIP \
538                 PAST LAST ROW)"
539            ),
540            SkipDegradation::TargetAtMatchStart => format!(
541                "target variable `{target}` resolves to the match's own first row, so resuming there \
542                 would re-find the same match forever; the scan resumed at the row after the match's \
543                 first row instead (degraded to SKIP TO NEXT ROW)"
544            ),
545        }
546    }
547}
548
549impl SkipMode {
550    /// The `AFTER MATCH SKIP` clause as SQL spells it, *without* the target variable — the mode alone.
551    /// `&'static str` so it can be the `name` of an `ExprError::InvalidParam`; the target variable is
552    /// named by [`SkipDegradation::describe`] instead, so a rendered diagnostic states the mode once.
553    pub fn clause_name(&self) -> &'static str {
554        match self {
555            SkipMode::PastLastRow => "AFTER MATCH SKIP PAST LAST ROW",
556            SkipMode::ToNextRow => "AFTER MATCH SKIP TO NEXT ROW",
557            SkipMode::ToFirst(_) => "AFTER MATCH SKIP TO FIRST",
558            SkipMode::ToLast(_) => "AFTER MATCH SKIP TO LAST",
559        }
560    }
561
562    /// The pattern variable a `SKIP TO FIRST|LAST` resumes at; `None` for the variable-less modes,
563    /// which are also the only ones that can never degrade.
564    pub fn target_var(&self) -> Option<&str> {
565        match self {
566            SkipMode::PastLastRow | SkipMode::ToNextRow => None,
567            SkipMode::ToFirst(var) | SkipMode::ToLast(var) => Some(var),
568        }
569    }
570
571    /// The position the scan resumes at after a match spanning `[start, end)` with per-row `labels`
572    /// (`labels[i]` is the variable bound to `rows[start + i]`), plus a [`SkipDegradation`] when that
573    /// position is not the one the query asked for. Always returns `> start` so the scan makes
574    /// progress.
575    ///
576    /// Two cases have no valid resume row, and both are data-dependent — the same query degrades or
577    /// not depending on which rows arrive:
578    ///
579    ///  * the target variable is bound to no row of this match (`(a b?)` matching only `a`, with
580    ///    `SKIP TO LAST b`), so the resume position falls back to the match end — silently becoming
581    ///    `SKIP PAST LAST ROW`;
582    ///  * the target resolves to the match's own first row (`SKIP TO FIRST` of the pattern's leading
583    ///    variable), which would re-find the same match forever, so it is clamped to `start + 1` —
584    ///    silently becoming `SKIP TO NEXT ROW`.
585    ///
586    /// The SQL standard prescribes a runtime error for both (Oracle raises ORA-62511 / ORA-62512;
587    /// Flink likewise). This implementation deliberately keeps the degradation and **reports** it
588    /// instead of raising it: an error here would abort the actor over a data-dependent condition,
589    /// and since the materialized view is already committed, every recovery attempt would replay the
590    /// same rows and die again — a recoverable query turned into a crash loop. No RisingWave
591    /// streaming operator fails an actor for a data-dependent condition; every hard error in this
592    /// operator is a contract or plan violation (non-append-only input, an unknown slot kind), which
593    /// recovery cannot fix either way. So the degradation is made *visible* rather than fatal: the
594    /// executor routes the returned diagnostic to the actor's `EvalErrorReport`, the same surface
595    /// expression evaluation errors already use (the `stream_expr_error` log and the
596    /// `user_compute_error` metric). See `report_skip_degradation_once` in `executor.rs` for how the
597    /// message is rendered — including the surface's fixed log prefix — and for the volume policy.
598    pub fn next_pos(
599        &self,
600        start: usize,
601        end: usize,
602        labels: &[String],
603    ) -> (usize, Option<SkipDegradation>) {
604        // Resolve a variable-targeted skip: `found` is the target's index within `labels`. Only these
605        // modes can degrade; the variable-less ones always have a valid resume row.
606        let resolve = |found: Option<usize>| match found {
607            // Index 0 is the match's own first row, so resuming there makes no progress.
608            Some(0) => (start + 1, Some(SkipDegradation::TargetAtMatchStart)),
609            Some(j) => (start + j, None),
610            None => (end.max(start + 1), Some(SkipDegradation::TargetAbsent)),
611        };
612        match self {
613            SkipMode::PastLastRow => (end.max(start + 1), None),
614            SkipMode::ToNextRow => (start + 1, None),
615            SkipMode::ToFirst(var) => resolve(labels.iter().position(|l| l == var)),
616            SkipMode::ToLast(var) => resolve(labels.iter().rposition(|l| l == var)),
617        }
618    }
619}
620
621impl Nfa {
622    /// Find all matches over `rows` under `ONE ROW PER MATCH` with the given `AFTER MATCH SKIP`
623    /// strategy: scan left to right; at each position take the greedy longest match; on a non-empty
624    /// match, record it and resume per `skip`; otherwise advance by one row.
625    ///
626    /// Empty matches (a pattern that accepts zero rows, e.g. `A*` on a non-matching row) are not
627    /// emitted and advance the scan by one, so the scan always terminates.
628    ///
629    /// Test-only reference matcher (see [`Nfa::longest_match`]); gated out of the release binary.
630    #[cfg(test)]
631    pub fn find_matches(&self, rows: &[BTreeSet<String>], skip: &SkipMode) -> Vec<MatchSpan> {
632        let mut matches = Vec::new();
633        let mut i = 0;
634        while i < rows.len() {
635            if let Some(end) = self.longest_match(rows, i)
636                && end > i
637            {
638                matches.push(MatchSpan { start: i, end });
639                // `find_matches` is label-less; the variable-targeted skips resolve like
640                // `PAST LAST ROW` here. `find_matches_labeled` applies them precisely.
641                i = match skip {
642                    SkipMode::ToNextRow => i + 1,
643                    _ => end,
644                };
645            } else {
646                i += 1;
647            }
648        }
649        matches
650    }
651}
652
653/// A match span together with the pattern variable assigned to each matched row.
654/// `labels[i]` is the variable that `rows[start + i]` was matched as.
655#[derive(Debug, Clone, PartialEq, Eq)]
656pub struct LabeledMatch {
657    pub start: usize,
658    pub end: usize,
659    pub labels: Vec<String>,
660}
661
662/// Visited-state guard for one traversal position of the dynamic matcher. ε-transitions keep the
663/// position, so each consumed row starts a fresh set (see [`Nfa::walk`]); these sets are opened
664/// O(rows × branches) times per partition visit, so their allocation cost matters. The
665/// common case — an automaton with at most 64 states — is a single `u64` bitmask (no allocation,
666/// membership is a bit test); larger automata (deep `PERMUTE` expansions) fall back to a `HashSet`.
667enum Visited {
668    Small(u64),
669    Large(HashSet<StateId>),
670}
671
672impl Visited {
673    fn new(n_states: usize) -> Self {
674        if n_states <= 64 {
675            Visited::Small(0)
676        } else {
677            Visited::Large(HashSet::new())
678        }
679    }
680
681    /// Marks `s` visited; returns whether it was newly inserted (mirrors `HashSet::insert`).
682    fn insert(&mut self, s: StateId) -> bool {
683        match self {
684            Visited::Small(bits) => {
685                let mask = 1u64 << s;
686                let newly = *bits & mask == 0;
687                *bits |= mask;
688                newly
689            }
690            Visited::Large(set) => set.insert(s),
691        }
692    }
693
694    fn remove(&mut self, s: StateId) {
695        match self {
696            Visited::Small(bits) => *bits &= !(1u64 << s),
697            Visited::Large(set) => {
698                set.remove(&s);
699            }
700        }
701    }
702
703    fn contains(&self, s: StateId) -> bool {
704        match self {
705            Visited::Small(bits) => *bits & (1u64 << s) != 0,
706            Visited::Large(set) => set.contains(&s),
707        }
708    }
709
710    /// Empties the set for reuse as a fresh scope (keeps the `HashSet` fallback's allocation).
711    fn clear(&mut self) {
712        match self {
713            Visited::Small(bits) => *bits = 0,
714            Visited::Large(set) => set.clear(),
715        }
716    }
717}
718
719impl Nfa {
720    /// Greedy longest match starting at `rows[start]`, returning the per-row variable assignment
721    /// along the chosen accepting path (the variable each consumed row was matched as). This is
722    /// what `MEASURES` navigation (`FIRST`/`LAST`), `CLASSIFIER()`, and aggregates over matched
723    /// rows consume. Returns `(end, labels)` where `labels.len() == end - start`, or `None`.
724    #[cfg(test)]
725    pub fn longest_match_labeled(
726        &self,
727        rows: &[BTreeSet<String>],
728        start: usize,
729    ) -> Option<(usize, Vec<String>)> {
730        let mut visited: HashSet<(StateId, usize)> = HashSet::new();
731        self.longest_from(rows, self.start, start, &mut visited)
732    }
733
734    /// Recursive longest-accepting-path search. `visited` guards against ε-cycles on the current
735    /// path (it tracks `(state, pos)` and is unwound on backtrack). Among continuations the one
736    /// reaching the furthest `end` wins; ties keep the first in transition order, making the label
737    /// assignment deterministic.
738    #[cfg(test)]
739    fn longest_from(
740        &self,
741        rows: &[BTreeSet<String>],
742        state: StateId,
743        pos: usize,
744        visited: &mut HashSet<(StateId, usize)>,
745    ) -> Option<(usize, Vec<String>)> {
746        if !visited.insert((state, pos)) {
747            return None;
748        }
749        let mut best: Option<(usize, Vec<String>)> =
750            (state == self.accept).then(|| (pos, Vec::new()));
751        for t in &self.states[state] {
752            let candidate = match t {
753                Transition::Epsilon(next) => self.longest_from(rows, *next, pos, visited),
754                Transition::OnVar { var, target } => {
755                    if pos < rows.len() && rows[pos].contains(var) {
756                        self.longest_from(rows, *target, pos + 1, visited).map(
757                            |(end, mut labels)| {
758                                labels.insert(0, var.clone());
759                                (end, labels)
760                            },
761                        )
762                    } else {
763                        None
764                    }
765                }
766            };
767            if let Some((end, labels)) = candidate
768                && best.as_ref().is_none_or(|(b, _)| end > *b)
769            {
770                best = Some((end, labels));
771            }
772        }
773        visited.remove(&(state, pos));
774        best
775    }
776
777    /// Like [`Nfa::find_matches`] but returns each match with its per-row variable labels.
778    /// Test-only reference matcher; the streaming executor uses [`Nfa::find_matches_dynamic`].
779    #[cfg(test)]
780    pub fn find_matches_labeled(
781        &self,
782        rows: &[BTreeSet<String>],
783        skip: &SkipMode,
784    ) -> Vec<LabeledMatch> {
785        let mut matches = Vec::new();
786        let mut i = 0;
787        while i < rows.len() {
788            if let Some((end, labels)) = self.longest_match_labeled(rows, i)
789                && end > i
790            {
791                let start = i;
792                // Test-only matcher: the diagnostic is dropped (there is no actor to report to).
793                (i, _) = skip.next_pos(start, end, &labels);
794                matches.push(LabeledMatch { start, end, labels });
795            } else {
796                i += 1;
797            }
798        }
799        matches
800    }
801
802    /// Like `find_matches_labeled`, but membership is decided by an async [`CandidateMatcher`]
803    /// instead of precomputed satisfied-sets, so `DEFINE` predicates with row-pattern navigation can
804    /// be evaluated against the running match. `n_rows` is the number of (sorted) rows to scan. This
805    /// is the only matcher the streaming executor uses.
806    pub async fn find_matches_dynamic(
807        &self,
808        n_rows: usize,
809        matcher: &(impl CandidateMatcher + Sync),
810        skip: &SkipMode,
811    ) -> StreamExecutorResult<Vec<LabeledMatch>> {
812        let mut matches = Vec::new();
813        let mut scan = MatchScan::new();
814        let mut budget = ScanBudget::unlimited();
815        while let Some(m) = self
816            .next_match(&mut scan, n_rows, matcher, skip, &mut budget, false)
817            .await?
818        {
819            matches.push(m);
820        }
821        Ok(matches)
822    }
823
824    /// Pull the next match at or after `scan`'s cursor, advancing the cursor by the skip mode.
825    /// Returns `None` once the cursor passes `n_rows`.
826    ///
827    /// This is the streaming form of [`Nfa::find_matches_dynamic`], which is a thin collect over
828    /// it. The executor's emit loop pulls instead of collecting so that stopping — at the first
829    /// boundary match that must be held for maximality — stops the *scan*, not just the emission:
830    /// nothing past the held match is computed (it would be recomputed from scratch on the next
831    /// watermark anyway) and at most one match is resident at a time. Collecting is worst-case
832    /// quadratic in live rows: under an overlapping skip mode a greedy `(a+)` over `n` qualifying
833    /// rows yields `n` matches whose label vectors sum to `O(n^2)` strings, all materialized before
834    /// the first one is examined.
835    pub async fn next_match(
836        &self,
837        scan: &mut MatchScan,
838        n_rows: usize,
839        matcher: &(impl CandidateMatcher + Sync),
840        skip: &SkipMode,
841        budget: &mut ScanBudget,
842        memoize: bool,
843    ) -> StreamExecutorResult<Option<LabeledMatch>> {
844        while scan.next_start < n_rows && !budget.hit {
845            let i = scan.next_start;
846            // Fewer rows than the shortest match before the boundary: no accepting path exists
847            // from `i` within `n_rows`, which is exactly the verdict a walk would reach after
848            // consuming its way to the boundary — so skip the walk and take the verdict. This is
849            // what keeps a rescan over a long pending run of a chain pattern (`a{600}`) from
850            // walking every start to the boundary, Θ(k²) per rescan. (Not a matchless start: the
851            // walk it stands in for is blocked at the boundary, and more rows may complete it.)
852            let Some(rows_ahead) = n_rows.checked_sub(i) else {
853                // A start past the boundary cannot come from the scan loop (it stops at `n_rows`);
854                // do not walk it — the walk would read rows that do not exist.
855                crate::consistency::consistency_panic!(
856                    start = i,
857                    n_rows,
858                    "MATCH_RECOGNIZE scan start lies past the fed boundary",
859                );
860                return Ok(None);
861            };
862            if rows_ahead < self.min_match_rows() {
863                scan.next_start += 1;
864                continue;
865            }
866            // The memo is per START: within one start a verdict depends only on `(var, pos)`
867            // (given path-independence), so it must not leak across starts, where `labels.len()`
868            // differs for the same position.
869            let mut memo = memoize.then(|| Memo::new(i, n_rows, self.states.len()));
870            let mut reached_boundary = false;
871            let found = self
872                .walk(
873                    Goal::Accept {
874                        n_rows,
875                        reached_boundary: &mut reached_boundary,
876                    },
877                    i,
878                    matcher,
879                    budget,
880                    memo.as_mut(),
881                )
882                .await?;
883            let found_empty = match found {
884                Some((end, labels)) if end > i => {
885                    // The diagnostic is dropped here on purpose: the executor recomputes the
886                    // resume position for the matches it actually *emits* and reports from there,
887                    // so a match that this scan finds but the emit path holds back or skips is not
888                    // reported twice (nor reported at all until it is emitted).
889                    (scan.next_start, _) = skip.next_pos(i, end, &labels);
890                    return Ok(Some(LabeledMatch {
891                        start: i,
892                        end,
893                        labels,
894                    }));
895                }
896                Some(_) => true,
897                None => false,
898            };
899            // Only advance past a start with a real verdict. The walk returns `None` for two
900            // different reasons — "no match from here" and "the budget died mid-walk, no
901            // verdict" — and advancing on the second leaves the cursor claiming a verdict the walk
902            // never reached: `(a b) | a` over two `a` rows with a budget of 1 dies inside start 0
903            // before ever trying the second alternative, which matches there.
904            //
905            // Inert for this operator (the loop condition already stops on `budget.hit`, and nothing
906            // here reads the cursor afterwards); it matters to a caller that resumes a pull.
907            if budget.hit {
908                break;
909            }
910            // A start whose walk found no accept and never reached the boundary is matchless
911            // FOREVER: every path from it died on a row below the boundary, and those rows are
912            // immutable, so no arrival can revive it. Record the contiguous run of such starts (see
913            // `MatchScan::matchless_upto`) — the finder's cross-visit memory, the counterpart of
914            // the freeze's proven-dead prefix: a broken run of `r` rows costs its Θ(r²) once,
915            // amortised across visits, instead of on every rescan forever. An empty match proves
916            // nothing about the other paths (the walk stopped at its first verdict), so it does
917            // not count.
918            if !found_empty && !reached_boundary && scan.matchless_upto == i {
919                scan.matchless_upto = i + 1;
920            }
921            scan.next_start += 1;
922        }
923        Ok(None)
924    }
925
926    /// Whether a match starting at `pos` is still *live* at the safe boundary `n_rows`: there exists
927    /// a path that consumes the safe rows `pos..n_rows` and reaches the boundary while still inside
928    /// the automaton (not yet accepted), so a future row could extend it into a complete match. Used
929    /// to evict rows that can no longer be part of any match.
930    ///
931    /// This is strictly stronger than "can `pos` begin the pattern": for `(a b)` over `[a, x]` where
932    /// `x` matches neither, the `a` *can* begin the pattern, but every path dies on `x` before the
933    /// boundary, so the start is dead and must be evictable. A lone `[a]` (boundary right after `a`),
934    /// in contrast, is kept because a future `b` may still complete it.
935    pub async fn reaches_boundary_alive(
936        &self,
937        pos: usize,
938        n_rows: usize,
939        matcher: &(impl CandidateMatcher + Sync),
940        budget: &mut ScanBudget,
941        memoize: bool,
942    ) -> StreamExecutorResult<bool> {
943        // Acyclic automaton: no path consumes more than `max_match_rows` rows, so with more rows
944        // than that before the boundary no path can reach it — dead, without a walk. This is what
945        // makes freezing a long chain match (`a{600}`) cheap: every position of its region has the
946        // rest of the match, and more, ahead of it. (With EXACTLY that many rows the walk runs: a
947        // match ending on the boundary keeps its start alive.)
948        let Some(rows_ahead) = n_rows.checked_sub(pos) else {
949            // A position past the boundary is a caller error, not a dead row. Without overflow
950            // checks the subtraction would wrap and the position would read as dead — evicting a
951            // row a live match may still need. Retain it.
952            crate::consistency::consistency_panic!(
953                pos,
954                n_rows,
955                "MATCH_RECOGNIZE liveness asked about a position past the fed boundary",
956            );
957            return Ok(true);
958        };
959        if let Some(max) = self.max_match_rows
960            && rows_ahead > max
961        {
962            return Ok(false);
963        }
964        let mut memo = memoize.then(|| Memo::new(pos, n_rows, self.states.len()));
965        Ok(self
966            .walk(
967                Goal::Boundary { n_rows },
968                pos,
969                matcher,
970                budget,
971                memo.as_mut(),
972            )
973            .await?
974            .is_some())
975    }
976
977    /// Whether the automaton is a fixed-length linear chain: every state has at most one
978    /// outgoing transition (plain concatenations like `(a b c)` — no alternation, no
979    /// quantifiers, no PERMUTE). For such patterns an accepted match consumed the only path there
980    /// is, so no more-preferred extension can exist and [`Nfa::may_extend`] is statically `false`
981    /// — the emission gate can skip the probe entirely.
982    pub fn is_linear(&self) -> bool {
983        self.states.iter().all(|ts| ts.len() <= 1)
984    }
985
986    /// Whether the finder's *preferred* result for the match starting at `start` could change if
987    /// more rows arrived past the boundary `end`.
988    ///
989    /// The finder ([`Nfa::next_match`]) returns the first accepting path in transition
990    /// order: greedy quantifiers try their consume edge before their exit edge, reluctant ones the
991    /// reverse, and ordered alternation tries branches as listed. A lower-priority path can
992    /// therefore NEVER override an accepting higher-priority one, no matter what rows arrive — for
993    /// `PATTERN (A (B | B C))` the first-listed `B` alternative wins even if a `C` shows up later.
994    /// So the question is not "could any NFA path consume more" but "could a path the finder
995    /// prefers over the current result become accepting".
996    ///
997    /// This walk mirrors the finder's own traversal exactly and stops at its first accept — the
998    /// preferred result. It answers `true` iff, strictly before that accept in preference order,
999    /// some consuming transition was blocked by the row boundary while its target can still reach
1000    /// `accept` ([`Nfa::reach_accept`], unknown rows assumed satisfiable): exactly the paths that
1001    /// arriving rows could turn into a more-preferred accepting result. Everything explored after
1002    /// the accept is lower-priority and irrelevant.
1003    ///
1004    /// `false` means the preferred result is **terminal**: it cannot change, so a boundary match is
1005    /// final and must be emitted — holding it would starve an idle partition forever (the frontier
1006    /// recompute finds neither a future row nor, without `WITHIN`, a deadline, and drops the
1007    /// partition). `true` means the standard maximality wait applies.
1008    pub async fn may_extend(
1009        &self,
1010        start: usize,
1011        end: usize,
1012        matcher: &(impl CandidateMatcher + Sync),
1013        budget: &mut ScanBudget,
1014        memoize: bool,
1015    ) -> StreamExecutorResult<bool> {
1016        let mut blocked = false;
1017        let mut memo = memoize.then(|| Memo::new(start, end, self.states.len()));
1018        let accepted = self
1019            .walk(
1020                Goal::AcceptOrBlocked {
1021                    end,
1022                    blocked: &mut blocked,
1023                },
1024                start,
1025                matcher,
1026                budget,
1027                memo.as_mut(),
1028            )
1029            .await?
1030            .is_some();
1031        // A spent budget means the walk may have stopped before the preferred accept: `blocked`
1032        // then under-approximates, and the only safe answer is "may extend" (hold — never emit on
1033        // partial information).
1034        if budget.hit {
1035            return Ok(true);
1036        }
1037        // Called for a match the finder produced over these same rows, so an accepting path within
1038        // `end` exists and `accepted` holds; if a caller ever probes a non-match, every explored
1039        // path is by definition higher-priority than the (absent) result, so `blocked` is still
1040        // the right answer.
1041        debug_assert!(accepted, "may_extend probed a non-accepting span");
1042        Ok(blocked)
1043    }
1044
1045    /// The one traversal behind the finder, the liveness check and the extension probe: a
1046    /// depth-first search from `self.start` at row `start_pos`, in transition order, with `DEFINE`
1047    /// predicates evaluated over the running match (`path`, the labels bound so far, is threaded to
1048    /// the matcher). Returns the row position at which `goal` was met together with the labels of
1049    /// the path that met it; `None` is "no such path" — or, with `budget.hit` latched, "undecided".
1050    ///
1051    /// Iterative, over an explicit heap stack. The recursive walkers this replaced — boxed
1052    /// `async_recursion` frames, polled through the real thread stack once per consumed row and
1053    /// once per ε-edge — needed a hard depth cap to avoid overflowing it, and the cap made any
1054    /// match spanning more than a couple of hundred rows permanently undecidable: unlike the
1055    /// budget it did not reset between visits, so every refresh died at the same depth. Every push
1056    /// is charged to the budget, so the stack holds at most `budget` frames — at the executor's
1057    /// 2^20 steps, about 32 megabytes of 32-byte frames in the worst case — and the budget is the
1058    /// only bound on a walk.
1059    ///
1060    /// The discipline, identical for all three goals:
1061    /// - transitions are tried in the order the builder emitted them, and the FIRST verdict wins —
1062    ///   greedy quantifiers list their consume edge before their exit edge, reluctant ones the
1063    ///   reverse, alternation as written;
1064    /// - a [`Visited`] scope per row position cuts ε-cycles: ε-edges keep the position and the
1065    ///   scope, a consumed row opens a fresh one, so distinct label assignments may reach the same
1066    ///   state at the next row;
1067    /// - the budget is charged once per predicate evaluation and once per edge taken, and a spent
1068    ///   budget ends the walk without a verdict and without recording anything;
1069    /// - failures are memoized only at consumption boundaries — a consumed frame starts with a fresh
1070    ///   scope, so its outcome is context-free — and never for a budget-aborted walk, which would
1071    ///   turn a transient abort into a permanent wrong verdict (see [`Memo`]).
1072    async fn walk(
1073        &self,
1074        mut goal: Goal<'_>,
1075        start_pos: usize,
1076        matcher: &(impl CandidateMatcher + Sync),
1077        budget: &mut ScanBudget,
1078        mut memo: Option<&mut Memo>,
1079    ) -> StreamExecutorResult<Option<(usize, Vec<String>)>> {
1080        let n_states = self.states.len();
1081        let mut path: Vec<String> = Vec::new();
1082        // One visited scope per consumption level; `scopes[depth]` is the live one. A walk opens a
1083        // scope per consumed row, so they are cleared and reused rather than reallocated.
1084        let mut scopes: Vec<Visited> = vec![Visited::new(n_states)];
1085        let mut depth = 0usize;
1086        let mut stack = vec![Frame::new(self.start, start_pos, false)];
1087
1088        loop {
1089            let Some(top) = stack.last_mut() else {
1090                return Ok(None);
1091            };
1092            if !top.entered {
1093                top.entered = true;
1094                // A walk entered with an already-spent budget aborts without deciding anything
1095                // (the caller must treat everything undecided conservatively; see `ScanBudget`).
1096                // Exhaustion MID-walk never reaches this point: every `step`/`charge` that
1097                // latches `hit` returns from `walk` directly below — which is what lets
1098                // `pop_failed` record a failure unconditionally.
1099                if budget.hit {
1100                    return Ok(None);
1101                }
1102                let (state, pos) = (top.state, top.pos);
1103                match goal.enter(self, state, pos) {
1104                    Enter::Verdict => return Ok(Some((pos, path))),
1105                    Enter::Dead => {
1106                        Self::pop_failed(
1107                            &mut stack,
1108                            &mut scopes,
1109                            &mut depth,
1110                            &mut path,
1111                            memo.as_deref_mut(),
1112                            budget,
1113                        );
1114                        continue;
1115                    }
1116                    Enter::Explore => {}
1117                }
1118                if scopes[depth].insert(state) {
1119                    top.inserted = true;
1120                } else {
1121                    Self::pop_failed(
1122                        &mut stack,
1123                        &mut scopes,
1124                        &mut depth,
1125                        &mut path,
1126                        memo.as_deref_mut(),
1127                        budget,
1128                    );
1129                    continue;
1130                }
1131            }
1132            let (state, pos) = (top.state, top.pos);
1133            let Some(t) = self.states[state].get(top.next_edge) else {
1134                // Every transition tried and none met the goal: this frame fails.
1135                Self::pop_failed(
1136                    &mut stack,
1137                    &mut scopes,
1138                    &mut depth,
1139                    &mut path,
1140                    memo.as_deref_mut(),
1141                    budget,
1142                );
1143                continue;
1144            };
1145            top.next_edge += 1;
1146            match t {
1147                Transition::Epsilon(next) => {
1148                    if !budget.step() {
1149                        return Ok(None);
1150                    }
1151                    stack.push(Frame::new(*next, pos, false));
1152                }
1153                Transition::OnVar { var, target } => {
1154                    if !goal.may_consume(self, *target, pos) {
1155                        continue;
1156                    }
1157                    if !budget.charge() {
1158                        return Ok(None);
1159                    }
1160                    if !matcher.matches(var, pos, &path).await? {
1161                        continue;
1162                    }
1163                    // Consumption boundary: the frame pushed below starts with a fresh visited
1164                    // scope, so its outcome is context-free — the failure memo is checked here and
1165                    // recorded on its exit, never at ε-level, where a failure can be a cycle-cut
1166                    // artifact.
1167                    if memo
1168                        .as_deref_mut()
1169                        .is_some_and(|m| m.is_failed(*target, pos + 1))
1170                    {
1171                        continue;
1172                    }
1173                    if !budget.step() {
1174                        return Ok(None);
1175                    }
1176                    path.push(var.clone());
1177                    depth += 1;
1178                    if depth == scopes.len() {
1179                        scopes.push(Visited::new(n_states));
1180                    } else {
1181                        scopes[depth].clear();
1182                    }
1183                    stack.push(Frame::new(*target, pos + 1, true));
1184                }
1185            }
1186        }
1187    }
1188
1189    /// Pop the top frame as a failure: unmark its state in its scope, and if it was entered by
1190    /// consuming a row, close that scope, drop its label, and memoize the failure.
1191    ///
1192    /// A budget-aborted walk never gets here — every latch site returns from `walk` directly — so
1193    /// the failure being recorded is always a proven one, by construction rather than by a guard.
1194    fn pop_failed(
1195        stack: &mut Vec<Frame>,
1196        scopes: &mut [Visited],
1197        depth: &mut usize,
1198        path: &mut Vec<String>,
1199        memo: Option<&mut Memo>,
1200        budget: &ScanBudget,
1201    ) {
1202        debug_assert!(
1203            !budget.hit,
1204            "a spent budget returns from `walk`; it never pops a frame"
1205        );
1206        // Callers hold the top frame, so the stack is non-empty; an empty stack here would be a
1207        // walk-invariant violation, and letting the loop end on it (no verdict) beats panicking an
1208        // actor over it.
1209        let Some(frame) = stack.pop() else {
1210            return;
1211        };
1212        if frame.inserted {
1213            scopes[*depth].remove(frame.state);
1214        }
1215        if frame.consumed {
1216            debug_assert!(*depth > 0, "a consumed frame always opened a scope");
1217            *depth = depth.saturating_sub(1);
1218            path.pop();
1219            if let Some(m) = memo {
1220                m.record_failure(frame.state, frame.pos);
1221            }
1222        }
1223    }
1224}
1225
1226/// What a [`Nfa::walk`] is looking for. The three questions the executor asks of the automaton share
1227/// one traversal and differ only in when a frame is a verdict and in what a consuming edge may do at
1228/// the row boundary.
1229enum Goal<'a> {
1230    /// The first accepting path in preference order — the match finder ([`Nfa::next_match`]).
1231    /// Consuming edges stop at `n_rows`. `reached_boundary` records whether any frame was entered
1232    /// AT `n_rows`: a walk that finds no accept and never got there died entirely on rows below
1233    /// the boundary, which no arrival can change — the start is matchless forever.
1234    Accept {
1235        n_rows: usize,
1236        reached_boundary: &'a mut bool,
1237    },
1238    /// Whether some path consumes the safe suffix up to `n_rows` while still inside the automaton
1239    /// ([`Nfa::reaches_boundary_alive`]). Reaching `n_rows` is the verdict; reaching `accept` before
1240    /// it is a complete (already-finalized) match, not a live partial one, and fails that path.
1241    Boundary { n_rows: usize },
1242    /// The first accept, bounded by `end` ([`Nfa::may_extend`]). A consuming edge at `end` cannot
1243    /// be evaluated — the row does not exist yet; if its target can still reach `accept`
1244    /// ([`Nfa::reach_accept`]) it is recorded in `blocked`, since a future row could fire it and
1245    /// produce a more-preferred result than any accept found later in the walk.
1246    AcceptOrBlocked { end: usize, blocked: &'a mut bool },
1247}
1248
1249/// A frame's verdict on entry.
1250enum Enter {
1251    /// The goal is met at this frame.
1252    Verdict,
1253    /// This path can no longer meet the goal.
1254    Dead,
1255    /// Keep walking.
1256    Explore,
1257}
1258
1259impl Goal<'_> {
1260    fn enter(&mut self, nfa: &Nfa, state: StateId, pos: usize) -> Enter {
1261        if let Goal::Accept {
1262            n_rows,
1263            reached_boundary,
1264        } = self
1265            && pos == *n_rows
1266        {
1267            **reached_boundary = true;
1268        }
1269        match self {
1270            // The single accept state is terminal: reaching it completes the match here.
1271            Goal::Accept { .. } | Goal::AcceptOrBlocked { .. } if state == nfa.accept => {
1272                Enter::Verdict
1273            }
1274            Goal::Boundary { n_rows } if pos == *n_rows => Enter::Verdict,
1275            Goal::Boundary { .. } if state == nfa.accept => Enter::Dead,
1276            _ => Enter::Explore,
1277        }
1278    }
1279
1280    /// Whether a consuming edge into `target` may be evaluated at `pos`.
1281    fn may_consume(&mut self, nfa: &Nfa, target: StateId, pos: usize) -> bool {
1282        match self {
1283            Goal::Accept { n_rows, .. } | Goal::Boundary { n_rows } => pos < *n_rows,
1284            Goal::AcceptOrBlocked { end, blocked } => {
1285                if pos == *end {
1286                    if nfa.reach_accept[target] {
1287                        **blocked = true;
1288                    }
1289                    false
1290                } else {
1291                    true
1292                }
1293            }
1294        }
1295    }
1296}
1297
1298/// One frame of the explicit walk stack: the state under exploration at row `pos`, and how far
1299/// through its transitions the walk has got.
1300struct Frame {
1301    state: StateId,
1302    pos: usize,
1303    /// Index of the next transition of `state` to try.
1304    next_edge: usize,
1305    /// Entered through a consuming transition: it opened a [`Visited`] scope and pushed a label,
1306    /// both undone on exit, and its failure is memoizable.
1307    consumed: bool,
1308    /// The entry checks have run (goal verdict, budget, visited).
1309    entered: bool,
1310    /// The frame marked `state` in its scope, and must unmark it on exit.
1311    inserted: bool,
1312}
1313
1314impl Frame {
1315    fn new(state: StateId, pos: usize, consumed: bool) -> Self {
1316        Self {
1317            state,
1318            pos,
1319            next_edge: 0,
1320            consumed,
1321            entered: false,
1322            inserted: false,
1323        }
1324    }
1325}
1326
1327/// A sub-NFA fragment with one entry and one exit state.
1328struct Fragment {
1329    start: StateId,
1330    accept: StateId,
1331}
1332
1333struct NfaBuilder {
1334    states: Vec<Vec<Transition>>,
1335}
1336
1337impl NfaBuilder {
1338    fn new_state(&mut self) -> StateId {
1339        self.states.push(Vec::new());
1340        self.states.len() - 1
1341    }
1342
1343    fn add_epsilon(&mut self, from: StateId, to: StateId) {
1344        self.states[from].push(Transition::Epsilon(to));
1345    }
1346
1347    fn add_on_var(&mut self, from: StateId, var: String, to: StateId) {
1348        self.states[from].push(Transition::OnVar { var, target: to });
1349    }
1350
1351    /// LOCKSTEP: the per-construct state counts below are mirrored by `estimate_nfa_states` in
1352    /// `frontend/src/binder/relation/match_recognize.rs`, which rejects a pattern whose expansion
1353    /// would exhaust memory here. No test can span the two crates, so changing how many states a
1354    /// construct allocates requires updating that estimator in the same change.
1355    fn build(&mut self, pattern: &Pattern) -> Fragment {
1356        match pattern {
1357            Pattern::Var(v) => {
1358                let start = self.new_state();
1359                let accept = self.new_state();
1360                self.add_on_var(start, v.clone(), accept);
1361                Fragment { start, accept }
1362            }
1363            Pattern::Concat(parts) => {
1364                if parts.is_empty() {
1365                    let s = self.new_state();
1366                    return Fragment {
1367                        start: s,
1368                        accept: s,
1369                    };
1370                }
1371                let first = self.build(&parts[0]);
1372                let mut accept = first.accept;
1373                for p in &parts[1..] {
1374                    let frag = self.build(p);
1375                    self.add_epsilon(accept, frag.start);
1376                    accept = frag.accept;
1377                }
1378                Fragment {
1379                    start: first.start,
1380                    accept,
1381                }
1382            }
1383            Pattern::Alt(alts) => {
1384                let start = self.new_state();
1385                let accept = self.new_state();
1386                for a in alts {
1387                    let frag = self.build(a);
1388                    self.add_epsilon(start, frag.start);
1389                    self.add_epsilon(frag.accept, accept);
1390                }
1391                Fragment { start, accept }
1392            }
1393            Pattern::Quantified(inner, q, reluctant) => self.build_quantified(inner, q, *reluctant),
1394            Pattern::Permute(vars) => {
1395                // PERMUTE expands to the alternation of every ordering of the variables.
1396                let alts: Vec<Pattern> = permutations(vars)
1397                    .into_iter()
1398                    .map(|order| Pattern::Concat(order.into_iter().map(Pattern::Var).collect()))
1399                    .collect();
1400                self.build(&Pattern::Alt(alts))
1401            }
1402        }
1403    }
1404
1405    /// LOCKSTEP with `estimate_nfa_states` — see [`Self::build`].
1406    fn build_quantified(&mut self, inner: &Pattern, q: &Quantifier, reluctant: bool) -> Fragment {
1407        match q {
1408            Quantifier::Star => self.build_star(inner, reluctant),
1409            Quantifier::Plus => {
1410                // inner followed by inner* (the repetition carries the reluctant preference)
1411                let first = self.build(inner);
1412                let star = self.build_star(inner, reluctant);
1413                self.add_epsilon(first.accept, star.start);
1414                Fragment {
1415                    start: first.start,
1416                    accept: star.accept,
1417                }
1418            }
1419            Quantifier::Question => {
1420                let start = self.new_state();
1421                let accept = self.new_state();
1422                let frag = self.build(inner);
1423                // Greedy orders take-the-inner before skip; reluctant orders skip first.
1424                if reluctant {
1425                    self.add_epsilon(start, accept); // skip first
1426                    self.add_epsilon(start, frag.start);
1427                } else {
1428                    self.add_epsilon(start, frag.start);
1429                    self.add_epsilon(start, accept); // skip
1430                }
1431                self.add_epsilon(frag.accept, accept);
1432                Fragment { start, accept }
1433            }
1434            Quantifier::Range { min, max } => self.build_range(inner, *min, *max, reluctant),
1435        }
1436    }
1437
1438    /// LOCKSTEP with `estimate_nfa_states` — see [`Self::build`].
1439    fn build_star(&mut self, inner: &Pattern, reluctant: bool) -> Fragment {
1440        let start = self.new_state();
1441        let accept = self.new_state();
1442        let frag = self.build(inner);
1443        // The matcher takes the first accepting path in edge order. Greedy emits the consume/loop
1444        // edge before the exit edge (longest match first); reluctant emits the exit edge first
1445        // (shortest match first).
1446        if reluctant {
1447            self.add_epsilon(start, accept); // zero occurrences first
1448            self.add_epsilon(start, frag.start);
1449            self.add_epsilon(frag.accept, accept); // exit before loop
1450            self.add_epsilon(frag.accept, frag.start);
1451        } else {
1452            self.add_epsilon(start, frag.start);
1453            self.add_epsilon(start, accept); // zero occurrences
1454            self.add_epsilon(frag.accept, frag.start); // loop
1455            self.add_epsilon(frag.accept, accept);
1456        }
1457        Fragment { start, accept }
1458    }
1459
1460    /// LOCKSTEP with `estimate_nfa_states` — see [`Self::build`]. The bounds reaching here are already
1461    /// capped at bind time precisely because this expansion is eager.
1462    fn build_range(
1463        &mut self,
1464        inner: &Pattern,
1465        min: u32,
1466        max: Option<u32>,
1467        reluctant: bool,
1468    ) -> Fragment {
1469        // Expand to `min` mandatory copies followed by either `*` (unbounded) or `max-min`
1470        // optional copies.
1471        let mut parts: Vec<Pattern> = Vec::new();
1472        for _ in 0..min {
1473            parts.push(inner.clone());
1474        }
1475        match max {
1476            None => parts.push(Pattern::Quantified(
1477                Box::new(inner.clone()),
1478                Quantifier::Star,
1479                reluctant,
1480            )),
1481            Some(max) => {
1482                for _ in min..max {
1483                    parts.push(Pattern::Quantified(
1484                        Box::new(inner.clone()),
1485                        Quantifier::Question,
1486                        reluctant,
1487                    ));
1488                }
1489            }
1490        }
1491        self.build(&Pattern::Concat(parts))
1492    }
1493}
1494
1495/// All orderings of `items`. Only used for `PERMUTE`, which has a small arity in practice.
1496fn permutations(items: &[String]) -> Vec<Vec<String>> {
1497    if items.is_empty() {
1498        return vec![vec![]];
1499    }
1500    let mut out = Vec::new();
1501    for i in 0..items.len() {
1502        let mut rest = items.to_vec();
1503        let head = rest.remove(i);
1504        for mut tail in permutations(&rest) {
1505            tail.insert(0, head.clone());
1506            out.push(tail);
1507        }
1508    }
1509    out
1510}
1511
1512/// A [`CandidateMatcher`] backed by precomputed satisfied-sets — the dynamic driver should then
1513/// agree with [`Nfa::find_matches_labeled`]. Test-only, but `pub(crate)` so the sibling
1514/// `incremental` module's differential-oracle tests can reuse the exact same reference matcher as
1515/// `nfa`'s own tests.
1516#[cfg(test)]
1517pub(crate) struct SetMatcher {
1518    rows: Vec<BTreeSet<String>>,
1519}
1520
1521#[cfg(test)]
1522impl SetMatcher {
1523    pub(crate) fn new(rows: Vec<BTreeSet<String>>) -> Self {
1524        Self { rows }
1525    }
1526}
1527
1528#[cfg(test)]
1529impl CandidateMatcher for SetMatcher {
1530    async fn matches(
1531        &self,
1532        var: &str,
1533        pos: usize,
1534        _labels: &[String],
1535    ) -> StreamExecutorResult<bool> {
1536        Ok(self.rows[pos].contains(var))
1537    }
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542    use super::*;
1543
1544    fn vars(s: &str) -> Pattern {
1545        Pattern::Var(s.to_owned())
1546    }
1547
1548    /// Build a row sequence from a string where each char names the single variable that row
1549    /// satisfies, e.g. "abc" -> [{a}, {b}, {c}].
1550    fn rows(seq: &str) -> Vec<BTreeSet<String>> {
1551        seq.chars()
1552            .map(|c| BTreeSet::from([c.to_string()]))
1553            .collect()
1554    }
1555
1556    #[test]
1557    fn concat_exact() {
1558        // A B C
1559        let p = Pattern::Concat(vec![vars("a"), vars("b"), vars("c")]);
1560        let nfa = Nfa::compile(&p);
1561        assert_eq!(nfa.longest_match(&rows("abc"), 0), Some(3));
1562        assert_eq!(nfa.longest_match(&rows("abx"), 0), None);
1563        assert_eq!(nfa.longest_match(&rows("ab"), 0), None);
1564    }
1565
1566    #[test]
1567    fn plus_is_greedy() {
1568        // A B+ C  on  a b b b c
1569        let p = Pattern::Concat(vec![
1570            vars("a"),
1571            Pattern::Quantified(Box::new(vars("b")), Quantifier::Plus, false),
1572            vars("c"),
1573        ]);
1574        let nfa = Nfa::compile(&p);
1575        assert_eq!(nfa.longest_match(&rows("abbbc"), 0), Some(5));
1576        // B+ requires at least one b.
1577        assert_eq!(nfa.longest_match(&rows("ac"), 0), None);
1578    }
1579
1580    #[test]
1581    fn question_optional() {
1582        // A B? C  matches both "abc" and "ac"
1583        let p = Pattern::Concat(vec![
1584            vars("a"),
1585            Pattern::Quantified(Box::new(vars("b")), Quantifier::Question, false),
1586            vars("c"),
1587        ]);
1588        let nfa = Nfa::compile(&p);
1589        assert_eq!(nfa.longest_match(&rows("abc"), 0), Some(3));
1590        assert_eq!(nfa.longest_match(&rows("ac"), 0), Some(2));
1591    }
1592
1593    #[test]
1594    fn star_greedy_longest() {
1595        // A*  on  a a a  -> greedy longest is 3
1596        let p = Pattern::Quantified(Box::new(vars("a")), Quantifier::Star, false);
1597        let nfa = Nfa::compile(&p);
1598        assert_eq!(nfa.longest_match(&rows("aaa"), 0), Some(3));
1599        // zero occurrences still matches (empty match).
1600        assert_eq!(nfa.longest_match(&rows("xyz"), 0), Some(0));
1601    }
1602
1603    #[test]
1604    fn alternation() {
1605        // (A | B) C
1606        let p = Pattern::Concat(vec![Pattern::Alt(vec![vars("a"), vars("b")]), vars("c")]);
1607        let nfa = Nfa::compile(&p);
1608        assert_eq!(nfa.longest_match(&rows("ac"), 0), Some(2));
1609        assert_eq!(nfa.longest_match(&rows("bc"), 0), Some(2));
1610        assert_eq!(nfa.longest_match(&rows("cc"), 0), None);
1611    }
1612
1613    #[test]
1614    fn range_bounds() {
1615        // A{2,3}
1616        let p = Pattern::Quantified(
1617            Box::new(vars("a")),
1618            Quantifier::Range {
1619                min: 2,
1620                max: Some(3),
1621            },
1622            false,
1623        );
1624        let nfa = Nfa::compile(&p);
1625        assert_eq!(nfa.longest_match(&rows("a"), 0), None); // need >= 2
1626        assert_eq!(nfa.longest_match(&rows("aa"), 0), Some(2));
1627        assert_eq!(nfa.longest_match(&rows("aaa"), 0), Some(3));
1628        assert_eq!(nfa.longest_match(&rows("aaaa"), 0), Some(3)); // capped at 3
1629    }
1630
1631    #[test]
1632    fn permute_any_order() {
1633        // PERMUTE(a, b)
1634        let p = Pattern::Permute(vec!["a".to_owned(), "b".to_owned()]);
1635        let nfa = Nfa::compile(&p);
1636        assert_eq!(nfa.longest_match(&rows("ab"), 0), Some(2));
1637        assert_eq!(nfa.longest_match(&rows("ba"), 0), Some(2));
1638        assert_eq!(nfa.longest_match(&rows("aa"), 0), None);
1639    }
1640
1641    #[test]
1642    fn match_from_offset() {
1643        // A B starting at index 1 of  x a b
1644        let p = Pattern::Concat(vec![vars("a"), vars("b")]);
1645        let nfa = Nfa::compile(&p);
1646        assert_eq!(nfa.longest_match(&rows("xab"), 1), Some(3));
1647        assert_eq!(nfa.longest_match(&rows("xab"), 0), None);
1648    }
1649
1650    fn spans(v: &[(usize, usize)]) -> Vec<MatchSpan> {
1651        v.iter()
1652            .map(|&(start, end)| MatchSpan { start, end })
1653            .collect()
1654    }
1655
1656    #[test]
1657    fn find_matches_skip_past_last_row() {
1658        // A B, repeated, with SKIP PAST LAST ROW -> non-overlapping matches.
1659        let p = Pattern::Concat(vec![vars("a"), vars("b")]);
1660        let nfa = Nfa::compile(&p);
1661        assert_eq!(
1662            nfa.find_matches(&rows("ababab"), &SkipMode::PastLastRow),
1663            spans(&[(0, 2), (2, 4), (4, 6)])
1664        );
1665    }
1666
1667    #[test]
1668    fn find_matches_skip_to_next_row_overlaps() {
1669        // A+ with SKIP TO NEXT ROW: matches may overlap (resume at start+1).
1670        let p = Pattern::Quantified(Box::new(vars("a")), Quantifier::Plus, false);
1671        let nfa = Nfa::compile(&p);
1672        // "aaa": greedy A+ at 0->(0,3); to-next resumes at 1->(1,3); 2->(2,3).
1673        assert_eq!(
1674            nfa.find_matches(&rows("aaa"), &SkipMode::ToNextRow),
1675            spans(&[(0, 3), (1, 3), (2, 3)])
1676        );
1677        // PAST LAST ROW on the same input: single match.
1678        assert_eq!(
1679            nfa.find_matches(&rows("aaa"), &SkipMode::PastLastRow),
1680            spans(&[(0, 3)])
1681        );
1682    }
1683
1684    #[test]
1685    fn find_matches_greedy_then_resume() {
1686        // A B+ : greedy consumes all b's, then resumes past the match.
1687        let p = Pattern::Concat(vec![
1688            vars("a"),
1689            Pattern::Quantified(Box::new(vars("b")), Quantifier::Plus, false),
1690        ]);
1691        let nfa = Nfa::compile(&p);
1692        // a b b | a b  -> (0,3) then (3,5)
1693        assert_eq!(
1694            nfa.find_matches(&rows("abbab"), &SkipMode::PastLastRow),
1695            spans(&[(0, 3), (3, 5)])
1696        );
1697    }
1698
1699    #[test]
1700    fn find_matches_skips_non_matching_rows() {
1701        // A B with junk rows between matches.
1702        let p = Pattern::Concat(vec![vars("a"), vars("b")]);
1703        let nfa = Nfa::compile(&p);
1704        // x a b x x a b -> (1,3),(5,7)
1705        assert_eq!(
1706            nfa.find_matches(&rows("xabxxab"), &SkipMode::PastLastRow),
1707            spans(&[(1, 3), (5, 7)])
1708        );
1709    }
1710
1711    #[test]
1712    fn find_matches_empty_pattern_terminates() {
1713        // A* matches empty everywhere; empty matches are not emitted and the scan terminates.
1714        let p = Pattern::Quantified(Box::new(vars("a")), Quantifier::Star, false);
1715        let nfa = Nfa::compile(&p);
1716        // "aa b aa" -> greedy A* consumes runs of a, emits non-empty ones.
1717        assert_eq!(
1718            nfa.find_matches(&rows("aabaa"), &SkipMode::PastLastRow),
1719            spans(&[(0, 2), (3, 5)])
1720        );
1721        // all-non-matching -> no matches, terminates.
1722        assert_eq!(
1723            nfa.find_matches(&rows("xxx"), &SkipMode::PastLastRow),
1724            spans(&[])
1725        );
1726    }
1727
1728    fn lbl(s: &str) -> Vec<String> {
1729        s.chars().map(|c| c.to_string()).collect()
1730    }
1731
1732    #[test]
1733    fn labeled_concat() {
1734        // A B -> rows labelled a, b.
1735        let p = Pattern::Concat(vec![vars("a"), vars("b")]);
1736        let nfa = Nfa::compile(&p);
1737        assert_eq!(
1738            nfa.longest_match_labeled(&rows("ab"), 0),
1739            Some((2, lbl("ab")))
1740        );
1741    }
1742
1743    #[test]
1744    fn labeled_plus_greedy() {
1745        // A B+ on a b b -> labels a, b, b (greedy consumes both b's).
1746        let p = Pattern::Concat(vec![
1747            vars("a"),
1748            Pattern::Quantified(Box::new(vars("b")), Quantifier::Plus, false),
1749        ]);
1750        let nfa = Nfa::compile(&p);
1751        assert_eq!(
1752            nfa.longest_match_labeled(&rows("abb"), 0),
1753            Some((3, lbl("abb")))
1754        );
1755    }
1756
1757    #[test]
1758    fn labeled_alternation() {
1759        // (A | B) C on b c -> labels b, c.
1760        let p = Pattern::Concat(vec![Pattern::Alt(vec![vars("a"), vars("b")]), vars("c")]);
1761        let nfa = Nfa::compile(&p);
1762        assert_eq!(
1763            nfa.longest_match_labeled(&rows("bc"), 0),
1764            Some((2, lbl("bc")))
1765        );
1766    }
1767
1768    #[test]
1769    fn labeled_permute() {
1770        // PERMUTE(a, b) on b a -> labels b, a.
1771        let p = Pattern::Permute(vec!["a".to_owned(), "b".to_owned()]);
1772        let nfa = Nfa::compile(&p);
1773        assert_eq!(
1774            nfa.longest_match_labeled(&rows("ba"), 0),
1775            Some((2, lbl("ba")))
1776        );
1777    }
1778
1779    #[test]
1780    fn find_matches_labeled_carries_labels() {
1781        // A B repeated -> two labelled matches.
1782        let p = Pattern::Concat(vec![vars("a"), vars("b")]);
1783        let nfa = Nfa::compile(&p);
1784        assert_eq!(
1785            nfa.find_matches_labeled(&rows("abab"), &SkipMode::PastLastRow),
1786            vec![
1787                LabeledMatch {
1788                    start: 0,
1789                    end: 2,
1790                    labels: lbl("ab")
1791                },
1792                LabeledMatch {
1793                    start: 2,
1794                    end: 4,
1795                    labels: lbl("ab")
1796                },
1797            ]
1798        );
1799    }
1800
1801    #[test]
1802    fn skip_to_first_last_var() {
1803        // Pattern (a b b) over five rows that each satisfy both `a` and `b`, so matches can overlap.
1804        // The skip strategy decides where each next match starts:
1805        //   PAST LAST ROW -> one match [0,3)
1806        //   SKIP TO LAST b -> [0,3), [2,5)      (resume at the match's last `b`)
1807        //   SKIP TO FIRST b -> [0,3), [1,4), [2,5)  (resume at the match's first `b`)
1808        let p = Pattern::Concat(vec![vars("a"), vars("b"), vars("b")]);
1809        let nfa = Nfa::compile(&p);
1810        let rows = vec![BTreeSet::from(["a".to_owned(), "b".to_owned()]); 5];
1811
1812        let starts = |skip: &SkipMode| {
1813            nfa.find_matches_labeled(&rows, skip)
1814                .into_iter()
1815                .map(|m| m.start)
1816                .collect::<Vec<_>>()
1817        };
1818        assert_eq!(starts(&SkipMode::PastLastRow), vec![0]);
1819        assert_eq!(starts(&SkipMode::ToLast("b".to_owned())), vec![0, 2]);
1820        assert_eq!(starts(&SkipMode::ToFirst("b".to_owned())), vec![0, 1, 2]);
1821    }
1822
1823    /// The two data-dependent cases where a variable-targeted skip has no valid resume row degrade
1824    /// (deliberately, instead of raising the runtime error the SQL standard prescribes — see
1825    /// [`SkipMode::next_pos`]). The degradation must be *visible*, so `next_pos` returns a
1826    /// diagnostic alongside the position, and the position itself must be unchanged: this is a
1827    /// visibility fix, not a behavior change.
1828    #[test]
1829    fn skip_target_degradations_are_reported() {
1830        // A match over rows 3..5, labelled `a` then `b`.
1831        let labels = lbl("ab");
1832
1833        // (a) The target is bound to no row of this match: nothing to resume at, so the scan resumes
1834        //     past the match's last row — silently becoming SKIP PAST LAST ROW.
1835        assert_eq!(
1836            SkipMode::ToLast("c".to_owned()).next_pos(3, 5, &labels),
1837            (5, Some(SkipDegradation::TargetAbsent))
1838        );
1839        assert_eq!(
1840            SkipMode::ToFirst("c".to_owned()).next_pos(3, 5, &labels),
1841            (5, Some(SkipDegradation::TargetAbsent))
1842        );
1843
1844        // (b) The target resolves to the match's own first row: resuming there would re-find the
1845        //     same match forever, so it is clamped one row on — silently becoming SKIP TO NEXT ROW.
1846        assert_eq!(
1847            SkipMode::ToFirst("a".to_owned()).next_pos(3, 5, &labels),
1848            (4, Some(SkipDegradation::TargetAtMatchStart))
1849        );
1850        assert_eq!(
1851            SkipMode::ToLast("a".to_owned()).next_pos(3, 5, &labels),
1852            (4, Some(SkipDegradation::TargetAtMatchStart))
1853        );
1854
1855        // A resolvable target reports nothing — note it can land on the same position as the clamped
1856        // case above, so the diagnostic (not the position) is what distinguishes them.
1857        assert_eq!(
1858            SkipMode::ToLast("b".to_owned()).next_pos(3, 5, &labels),
1859            (4, None)
1860        );
1861        // The variable-less modes cannot degrade.
1862        assert_eq!(SkipMode::PastLastRow.next_pos(3, 5, &labels), (5, None));
1863        assert_eq!(SkipMode::ToNextRow.next_pos(3, 5, &labels), (4, None));
1864    }
1865
1866    /// The diagnostic names the target variable and the strategy the resume position degraded to. The
1867    /// skip mode itself is named by the caller (`clause_name`), so it must NOT be repeated here — the
1868    /// rendered message would otherwise say `SKIP` twice.
1869    #[test]
1870    fn skip_degradation_describes_target_and_fallback() {
1871        let absent = SkipDegradation::TargetAbsent.describe(&SkipMode::ToLast("c".to_owned()));
1872        assert!(absent.contains("target variable `c`"), "{absent}");
1873        assert!(absent.contains("SKIP PAST LAST ROW"), "{absent}");
1874        assert!(!absent.contains("SKIP TO LAST"), "{absent}");
1875
1876        let at_start =
1877            SkipDegradation::TargetAtMatchStart.describe(&SkipMode::ToFirst("a".to_owned()));
1878        assert!(at_start.contains("target variable `a`"), "{at_start}");
1879        assert!(at_start.contains("SKIP TO NEXT ROW"), "{at_start}");
1880        assert!(!at_start.contains("SKIP TO FIRST"), "{at_start}");
1881    }
1882
1883    /// The clause name feeds the `ExprError` parameter name, so it must state the mode without the
1884    /// target variable (which `describe` names) — one mention of the mode per rendered message.
1885    #[test]
1886    fn skip_clause_name_and_target_var() {
1887        assert_eq!(
1888            SkipMode::ToLast("c".to_owned()).clause_name(),
1889            "AFTER MATCH SKIP TO LAST"
1890        );
1891        assert_eq!(
1892            SkipMode::ToFirst("a".to_owned()).clause_name(),
1893            "AFTER MATCH SKIP TO FIRST"
1894        );
1895        assert_eq!(
1896            SkipMode::PastLastRow.clause_name(),
1897            "AFTER MATCH SKIP PAST LAST ROW"
1898        );
1899        assert_eq!(
1900            SkipMode::ToNextRow.clause_name(),
1901            "AFTER MATCH SKIP TO NEXT ROW"
1902        );
1903
1904        assert_eq!(SkipMode::ToLast("c".to_owned()).target_var(), Some("c"));
1905        assert_eq!(SkipMode::ToFirst("a".to_owned()).target_var(), Some("a"));
1906        // The variable-less modes have no target — and can never degrade.
1907        assert_eq!(SkipMode::PastLastRow.target_var(), None);
1908        assert_eq!(SkipMode::ToNextRow.target_var(), None);
1909    }
1910
1911    #[test]
1912    fn row_satisfying_multiple_vars() {
1913        // Overlapping DEFINE predicates: a row can satisfy several variables.
1914        let p = Pattern::Concat(vec![vars("a"), vars("b")]);
1915        let nfa = Nfa::compile(&p);
1916        let rows = vec![
1917            BTreeSet::from(["a".to_owned(), "b".to_owned()]),
1918            BTreeSet::from(["b".to_owned()]),
1919        ];
1920        assert_eq!(nfa.longest_match(&rows, 0), Some(2));
1921    }
1922
1923    /// [`Nfa::next_match`] is lazy: pulling one match must not evaluate predicates past that
1924    /// match's scan region, and pulling to exhaustion must enumerate exactly what
1925    /// [`Nfa::find_matches_dynamic`] collects. The emit loop relies on the first property to stop
1926    /// scanning at a held boundary match without paying for (or materializing) the rest.
1927    #[tokio::test]
1928    async fn next_match_is_lazy_and_equivalent() {
1929        use std::sync::atomic::{AtomicUsize, Ordering};
1930
1931        struct CountingMatcher {
1932            rows: Vec<BTreeSet<String>>,
1933            calls: AtomicUsize,
1934        }
1935        impl CandidateMatcher for CountingMatcher {
1936            async fn matches(
1937                &self,
1938                var: &str,
1939                pos: usize,
1940                _labels: &[String],
1941            ) -> StreamExecutorResult<bool> {
1942                self.calls.fetch_add(1, Ordering::Relaxed);
1943                Ok(self.rows[pos].contains(var))
1944            }
1945        }
1946
1947        let nfa = Nfa::compile(&Pattern::Concat(vec![vars("a"), vars("b")]));
1948        let r = rows("ababab");
1949
1950        // Pull ONE match, then compare predicate-evaluation counts against a full collect.
1951        let first_only = CountingMatcher {
1952            rows: r.clone(),
1953            calls: AtomicUsize::new(0),
1954        };
1955        let mut scan = MatchScan::new();
1956        let skip = SkipMode::PastLastRow;
1957        let first = nfa
1958            .next_match(
1959                &mut scan,
1960                r.len(),
1961                &first_only,
1962                &skip,
1963                &mut ScanBudget::unlimited(),
1964                false,
1965            )
1966            .await
1967            .unwrap()
1968            .expect("first match");
1969        assert_eq!((first.start, first.end), (0, 2));
1970        let one_pull = first_only.calls.load(Ordering::Relaxed);
1971
1972        let full = CountingMatcher {
1973            rows: r.clone(),
1974            calls: AtomicUsize::new(0),
1975        };
1976        let collected = nfa
1977            .find_matches_dynamic(r.len(), &full, &skip)
1978            .await
1979            .unwrap();
1980        let full_scan = full.calls.load(Ordering::Relaxed);
1981        assert!(
1982            one_pull < full_scan,
1983            "one pull ({one_pull} evaluations) must cost less than the full scan ({full_scan})"
1984        );
1985
1986        // Pulling to exhaustion enumerates exactly the collected sequence.
1987        let m = SetMatcher { rows: r.clone() };
1988        let mut scan = MatchScan::new();
1989        let mut pulled = Vec::new();
1990        while let Some(mm) = nfa
1991            .next_match(
1992                &mut scan,
1993                r.len(),
1994                &m,
1995                &skip,
1996                &mut ScanBudget::unlimited(),
1997                false,
1998            )
1999            .await
2000            .unwrap()
2001        {
2002            pulled.push(mm);
2003        }
2004        assert_eq!(pulled, collected);
2005    }
2006
2007    /// The catastrophic-backtracking family: `(a? a? … a? b)` over a run of `a`-rows costs
2008    /// exponentially many predicate evaluations per start unmemoized. The per-start `(state, pos)`
2009    /// failure memo (sound for path-independent verdicts, recorded only at consumption boundaries)
2010    /// must collapse that to polynomial — and must not change a single result.
2011    #[tokio::test]
2012    async fn memoization_defuses_catastrophic_backtracking() {
2013        use std::sync::atomic::{AtomicUsize, Ordering};
2014
2015        struct CountingMatcher {
2016            rows: Vec<BTreeSet<String>>,
2017            calls: AtomicUsize,
2018        }
2019        impl CandidateMatcher for CountingMatcher {
2020            async fn matches(
2021                &self,
2022                var: &str,
2023                pos: usize,
2024                _labels: &[String],
2025            ) -> StreamExecutorResult<bool> {
2026                self.calls.fetch_add(1, Ordering::Relaxed);
2027                Ok(self.rows[pos].contains(var))
2028            }
2029        }
2030
2031        // (a? ×16  b) over 20 `a`-rows and no `b`: every start fails, each exploring the
2032        // exponential subset lattice of which optionals consumed which rows.
2033        let mut parts: Vec<Pattern> = (0..16)
2034            .map(|_| Pattern::Quantified(Box::new(vars("a")), Quantifier::Question, false))
2035            .collect();
2036        parts.push(vars("b"));
2037        let nfa = Nfa::compile(&Pattern::Concat(parts));
2038        let r = rows(&"a".repeat(20));
2039
2040        let run = |memoize: bool| {
2041            let nfa = &nfa;
2042            let r = r.clone();
2043            async move {
2044                let m = CountingMatcher {
2045                    rows: r.clone(),
2046                    calls: AtomicUsize::new(0),
2047                };
2048                let mut budget = ScanBudget::unlimited();
2049                let mut scan = MatchScan::new();
2050                let mut out = Vec::new();
2051                while let Some(mm) = nfa
2052                    .next_match(
2053                        &mut scan,
2054                        r.len(),
2055                        &m,
2056                        &SkipMode::PastLastRow,
2057                        &mut budget,
2058                        memoize,
2059                    )
2060                    .await
2061                    .unwrap()
2062                {
2063                    out.push(mm);
2064                }
2065                (out, m.calls.load(Ordering::Relaxed))
2066            }
2067        };
2068
2069        let (plain_out, plain_calls) = run(false).await;
2070        let (memo_out, memo_calls) = run(true).await;
2071        assert_eq!(plain_out, memo_out);
2072        assert!(
2073            memo_calls * 20 < plain_calls,
2074            "memoized scan ({memo_calls} evaluations) must be far below the backtracking scan \
2075             ({plain_calls})"
2076        );
2077        // And the memoized cost is genuinely polynomial-small for this size.
2078        assert!(memo_calls < 50_000, "memoized: {memo_calls}");
2079    }
2080
2081    /// A spent [`ScanBudget`] stops the walks without verdicts: the finder yields no further
2082    /// match (never a wrong one), the liveness walker answers "not alive" only alongside the
2083    /// sticky `hit` flag (which the executor must check before believing it), and the extension
2084    /// probe answers "may extend" (hold).
2085    #[tokio::test]
2086    async fn spent_budget_stops_without_verdicts() {
2087        let nfa = Nfa::compile(&Pattern::Concat(vec![vars("a"), vars("b")]));
2088        let r = rows("ab");
2089        let m = SetMatcher { rows: r.clone() };
2090
2091        // Unlimited: the match is found.
2092        let mut budget = ScanBudget::unlimited();
2093        let mut scan = MatchScan::new();
2094        let found = nfa
2095            .next_match(
2096                &mut scan,
2097                r.len(),
2098                &m,
2099                &SkipMode::PastLastRow,
2100                &mut budget,
2101                false,
2102            )
2103            .await
2104            .unwrap();
2105        assert!(found.is_some());
2106        assert!(!budget.hit);
2107
2108        // Zero budget: no match, hit latched.
2109        let mut budget = ScanBudget::new(0);
2110        let mut scan = MatchScan::new();
2111        let found = nfa
2112            .next_match(
2113                &mut scan,
2114                r.len(),
2115                &m,
2116                &SkipMode::PastLastRow,
2117                &mut budget,
2118                false,
2119            )
2120            .await
2121            .unwrap();
2122        assert!(found.is_none());
2123        assert!(budget.hit);
2124
2125        // Liveness under zero budget: answers false with hit latched — the executor treats that
2126        // as "undecided, retain", never as "dead".
2127        let mut budget = ScanBudget::new(0);
2128        let alive = nfa
2129            .reaches_boundary_alive(0, 1, &m, &mut budget, false)
2130            .await
2131            .unwrap();
2132        assert!(!alive);
2133        assert!(budget.hit);
2134
2135        // Extension probe under zero budget: conservative "may extend".
2136        let mut budget = ScanBudget::new(0);
2137        assert!(nfa.may_extend(0, 2, &m, &mut budget, false).await.unwrap());
2138        assert!(budget.hit);
2139    }
2140
2141    #[tokio::test]
2142    async fn dynamic_matches_static_for_set_predicate() {
2143        let p = Pattern::Concat(vec![vars("a"), vars("b")]);
2144        let nfa = Nfa::compile(&p);
2145        let r = rows("abab");
2146        let m = SetMatcher { rows: r.clone() };
2147        let dynamic = nfa
2148            .find_matches_dynamic(r.len(), &m, &SkipMode::PastLastRow)
2149            .await
2150            .unwrap();
2151        assert_eq!(
2152            dynamic,
2153            nfa.find_matches_labeled(&r, &SkipMode::PastLastRow)
2154        );
2155    }
2156
2157    /// [`Nfa::may_extend`]: a boundary match whose PREFERRED result cannot change is final (emit
2158    /// now); one where a higher-priority path is blocked on the row boundary — an open greedy
2159    /// quantifier, an earlier-listed longer alternation branch, an unexhausted range, an optional
2160    /// tail — must be held. The check follows the finder's preference order: lower-priority paths
2161    /// (a later-listed alternation branch, a reluctant loop's consume edge) can never override an
2162    /// accepting result and must not hold it.
2163    #[tokio::test]
2164    async fn may_extend_follows_the_finder_preference_order() {
2165        let m = |s: &str| SetMatcher { rows: rows(s) };
2166
2167        // Fixed (a b): after consuming both, nothing can extend — terminal.
2168        let fixed = Nfa::compile(&Pattern::Concat(vec![vars("a"), vars("b")]));
2169        assert!(
2170            !fixed
2171                .may_extend(0, 2, &m("ab"), &mut ScanBudget::unlimited(), false)
2172                .await
2173                .unwrap()
2174        );
2175
2176        // (a b+): the greedy loop's consume edge precedes its exit, so a future `b` would produce
2177        // a preferred (longer) result — held.
2178        let open = Nfa::compile(&Pattern::Concat(vec![
2179            vars("a"),
2180            Pattern::Quantified(Box::new(vars("b")), Quantifier::Plus, false),
2181        ]));
2182        assert!(
2183            open.may_extend(0, 2, &m("ab"), &mut ScanBudget::unlimited(), false)
2184                .await
2185                .unwrap()
2186        );
2187
2188        // (a (b | b c)): ordered alternation — the first-listed `b` branch already accepted, and a
2189        // later `c` cannot override it. Terminal, matching what the finder would return.
2190        let alt = Nfa::compile(&Pattern::Concat(vec![
2191            vars("a"),
2192            Pattern::Alt(vec![vars("b"), Pattern::Concat(vec![vars("b"), vars("c")])]),
2193        ]));
2194        assert!(
2195            !alt.may_extend(0, 2, &m("ab"), &mut ScanBudget::unlimited(), false)
2196                .await
2197                .unwrap()
2198        );
2199
2200        // (a (b c | b)): the LONGER branch is listed first, so a future `c` would produce a
2201        // preferred result — held. Preference direction, not structure, decides.
2202        let alt_rev = Nfa::compile(&Pattern::Concat(vec![
2203            vars("a"),
2204            Pattern::Alt(vec![Pattern::Concat(vec![vars("b"), vars("c")]), vars("b")]),
2205        ]));
2206        assert!(
2207            alt_rev
2208                .may_extend(0, 2, &m("ab"), &mut ScanBudget::unlimited(), false)
2209                .await
2210                .unwrap()
2211        );
2212
2213        // (a b+?): reluctant — the exit edge precedes the consume edge, so the short result is the
2214        // preferred one and future `b`s cannot change it. Terminal.
2215        let reluctant = Nfa::compile(&Pattern::Concat(vec![
2216            vars("a"),
2217            Pattern::Quantified(Box::new(vars("b")), Quantifier::Plus, true),
2218        ]));
2219        assert!(
2220            !reluctant
2221                .may_extend(0, 2, &m("ab"), &mut ScanBudget::unlimited(), false)
2222                .await
2223                .unwrap()
2224        );
2225
2226        // (a b{1,2}): one `b` leaves the range open; two exhaust it.
2227        let range = Nfa::compile(&Pattern::Concat(vec![
2228            vars("a"),
2229            Pattern::Quantified(
2230                Box::new(vars("b")),
2231                Quantifier::Range {
2232                    min: 1,
2233                    max: Some(2),
2234                },
2235                false,
2236            ),
2237        ]));
2238        assert!(
2239            range
2240                .may_extend(0, 2, &m("ab"), &mut ScanBudget::unlimited(), false)
2241                .await
2242                .unwrap()
2243        );
2244        assert!(
2245            !range
2246                .may_extend(0, 3, &m("abb"), &mut ScanBudget::unlimited(), false)
2247                .await
2248                .unwrap()
2249        );
2250
2251        // (a b?): the optional tail extends a bare [a]; a consumed (a, b) is terminal.
2252        let opt = Nfa::compile(&Pattern::Concat(vec![
2253            vars("a"),
2254            Pattern::Quantified(Box::new(vars("b")), Quantifier::Question, false),
2255        ]));
2256        assert!(
2257            opt.may_extend(0, 1, &m("a"), &mut ScanBudget::unlimited(), false)
2258                .await
2259                .unwrap()
2260        );
2261        assert!(
2262            !opt.may_extend(0, 2, &m("ab"), &mut ScanBudget::unlimited(), false)
2263                .await
2264                .unwrap()
2265        );
2266    }
2267
2268    #[tokio::test]
2269    async fn reaches_boundary_alive_evicts_dead_prefix() {
2270        // PATTERN (a b): a start is live only if a match from it can still reach the safe boundary.
2271        let nfa = Nfa::compile(&Pattern::Concat(vec![vars("a"), vars("b")]));
2272
2273        // `[a]` with the boundary right after it: the `a` is a live partial match — a future `b` may
2274        // complete it — so it must be retained.
2275        let m = SetMatcher { rows: rows("a") };
2276        assert!(
2277            nfa.reaches_boundary_alive(0, 1, &m, &mut ScanBudget::unlimited(), false)
2278                .await
2279                .unwrap()
2280        );
2281
2282        // `[a, x]` and `[a, x, x]` (x satisfies neither `a` nor `b`): the `a` can still *begin* the
2283        // pattern, but the following safe rows already block it from completing, so it is dead and
2284        // must be evictable. This is the case the previous `can_begin_at`-based predicate retained
2285        // forever.
2286        let m = SetMatcher { rows: rows("ax") };
2287        assert!(
2288            !nfa.reaches_boundary_alive(0, 2, &m, &mut ScanBudget::unlimited(), false)
2289                .await
2290                .unwrap()
2291        );
2292        let m = SetMatcher { rows: rows("axx") };
2293        assert!(
2294            !nfa.reaches_boundary_alive(0, 3, &m, &mut ScanBudget::unlimited(), false)
2295                .await
2296                .unwrap()
2297        );
2298
2299        // A later start can be the live one: in `[x, a]` row 0 is dead but row 1 (the `a`) is live.
2300        let m = SetMatcher { rows: rows("xa") };
2301        assert!(
2302            !nfa.reaches_boundary_alive(0, 2, &m, &mut ScanBudget::unlimited(), false)
2303                .await
2304                .unwrap()
2305        );
2306        assert!(
2307            nfa.reaches_boundary_alive(1, 2, &m, &mut ScanBudget::unlimited(), false)
2308                .await
2309                .unwrap()
2310        );
2311
2312        // A complete match sitting exactly at the boundary is not yet finalized (it needs a following
2313        // safe row to confirm maximality), so its start is still retained.
2314        let m = SetMatcher { rows: rows("ab") };
2315        assert!(
2316            nfa.reaches_boundary_alive(0, 2, &m, &mut ScanBudget::unlimited(), false)
2317                .await
2318                .unwrap()
2319        );
2320    }
2321
2322    /// A path-dependent matcher: `b` only matches once an `a` has been bound earlier in the match.
2323    /// This exercises threading the running labels into the predicate.
2324    struct NeedsPrecedingA;
2325    impl CandidateMatcher for NeedsPrecedingA {
2326        async fn matches(
2327            &self,
2328            var: &str,
2329            _pos: usize,
2330            labels: &[String],
2331        ) -> StreamExecutorResult<bool> {
2332            Ok(match var {
2333                "a" => true,
2334                "b" => labels.iter().any(|l| l == "a"),
2335                _ => false,
2336            })
2337        }
2338    }
2339
2340    #[tokio::test]
2341    async fn reluctant_quantifier_prefers_fewer() {
2342        // Three rows that each satisfy both `a` and `b`, so `a+ b` can stop early.
2343        let rows = vec![BTreeSet::from(["a".to_owned(), "b".to_owned()]); 3];
2344        let m = SetMatcher { rows: rows.clone() };
2345
2346        // Greedy `a+ b`: consume as many `a` as possible -> [0, 3) (a a b).
2347        let greedy = Nfa::compile(&Pattern::Concat(vec![
2348            Pattern::Quantified(Box::new(vars("a")), Quantifier::Plus, false),
2349            vars("b"),
2350        ]));
2351        assert_eq!(
2352            greedy
2353                .find_matches_dynamic(rows.len(), &m, &SkipMode::PastLastRow)
2354                .await
2355                .unwrap(),
2356            vec![LabeledMatch {
2357                start: 0,
2358                end: 3,
2359                labels: lbl("aab")
2360            }]
2361        );
2362
2363        // Reluctant `a+? b`: take the fewest `a` -> [0, 2) (a b), then [2, ...) finds nothing more.
2364        let reluctant = Nfa::compile(&Pattern::Concat(vec![
2365            Pattern::Quantified(Box::new(vars("a")), Quantifier::Plus, true),
2366            vars("b"),
2367        ]));
2368        assert_eq!(
2369            reluctant
2370                .find_matches_dynamic(rows.len(), &m, &SkipMode::PastLastRow)
2371                .await
2372                .unwrap(),
2373            vec![LabeledMatch {
2374                start: 0,
2375                end: 2,
2376                labels: lbl("ab")
2377            }]
2378        );
2379    }
2380
2381    /// `n` rows that each satisfy both `a` and `b`, so quantifier preference (not the predicates)
2382    /// decides how a match is split between variables.
2383    fn ab_rows(n: usize) -> Vec<BTreeSet<String>> {
2384        vec![BTreeSet::from(["a".to_owned(), "b".to_owned()]); n]
2385    }
2386
2387    fn plus(inner: Pattern, reluctant: bool) -> Pattern {
2388        Pattern::Quantified(Box::new(inner), Quantifier::Plus, reluctant)
2389    }
2390
2391    fn star(inner: Pattern, reluctant: bool) -> Pattern {
2392        Pattern::Quantified(Box::new(inner), Quantifier::Star, reluctant)
2393    }
2394
2395    #[tokio::test]
2396    async fn nested_reluctant_then_greedy_adjacent() {
2397        // `a*? a*` over three `a` rows. The reluctant first star takes as few as possible (zero) and
2398        // the greedy second star takes the rest, so the whole run is still consumed: [0, 3). This
2399        // guards against an empty-match or non-termination bug when two quantifiers over the same
2400        // variable sit adjacent with opposite preferences.
2401        let r = rows("aaa");
2402        let m = SetMatcher { rows: r.clone() };
2403        let nfa = Nfa::compile(&Pattern::Concat(vec![
2404            star(vars("a"), true),
2405            star(vars("a"), false),
2406        ]));
2407        assert_eq!(
2408            nfa.find_matches_dynamic(r.len(), &m, &SkipMode::PastLastRow)
2409                .await
2410                .unwrap(),
2411            vec![LabeledMatch {
2412                start: 0,
2413                end: 3,
2414                labels: lbl("aaa")
2415            }]
2416        );
2417    }
2418
2419    #[tokio::test]
2420    async fn nested_quantifier_preference_flips_split() {
2421        // Four rows that each satisfy both `a` and `b`, matched by `(<a-quant> b+)+`. The inner
2422        // first-variable quantifier's preference decides the split; the rest is greedy `b+`.
2423        let r = ab_rows(4);
2424        let m = SetMatcher { rows: r.clone() };
2425
2426        // Reluctant `a+?` takes the fewest `a` (one), then greedy `b+` takes the rest -> "abbb".
2427        let reluctant = Nfa::compile(&plus(
2428            Pattern::Concat(vec![plus(vars("a"), true), plus(vars("b"), false)]),
2429            false,
2430        ));
2431        assert_eq!(
2432            reluctant
2433                .find_matches_dynamic(r.len(), &m, &SkipMode::PastLastRow)
2434                .await
2435                .unwrap(),
2436            vec![LabeledMatch {
2437                start: 0,
2438                end: 4,
2439                labels: lbl("abbb")
2440            }]
2441        );
2442
2443        // Greedy `a+` takes as many `a` as it can while still leaving one row for the mandatory
2444        // `b+`, so it backtracks from four to three -> "aaab".
2445        let greedy = Nfa::compile(&plus(
2446            Pattern::Concat(vec![plus(vars("a"), false), plus(vars("b"), false)]),
2447            false,
2448        ));
2449        assert_eq!(
2450            greedy
2451                .find_matches_dynamic(r.len(), &m, &SkipMode::PastLastRow)
2452                .await
2453                .unwrap(),
2454            vec![LabeledMatch {
2455                start: 0,
2456                end: 4,
2457                labels: lbl("aaab")
2458            }]
2459        );
2460    }
2461
2462    #[tokio::test]
2463    async fn dynamic_threads_running_labels() {
2464        // (a b): `b` sees `a` in the running labels -> matches.
2465        let ab = Nfa::compile(&Pattern::Concat(vec![vars("a"), vars("b")]));
2466        let m = NeedsPrecedingA;
2467        assert_eq!(
2468            ab.find_matches_dynamic(2, &m, &SkipMode::PastLastRow)
2469                .await
2470                .unwrap(),
2471            vec![LabeledMatch {
2472                start: 0,
2473                end: 2,
2474                labels: lbl("ab")
2475            }]
2476        );
2477
2478        // (b a): `b` is first, the running labels are empty, so it cannot match -> no match.
2479        let ba = Nfa::compile(&Pattern::Concat(vec![vars("b"), vars("a")]));
2480        assert_eq!(
2481            ba.find_matches_dynamic(2, &m, &SkipMode::PastLastRow)
2482                .await
2483                .unwrap(),
2484            vec![]
2485        );
2486    }
2487
2488    /// A budget that dies inside a start must NOT leave the scan cursor past it: the cursor would
2489    /// then claim a verdict for a start whose walk never reached one. (It is not a fully-scanned
2490    /// marker either way — a hit jumps it past the whole match — but "aborted" and "decided" must
2491    /// stay distinguishable for a caller that resumes a pull.)
2492    #[tokio::test]
2493    async fn an_aborted_start_is_not_reported_as_scanned() {
2494        // `(a b) | a`: the preferred branch charges `a` then `b`; with a budget of 1 the walk dies
2495        // inside start 0 having never tried the second alternative, which DOES match there.
2496        let pat = Pattern::Alt(vec![
2497            Pattern::Concat(vec![Pattern::Var("a".into()), Pattern::Var("b".into())]),
2498            Pattern::Var("a".into()),
2499        ]);
2500        let nfa = Nfa::compile(&pat);
2501        let matcher = SetMatcher::new(vec![BTreeSet::from(["a".to_owned()]); 2]);
2502        let mut scan = MatchScan::new();
2503        let mut budget = ScanBudget::new(1);
2504        let found = nfa
2505            .next_match(
2506                &mut scan,
2507                2,
2508                &matcher,
2509                &SkipMode::PastLastRow,
2510                &mut budget,
2511                false,
2512            )
2513            .await
2514            .unwrap();
2515        assert!(
2516            found.is_none() && budget.hit,
2517            "the test needs the budget to die inside start 0"
2518        );
2519        assert_eq!(
2520            scan.next_start(),
2521            0,
2522            "start 0 was aborted with no verdict, so the cursor must still point at it"
2523        );
2524    }
2525
2526    #[test]
2527    fn max_match_rows_is_the_longest_path_or_none_for_a_cycle() {
2528        let var = |s: &str| Pattern::Var(s.to_owned());
2529        let concat = |s: &str| Pattern::Concat(s.split(' ').map(var).collect());
2530        let q = |p: Pattern, q: Quantifier| Pattern::Quantified(Box::new(p), q, false);
2531        let range = |min, max| Quantifier::Range { min, max };
2532        assert_eq!(Nfa::compile(&concat("a b c")).max_match_rows(), Some(3));
2533        assert_eq!(
2534            Nfa::compile(&Pattern::Alt(vec![concat("a b"), var("c")])).max_match_rows(),
2535            Some(2)
2536        );
2537        assert_eq!(
2538            Nfa::compile(&Pattern::Concat(vec![
2539                q(var("a"), Quantifier::Question),
2540                var("b")
2541            ]))
2542            .max_match_rows(),
2543            Some(2)
2544        );
2545        assert_eq!(
2546            Nfa::compile(&q(var("a"), range(2, Some(5)))).max_match_rows(),
2547            Some(5)
2548        );
2549        assert_eq!(
2550            Nfa::compile(&q(var("a"), range(600, Some(600)))).max_match_rows(),
2551            Some(600)
2552        );
2553        assert_eq!(
2554            Nfa::compile(&q(var("a"), Quantifier::Plus)).max_match_rows(),
2555            None
2556        );
2557        assert_eq!(
2558            Nfa::compile(&q(var("a"), Quantifier::Star)).max_match_rows(),
2559            None
2560        );
2561        assert_eq!(
2562            Nfa::compile(&q(var("a"), range(2, None))).max_match_rows(),
2563            None
2564        );
2565    }
2566
2567    /// With more rows to the boundary than an acyclic automaton can consume, a position is dead
2568    /// without a walk — a budget of 1 survives untouched. With exactly as many, the walk runs, and
2569    /// a match that ends on the boundary keeps its start alive.
2570    #[tokio::test]
2571    async fn far_from_the_boundary_an_acyclic_pattern_is_dead_without_a_walk() {
2572        let pat = Pattern::Quantified(
2573            Box::new(Pattern::Var("a".into())),
2574            Quantifier::Range {
2575                min: 600,
2576                max: Some(600),
2577            },
2578            false,
2579        );
2580        let nfa = Nfa::compile(&pat);
2581        let n_rows = 1300;
2582        let matcher = SetMatcher::new(vec![BTreeSet::from(["a".to_owned()]); n_rows]);
2583        let mut budget = ScanBudget::new(1);
2584        assert!(
2585            !nfa.reaches_boundary_alive(0, n_rows, &matcher, &mut budget, true)
2586                .await
2587                .unwrap()
2588        );
2589        assert!(!budget.hit, "the verdict must not have cost a walk");
2590        let mut budget = ScanBudget::unlimited();
2591        assert!(
2592            nfa.reaches_boundary_alive(700, n_rows, &matcher, &mut budget, true)
2593                .await
2594                .unwrap(),
2595            "a{{600}} from 700 accepts exactly on the boundary, which is alive"
2596        );
2597    }
2598
2599    /// A start whose walk finds no accept and never reaches the boundary died on rows that will
2600    /// never change: the scan proves it matchless forever. The proof runs contiguously from where
2601    /// the scan began, stops at a start that reached the boundary, and is not extended by a start
2602    /// the finder skipped as too short (that one is blocked at the boundary, not dead).
2603    #[tokio::test]
2604    async fn starts_that_die_below_the_boundary_are_proven_matchless() {
2605        let nfa = Nfa::compile(&Pattern::Concat(vec![
2606            Pattern::Var("a".into()),
2607            Pattern::Var("b".into()),
2608        ]));
2609        let scan_all = |seq: &str| {
2610            let nfa = &nfa;
2611            let matcher = SetMatcher::new(rows(seq));
2612            let n = seq.len();
2613            async move {
2614                let mut scan = MatchScan::new();
2615                let mut budget = ScanBudget::unlimited();
2616                let found = nfa
2617                    .next_match(
2618                        &mut scan,
2619                        n,
2620                        &matcher,
2621                        &SkipMode::PastLastRow,
2622                        &mut budget,
2623                        false,
2624                    )
2625                    .await
2626                    .unwrap();
2627                (found.map(|m| (m.start, m.end)), scan)
2628            }
2629        };
2630        // Starts 0, 1 and 2 all die below the boundary; start 3 is skipped as too short.
2631        let (found, scan) = scan_all("axaa").await;
2632        assert_eq!(found, None);
2633        assert_eq!(scan.matchless_upto(), 3);
2634        assert_eq!(scan.next_start(), 4);
2635        // Start 2 consumes `a` and stands at the boundary waiting for `b`: not matchless.
2636        let (found, scan) = scan_all("axa").await;
2637        assert_eq!(found, None);
2638        assert_eq!(scan.matchless_upto(), 2);
2639        // A match ends the contiguous run.
2640        let (found, scan) = scan_all("xab").await;
2641        assert_eq!(found, Some((1, 3)));
2642        assert_eq!(scan.matchless_upto(), 1);
2643    }
2644
2645    #[test]
2646    fn min_match_rows_is_the_shortest_accepting_path() {
2647        let var = |s: &str| Pattern::Var(s.to_owned());
2648        let concat = |s: &str| Pattern::Concat(s.split(' ').map(var).collect());
2649        let q = |p: Pattern, q: Quantifier| Pattern::Quantified(Box::new(p), q, false);
2650        assert_eq!(Nfa::compile(&concat("a b c")).min_match_rows(), 3);
2651        assert_eq!(
2652            Nfa::compile(&q(var("a"), Quantifier::Plus)).min_match_rows(),
2653            1
2654        );
2655        assert_eq!(
2656            Nfa::compile(&q(var("a"), Quantifier::Star)).min_match_rows(),
2657            0
2658        );
2659        assert_eq!(
2660            Nfa::compile(&Pattern::Concat(vec![
2661                q(var("a"), Quantifier::Question),
2662                var("b")
2663            ]))
2664            .min_match_rows(),
2665            1
2666        );
2667        assert_eq!(
2668            Nfa::compile(&Pattern::Alt(vec![concat("a b"), var("c")])).min_match_rows(),
2669            1
2670        );
2671        assert_eq!(
2672            Nfa::compile(&Pattern::Permute(vec!["a".into(), "b".into(), "c".into()]))
2673                .min_match_rows(),
2674            3
2675        );
2676        assert_eq!(
2677            Nfa::compile(&q(
2678                var("a"),
2679                Quantifier::Range {
2680                    min: 600,
2681                    max: Some(600),
2682                }
2683            ))
2684            .min_match_rows(),
2685            600
2686        );
2687    }
2688
2689    /// A start with fewer rows before the boundary than the shortest possible match is skipped
2690    /// without a walk — and the skip IS a verdict ("no match from here within these rows"), so the
2691    /// cursor advances past it exactly as it would after a failed walk. Here the whole buffer is
2692    /// shorter than `a{600}`: nothing is walked, and a budget of 1 survives untouched.
2693    #[tokio::test]
2694    async fn starts_too_close_to_the_boundary_are_skipped_without_a_walk() {
2695        let pat = Pattern::Quantified(
2696            Box::new(Pattern::Var("a".into())),
2697            Quantifier::Range {
2698                min: 600,
2699                max: Some(600),
2700            },
2701            false,
2702        );
2703        let nfa = Nfa::compile(&pat);
2704        let n_rows = 10;
2705        let matcher = SetMatcher::new(vec![BTreeSet::from(["a".to_owned()]); n_rows]);
2706        let mut scan = MatchScan::new();
2707        let mut budget = ScanBudget::new(1);
2708        let found = nfa
2709            .next_match(
2710                &mut scan,
2711                n_rows,
2712                &matcher,
2713                &SkipMode::PastLastRow,
2714                &mut budget,
2715                true,
2716            )
2717            .await
2718            .unwrap();
2719        assert!(found.is_none());
2720        assert!(!budget.hit, "no start was walked, so nothing was charged");
2721        assert_eq!(
2722            scan.next_start(),
2723            n_rows,
2724            "every start was skipped with a verdict, so the cursor passed them all"
2725        );
2726    }
2727
2728    /// The walkers used to recurse once per consumed row and once per ε-edge under a hard cap of
2729    /// 512 frames, so any match spanning more than a couple of hundred rows was permanently
2730    /// undecidable: the cap did not reset with the budget, and every visit died at the same depth.
2731    /// The walk is iterative now and only the budget bounds it. All three walkers reach this depth.
2732    #[tokio::test]
2733    async fn a_match_spanning_thousands_of_rows_is_decided() {
2734        let pat = Pattern::Quantified(Box::new(Pattern::Var("a".into())), Quantifier::Plus, false);
2735        let nfa = Nfa::compile(&pat);
2736        let n_rows = 2000;
2737        let matcher = SetMatcher::new(vec![BTreeSet::from(["a".to_owned()]); n_rows]);
2738
2739        let mut scan = MatchScan::new();
2740        let mut budget = ScanBudget::unlimited();
2741        let found = nfa
2742            .next_match(
2743                &mut scan,
2744                n_rows,
2745                &matcher,
2746                &SkipMode::PastLastRow,
2747                &mut budget,
2748                true,
2749            )
2750            .await
2751            .unwrap()
2752            .expect("a 2000-row greedy match must be found");
2753        assert_eq!((found.start, found.end), (0, n_rows));
2754        assert_eq!(found.labels.len(), n_rows);
2755        assert!(!budget.hit);
2756
2757        // The greedy `a+` is blocked at the boundary, so the match may still extend...
2758        let mut budget = ScanBudget::unlimited();
2759        assert!(
2760            nfa.may_extend(0, n_rows, &matcher, &mut budget, true)
2761                .await
2762                .unwrap()
2763        );
2764        assert!(!budget.hit);
2765        // ...and its start is alive there.
2766        let mut budget = ScanBudget::unlimited();
2767        assert!(
2768            nfa.reaches_boundary_alive(0, n_rows, &matcher, &mut budget, true)
2769                .await
2770                .unwrap()
2771        );
2772        assert!(!budget.hit);
2773    }
2774
2775    /// The reported case: the binder accepts repetition counts up to 1000, and `a{600}` compiles to
2776    /// a 600-copy chain the old depth cap could never walk to its accept. It must match exactly 600
2777    /// rows — no fewer, and not greedily more.
2778    #[tokio::test]
2779    async fn a_bounded_repetition_beyond_the_old_depth_cap_matches_exactly() {
2780        let pat = Pattern::Quantified(
2781            Box::new(Pattern::Var("a".into())),
2782            Quantifier::Range {
2783                min: 600,
2784                max: Some(600),
2785            },
2786            false,
2787        );
2788        let nfa = Nfa::compile(&pat);
2789        let spans = |n_rows: usize| {
2790            let nfa = &nfa;
2791            async move {
2792                let matcher = SetMatcher::new(vec![BTreeSet::from(["a".to_owned()]); n_rows]);
2793                nfa.find_matches_dynamic(n_rows, &matcher, &SkipMode::PastLastRow)
2794                    .await
2795                    .unwrap()
2796                    .into_iter()
2797                    .map(|m| (m.start, m.end))
2798                    .collect::<Vec<_>>()
2799            }
2800        };
2801        assert_eq!(
2802            spans(599).await,
2803            vec![],
2804            "599 rows cannot complete a{{600}}"
2805        );
2806        assert_eq!(spans(600).await, vec![(0, 600)]);
2807        assert_eq!(
2808            spans(1300).await,
2809            vec![(0, 600), (600, 1200)],
2810            "two exact matches; the 100-row tail cannot complete a third"
2811        );
2812    }
2813
2814    /// Guards on the position arithmetic that production builds (no overflow checks) would
2815    /// otherwise wrap silently. Under strict consistency — the default, and the test default —
2816    /// a breach panics through `consistency_panic!`; in the non-strict escape hatch it logs and
2817    /// takes the conservative branch instead.
2818    mod position_guards {
2819        use super::*;
2820
2821        /// Run `f` with strict consistency disabled — the escape hatch in which a breached guard
2822        /// logs and takes its conservative branch instead of panicking.
2823        async fn non_strict<F: std::future::Future>(f: F) -> F::Output {
2824            let config = risingwave_common::config::StreamingConfig {
2825                unsafe_disable_strict_consistency: true,
2826                ..Default::default()
2827            };
2828            crate::CONFIG.scope(std::sync::Arc::new(config), f).await
2829        }
2830
2831        /// A memo asked about a position before its start: previously `pos - base` wrapped and
2832        /// the slot-growth loop allocated until the process died.
2833        #[test]
2834        #[should_panic(expected = "inconsistency")]
2835        fn memo_position_before_base_is_reported_not_wrapped() {
2836            let mut memo = Memo::new(5, 10, 3);
2837            let _ = memo.is_failed(0, 3);
2838        }
2839
2840        /// A liveness question about a position past the fed boundary: previously `n_rows - pos`
2841        /// wrapped past `max_match_rows` and the position read as dead — an eviction.
2842        #[tokio::test]
2843        #[should_panic(expected = "inconsistency")]
2844        async fn liveness_past_the_boundary_is_reported_not_dead() {
2845            let nfa = Nfa::compile(&Pattern::Concat(vec![vars("a"), vars("b")]));
2846            let m = SetMatcher::new(rows("ab"));
2847            let _ = nfa
2848                .reaches_boundary_alive(3, 2, &m, &mut ScanBudget::unlimited(), false)
2849                .await;
2850        }
2851
2852        /// Non-strict: the memo forgets nothing it knows and learns nothing it cannot place — a
2853        /// below-base position is "not failed", and recording there is a no-op.
2854        #[tokio::test]
2855        async fn non_strict_memo_before_base_is_not_failed() {
2856            non_strict(async {
2857                let mut memo = Memo::new(5, 10, 3);
2858                memo.record_failure(1, 3);
2859                assert!(!memo.is_failed(1, 3));
2860                assert!(
2861                    memo.failed.is_empty(),
2862                    "nothing allocated for a position it cannot place"
2863                );
2864                // In range still works.
2865                memo.record_failure(1, 7);
2866                assert!(memo.is_failed(1, 7));
2867            })
2868            .await;
2869        }
2870
2871        /// Non-strict: a position past the boundary is retained, not evicted.
2872        #[tokio::test]
2873        async fn non_strict_liveness_past_the_boundary_is_alive() {
2874            non_strict(async {
2875                let nfa = Nfa::compile(&Pattern::Concat(vec![vars("a"), vars("b")]));
2876                let m = SetMatcher::new(rows("ab"));
2877                let alive = nfa
2878                    .reaches_boundary_alive(3, 2, &m, &mut ScanBudget::unlimited(), false)
2879                    .await
2880                    .unwrap();
2881                assert!(alive);
2882            })
2883            .await;
2884        }
2885    }
2886}