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