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