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 ¤t {
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}