fix(fts): match_phrase slop admits transpositions at Lucene cost 2 (#830) - #882
Conversation
) Root cause: both sloppy-phrase evaluators — the segment positional walk (xerj_fts::search::phrase_positions_match) and the memtable/stored-scan walk it was hand-mirrored by (phrase_walk, xerj-engine index.rs) — were in-order greedy gap walks: anchor on the first term, match each later term at the earliest position strictly AFTER the previous match, sum the gaps. A reordered pair can never satisfy "strictly after in query order", so a transposition never matched at ANY slop: {"match_phrase":{"t":{"query":"quick brown","slop":2}}} returned zero hits on a document reading `brown quick` at slop 2, 3, 4, … where Lucene/ES match it at distance 2. Lucene's SloppyPhraseMatcher class javadoc is explicit: for query "a b"~2, document "x a b a y" matches once as "a b" (distance 0) and once as "b a" (distance 2). The gap was documented in-code as a known divergence (and a7ff459 corrected the ast/planner comments to say in-order-only as a stopgap); this is the real fix. Mechanism (Lucene semantics, adapted — algorithm, not code — from lucene/core/src/java/org/apache/lucene/search/SloppyPhraseMatcher.java, Apache-2.0): slop is an edit distance in single-position term moves. Pick one document position p_i per phrase term i; the match distance is the span of the ADJUSTED positions, max(p_i - i) - min(p_i - i), and the phrase matches when some choice keeps it <= slop. Lucene computes exactly this: nextMatch (:205-:237) pops the minimum PhrasePositions from a pq ordered by adjusted position (pp.position = position - offset, PhrasePositions.java nextPosition) and measures matchLength = end - pp.position, advancing the popped minimum — the classic minimal k-list window sweep. An out-of-order pair costs >= 2 by construction (p_i - p_j >= 1 and j - i >= 1), so transpositions appear at slop 2 and never at slop 1. For in-order matches the span telescopes to the old summed-gaps value ((p_last - p_first) - (n-1)), so every previously-matching doc still matches: the change is strictly additive. Fix: - engine/crates/xerj-fts/src/search.rs: phrase_positions_match slop>0 arm replaced with the minimal-window sweep over adjusted positions (plain pointers instead of a pq; phrase term counts are small). The slop==0 exact-adjacency arm is unchanged. Repeated-term collisions — Lucene's advanceRpts rule that one document token cannot fill two phrase slots — are enforced by rejecting any window with two slots at the same document position ("a a" still does not match a doc holding one `a`). Now pub: it is THE evaluator for both arms. - engine/crates/xerj-engine/src/index.rs: phrase_walk no longer mirrors the segment walk — it materialises per-query-slot token-index lists (last_is_prefix folds the prefix expansion into the last slot's list) and calls the SAME xerj_fts function, so the two arms cannot drift and the hit set is flush-invariant by construction (the #218/#222/#230 regression class). match_phrase, match_phrase_prefix, and the multi_match phrase/phrase_prefix types all go through these two arms. - engine/crates/xerj-fts/src/lib.rs: export phrase_positions_match. - engine/crates/xerj-query/src/ast.rs, planner.rs: the a7ff459 stopgap comments (in-order only, "NOT transpositions") now overstated the limitation — replaced with the actual Lucene move-distance semantics. - CHANGELOG.md: Unreleased/Fixed entry. Test proof: - NEW engine/crates/xerj-engine/tests/match_phrase_slop_transposition.rs (3 tests: match_phrase, match_phrase_prefix, multi_match phrase; docs adj="quick brown fox", rev="brown quick fox", gap="quick lazy brown", ctrl; slop 0 -> {adj}, slop 1 -> {adj,gap} (transposition NOT admitted below 2), slop 2/3 -> {adj,gap,rev}; every case asserted pre-flush (memtable) AND post-flush (segment)). Verified failing on the unfixed code (fix stashed): all 3 fail exactly at the slop-2 rows with `rev` missing, pre-flush — the issue's repro. - xerj-fts unit tests: sloppy_phrase_transposition_semantics (cost-2 boundary, forward-gap cost, Lucene's javadoc doc shape, 3-term transposed middle pair), sloppy_phrase_repeat_needs_distinct_positions, phrase_query_slop_transposition (segment end-to-end via PhraseQuery). - Flipped tests/multi_match_phrase_positions.rs sloppy_phrase_is_in_order_only_unlike_lucene -> sloppy_phrase_admits_transpositions_like_lucene — that test's own doc said it pins the divergence and flips when the walk learns transpositions. - Suites: xerj-fts 88+1 pass, xerj-query 186+9 pass, xerj-engine --lib 664 pass + adjacent test files (es_compat_tests, multi_match_phrase_positions, multi_match_flush_parity, multi_match_scoring, multi_match_unmapped_field, query_string_default_field, search_bounded_under_ghosts, tied_score_resorts) all green. fmt + clippy -D warnings clean.
…ing the minimum (#830) Follow-up to 9d17311 on this branch, which review BLOCKED. Two defects, one of them a wrong-results regression that 9d17311 introduced and its own commit message then denied. Root cause (wrong results). The minimal-window sweep in phrase_positions_match advanced the pointer holding the MINIMUM adjusted position whenever a window was rejected — including when it was rejected for a repeat collision (two phrase slots sitting on one document token). The colliding slot and the minimum slot are usually different slots, so on a collision the sweep walked a NON-colliding pointer forward, and if that slot's term occurs once its list exhausts and the sweep returns false without ever trying the assignment that separates the repeats. Every sloppy phrase containing a repeated term could therefore lose matches it had before #830, at any slop: query "i said no no"~1 doc "i said uh no no" -> was true, became false query "a b b"~1 doc "a x b b" -> was true, became false query "no no no"~1 doc "no way no no" -> was true, became false (position lists [[0],[1],[3,4],[3,4]], [[0],[2,3],[2,3]], [[0,2,3]]x3; ES/Lucene match all three — "i said no no"~1 has adjusted positions 0,0,1,1, span 1). Lucene does not have this hole because advanceRpts (:317) advances the COLLIDING repeat, not the current minimum; 9d17311 cited advanceRpts and then implemented a different rule. Both arms delegate to this function, so the regression was flush-invariant and invariantly wrong. Fix: decide each window instead of guessing which pointer to move. Sweep every candidate window floor lo (each adjusted position is one, via forward-only cursors) and ask whether the window [lo, lo + slop] admits a choice of one position per slot with all positions DISTINCT — a system of distinct representatives, decided by bipartite matching (Kuhn) over the admissible run of each slot, truncated to n candidates (a slot with n or more candidates can always be re-pointed at a free one, so the truncation cannot turn a feasible window infeasible). The sweep is complete: any assignment with span <= slop lies entirely inside the window floored at that assignment's own minimum adjusted position. The slop==0 exact-adjacency arm is untouched. Cost is unchanged in order — the three pointer families only move forward, so the sweep is linear in the total number of positions, and the matching only runs on windows where slots actually collide (the earliest-candidate fast path decides everything else). Measured on 200k-position lists: no-match worst case 2.4ms for 2 distinct terms and 7.8ms for a 3x repeated term, against 5.2ms for 9d17311 and 4.0s for the pre-#830 quadratic walk. Honest-claims corrections (the second blocking finding). 9d17311's commit message, the PR body, the CHANGELOG entry and the search.rs doc comment all asserted that "every previously-matching doc still matches — the change is strictly additive". The telescoping argument behind it is sound for a FIXED assignment but says nothing about the predicate, and with the search over assignments incomplete the claim was false. It is true of THIS commit, and now stated as something checked rather than argued: - CHANGELOG.md: the additive claim now names the evidence (the exhaustive test below) and no longer says "flush-invariant by construction" — the arms share an evaluator, not a position model. - search.rs / index.rs doc comments: same correction, plus the removal of the false statement that "distinct terms never share a document position" — SynonymFilter deliberately co-locates synonyms, so enforcing distinctness pairwise over ALL slots is stricter than Lucene on a synonym field. That divergence is now documented as deliberate and conservative (it can only withhold a match, and it is what the pre-#830 walk did) rather than asserted away. Test proof (all fail on 9d17311, verified by reverting only the algorithm and re-running): - xerj-fts search.rs sloppy_phrase_repeated_term_keeps_in_order_matches: the three cases above, plus the negative that still binds (three "no" slots cannot be filled by two document tokens at slop 10). - xerj-fts search.rs sloppy_phrase_agrees_with_brute_force_and_keeps_pre_830_hits: every document of length <= 5 and every phrase of length <= 3 over a 3-symbol alphabet at slop 0..=3 (56k cases), cross-checked against a brute-force reference AND against a copy of the pre-#830 walk, which must never match something the evaluator rejects. This is the "strictly additive" claim as a test. - xerj-engine tests/match_phrase_slop_transposition.rs: three new end-to-end cases (match_phrase with a repeated term, all-slots- repeated, and the same through multi_match), each asserted pre- and post-flush. - Out-of-tree, at the review's own standard: the four functions were extracted verbatim from search.rs and compiled standalone against the brute-force oracle — 2,360,880 exhaustive cases (docs <= 7 tokens, phrases <= 4, slop 0..=5) and 500,000 randomised sparse-position cases: 0 disagreements with brute force, 0 pre-#830 matches lost. Also corrected, from the review's non-blocking notes: the match_phrase_slop_transposition.rs module doc claimed every case is asserted against the segment positional arm post-flush, but the planner keeps single-field slop>0 phrases on the stored scan, so those rows re-run phrase_walk after the flush; and the [b, a] assertion in sloppy_phrase_transposition_semantics is satisfied by an in-order pair, which the comment now says outright (it pins the javadoc example, not transposition cost). Gates: cargo fmt --all --check clean; cargo clippy --release -p xerj-fts -p xerj-engine --all-targets -D warnings clean; xerj-fts 90 unit tests + 1 doc-test pass; match_phrase_slop_transposition 6/6 and multi_match_phrase_positions 12/12 pass.
Review response — both blocking findings addressed (9a93a36)Finding 1 (wrong results on repeated terms): confirmed and fixed. I reproduced it first, the same way the review did — both versions of The mechanism is exactly as described: on a repeat collision the sweep advanced the pointer at the minimum adjusted position, which is usually a non-colliding slot, so a single-occurrence slot exhausted before the assignment that separates the repeats was ever tried. The fix stops guessing which pointer to advance and decides each window instead. Sweep every candidate window floor Evidence, at the standard the review set — the four functions extracted verbatim from the new (docs <= 7 tokens over a 3-symbol alphabet, phrases <= 4, slop 0..=5; plus 500k randomised sparse-position cases that no small document can express. Regression tests, all three of the review's cases plus the negative that still binds, and each verified to fail when only the algorithm is reverted to the previous head:
Perf on 200k-position lists, no-match worst case: 2.4ms (2 distinct terms) and 7.8ms (3× repeated term), vs 5.2ms for the previous head and 4.0s for the pre-#830 walk. Same order, and the matching only runs on windows where slots actually collide. Finding 2 (false safety claim): corrected everywhere it appeared. The commit message, PR body, CHANGELOG entry and
Non-blocking notes. (1) The "distinct terms never share a document position" comment was false — Gates. Residual risk. Distinctness is enforced pairwise over all slots because the evaluator sees position lists, not term identity; on a synonym-analyzed field two different co-located query terms are therefore rejected where Lucene would accept. That is pre-existing (the old walk required strictly increasing positions), one-directional (never a false positive), and now documented rather than denied — worth its own issue if a synonym field ever needs it. |
What
match_phraseslopnow follows LuceneSloppyPhraseMatchermove-distance semantics: a transposed (reordered) pair costs 2, so{"match_phrase":{"t":{"query":"quick brown","slop":2}}}matches a document readingbrown quick— previously it returned zero hits at any slop.match_phrase_prefixand themulti_matchphrase/phrase_prefixtypes gain the same behavior (they share the evaluator). Closes the behavioral half of #830; the doc-accuracy half was the a7ff459 stopgap (PR #831), whose "in-order only" comments this PR now replaces with the real semantics.Root cause
Both sloppy-phrase evaluators — segment (
xerj_fts::search::phrase_positions_match) and the memtable/stored-scan walk hand-mirrored on it (phrase_walk, xerj-engineindex.rs) — were in-order greedy gap walks: anchor on the first term, match each later term at the earliest position strictly after the previous match, sum the gaps. A reordering can never satisfy "strictly after in query order", so transpositions never matched. Lucene's semantics (its class javadoc): for query"a b"~2, documentx a b a ymatches once asa b(distance 0) and once asb a(distance 2).Fix
Slop is an edit distance in single-position term moves: pick one document position
p_iper phrase termi; the distance is the span of the adjusted positions,max(p_i - i) - min(p_i - i); match when some choice keeps it<= slopand the chosen positions are distinct (one document token cannot fill two phrase slots — Lucene's repeat rule;"a a"does not match a doc holding onea). That is the distance Lucene'snextMatchcomputes (matchLength = end - pp.positionover a pq ofposition - offset, SloppyPhraseMatcher.java:205-237); algorithm adapted, not code (Apache-2.0, cited in the comments). Out-of-order pairs cost >= 2 by construction, so transpositions appear at slop 2 and never at slop 1.Deciding that predicate is the part the first round of this PR got wrong (see below). It is now a window sweep with an exact feasibility test: every adjusted position is a candidate window floor
lo, and the window[lo, lo + slop]is accepted iff it admits a system of distinct representatives — one position per slot, no position used twice — decided by bipartite matching over each slot's admissible run. The sweep is complete: any assignment with span<= sloplies entirely inside the window floored at its own minimum adjusted position.slop == 0keeps the exact-adjacency arm untouched.Both arms call the one evaluator:
phrase_walkmaterialises per-query-slot token-index position lists (the prefix slot folds its expansions into one list) and calls the samephrase_positions_matchthe segment positional clause uses, so slop cannot be evaluated differently on the two sides of a flush (#218/#222/#230 regression class). What the two arms do not share is a position model — the memtable arm re-analyses stored text with the standard analyzer while the segment arm reads indexed positions — so this is one evaluator, not flush-invariance "by construction"; a custom analyzer can still hand the arms different position lists.engine/crates/xerj-fts/src/search.rs— new slop>0 arm (window sweep + SDR); slop==0 arm unchanged; nowpubengine/crates/xerj-engine/src/index.rs—phrase_walkdelegates to itengine/crates/xerj-fts/src/lib.rs— exportengine/crates/xerj-query/src/{ast,planner}.rs— a7ff459's stopgap comments replaced with the actual semanticsCHANGELOG.md— Unreleased/Fixed entryReview round 2 — repeated terms (9a93a36)
Review blocked the first implementation and was right. The minimal-window sweep it used advanced the pointer at the minimum adjusted position whenever a window was rejected, including when the rejection was a repeat collision. The colliding slot and the minimum slot are usually different, so on a collision the sweep advanced a non-colliding pointer; when that slot's term occurs once, its list exhausts and the sweep returns
falsewithout ever trying the assignment that separates the repeats. Every sloppy phrase with a repeated term could lose matches it had before #830, at any slop:"i said no no"~1,2,3,5,10i said uh no no"a b b"~1,2,4a x b b"no no no"~1,2,3no way no noES/Lucene match all three (
"i said no no"~1has adjusted positions 0,0,1,1 — span 1). Lucene avoids the hole becauseadvanceRpts(:317) advances the colliding repeat rather than the current minimum; the first round citedadvanceRptsand implemented a different rule. Both arms delegate, so the regression was flush-invariant and invariantly wrong. The fix decides each window exactly (the SDR test above) instead of guessing which pointer to move.Cost is unchanged in order: the pointer families only move forward (linear in total positions), and the matching runs only on windows where slots actually collide. Measured on 200k-position lists, worst case with no match: 2.4ms for two distinct terms, 7.8ms for a 3× repeated term — against 5.2ms for the first round and 4.0s for the pre-#830 quadratic walk.
Claims corrected. The first round's PR body, commit message, CHANGELOG entry and doc comment all said "every previously-matching doc still matches — the change is strictly additive". The telescoping argument behind it is sound for a fixed assignment but says nothing about the predicate, and with the search over assignments incomplete the claim was false. It is true of the current head, and now stated as something checked rather than argued (the exhaustive test below). The CHANGELOG no longer says "flush-invariant by construction", and the doc comment no longer says "distinct terms never share a document position" —
SynonymFilterdeliberately co-locates synonyms, so enforcing distinctness pairwise over all slots is stricter than Lucene on a synonym field; that divergence is now documented as deliberate and conservative (it can only withhold a match, and it is what the pre-#830 walk did) instead of asserted away.Test proof
Fails on main:
New
engine/crates/xerj-engine/tests/match_phrase_slop_transposition.rs—match_phrase,match_phrase_prefix, andmulti_matchphrase over docsadj="quick brown fox",rev="brown quick fox",gap="quick lazy brown": slop 0 → {adj}, slop 1 → {adj, gap} (transposition not admitted below 2), slop 2/3 → {adj, gap, rev}; every case asserted pre-flush and post-flush. Run against the unfixed code, all three fail exactly at the slop-2 rows:Fails on the first round of this PR (verified by reverting only the algorithm and re-running):
sloppy_phrase_repeated_term_keeps_in_order_matches(xerj-fts) — the three rows in the table above, plus the negative that still binds: threenoslots cannot be filled by two document tokens at slop 10.sloppy_phrase_agrees_with_brute_force_and_keeps_pre_830_hits(xerj-fts) — every document of length <= 5 and every phrase of length <= 3 over a 3-symbol alphabet at slop 0..=3 (56k cases), cross-checked against a brute-force reference and against a copy of the pre-es-compat: match_phrase slop is in-order only — transpositions never match (diverges from Lucene SloppyPhraseMatcher) #830 walk, which must never match something the evaluator rejects. That is the "strictly additive" claim as a test rather than a sentence.match_phrase_slop_repeated_term_still_matches,match_phrase_slop_all_slots_repeated,multi_match_phrase_slop_repeated_term_still_matches(xerj-engine) — the same three cases end to end, pre- and post-flush.Out of tree, at the review's own standard: the four functions extracted verbatim from
search.rsand compiled standalone against the brute-force oracle — 2,360,880 exhaustive cases (docs <= 7 tokens, phrases <= 4, slop 0..=5) and 500,000 randomised sparse-position cases: 0 disagreements with brute force, 0 pre-#830 matches lost.Also corrected from the review's non-blocking notes: the
match_phrase_slop_transposition.rsmodule doc claimed every case is asserted against the segment positional arm post-flush, but the planner keeps single-fieldslop > 0phrases on the stored scan, so those rows re-runphrase_walkafter the flush (the segment arm is covered by the xerj-fts unit tests and themulti_matchcase); and the[b, a]assertion insloppy_phrase_transposition_semanticsis satisfied by an in-order pair, which the comment now says outright.Gates
cargo fmt --all --checkclean;cargo clippy --release -p xerj-fts -p xerj-engine --all-targets -- -D warningsclean; xerj-fts full suite (90 unit + 1 doc-test) green;match_phrase_slop_transposition6/6 andmulti_match_phrase_positions12/12 green; xerj-engine--lib phraseprojection tests 6/6 green.Closes #830.