Interval queries built from analyzed text lose stacked synonyms as soon as some other word of the same text has a multi-word synonym.
wifi spans the two positions of wi fi, and canine and dog are stacked at the last position. The builder keeps the first stacked token and drops dog. Select the still to open it at full size. Generated with AI, checked against the code.- Give the query analyzer two
SynonymGraphFilterrules,wifi => wi fi(two words) anddog => canine(one word), and index 4 documents: "wifi dog", "wifi canine", "wi fi dog", "wi fi canine". - Search with
Intervals.analyzedText("wifi dog", analyzer, "f", 0, true), orfn:or("wifi dog")in the flexible query parser. You expect all 4:QueryBuilderwith the same analyzer finds 4, andanalyzedText("dog")alone keepsor(canine, dog). - You get 2, "wifi canine" and "wi fi canine". The document with exactly the query text is missed: the built query is
or(BLOCK(wi,fi,canine), BLOCK(wifi,canine)), anddogis gone. - Why:
wifispans two positions, so the whole text takes the builder's token-graph path. At a position with no multi-word alternative that path takes only the first token stacked there (canine, emitted first) and asserts there is no second one. - With assertions on, building the query throws
AssertionErroratIntervalBuilder.java:233; with them off (production) matches are silently lost, in every gap and order mode.WordDelimiterGraphFilterwithKeywordRepeatFilterand a stemmer does the same with no synonyms at all.
Applications that build interval queries with Intervals.analyzedText or the flexible parser's fn: functions, with a query analyzer that mixes a multi-word synonym (or WordDelimiterGraphFilter catenation) with a stacked token elsewhere (a one-word synonym, or KeywordRepeatFilter and a stemmer). Every such query loses the stacked alternatives outside the multi-position token's span, at any index size; the index is fine.
Mechanism, terms and numbers585 words
Intervals.analyzedText turns analyzed text into an interval source. When no token spans more than one position, tokens stacked at one position (a single-word synonym, or a stem next to the original word) are ORed. As soon as one token spans two or more positions (a multi-word synonym, a WordDelimiterGraphFilter catenation), the whole text goes through IntervalBuilder.analyzeGraph, which, at every position without a multi-word alternative, keeps only the first finite string and asserts there is no second one. Stacked tokens of equal length are exactly such a position with several strings, so all but the first emitted token are dropped with assertions off, and AssertionError is thrown with assertions on. With wifi => wi fi and dog => canine, analyzedText("wifi dog") finds 2 of 4 documents and misses "wifi dog"; core QueryBuilder finds all 4. It is reached through both analyzedText overloads and the plain-term and quoted-text operands of the flexible StandardQueryParser fn: functions (not the patterns of fn:wildcard and fn:fuzzyTerm), in every release from 9.1.0 to 10.5.2 and on main. The index is not affected.
- Stacked token: a token with position increment 0, at the same position as the token before it, for example
canineon top ofdog. Interval queries should treat stacked tokens as alternatives,or(canine, dog). - Token graph: a token stream in which some token spans more than one position (position length > 1), for example
wifispanning the two positions ofwi fi. The paths through the graph are its finite strings:wi fi dog,wi fi canine,wifi dog,wifi canine. - Side path:
GraphTokenStreamFiniteStrings.hasSidePath(state)is true when alternatives of different length start at a state (wifiversuswi fi). Equal-length stacked tokens (dog,canine) are not a side path: they leave the state together and arrive together.
IntervalBuilder.analyzeTextnotes whether any token has position length > 1 (IntervalBuilder.java:95-98). If so, the whole text goes toanalyzeGraph(:109-111) instead ofanalyzeSynonyms, which would OR the stacked tokens (:172-199).analyzeGraph(:201-237) walks the graph segment by segment. A segment with a side path becomes an OR of phrases, one per finite string (:215-228). Any other segment runsTokenStream ts = it.next();once and thenassert it.hasNext() == false;(:230-233).- For
wifi dogthe last segment holdscanineanddog: no side path, two finite strings (the diagram probe printshasSidePath=false finiteStrings(in order)=["canine", "dog"]). Line 231 keepscanine, line 233 throws under-ea, and under-dadogis dropped. The built source isor(BLOCK(wi,fi,canine),BLOCK(wifi,canine)). - Which token survives is the one the analyzer emits first:
SynonymGraphFilteremits the synonymcaninebefore the originaldog, so the user's own word is the one lost.
| query, query-time analysis | expected | found |
|---|---|---|
analyzedText("wifi dog"), rules wifi => wi fi, dog => canine | 4 | 2 |
control: QueryBuilder.createPhraseQuery("wifi dog"), same analyzer | 4 | 4 |
control: analyzedText("dog") = or(canine,dog) | 4 | 4 |
flexible parser fn:or("wifi dog"); fn:ordered("wifi dog" park); fn:phrase("wifi dog" park) | 4 each | 2 each |
test framework MockSynonymAnalyzer: analyzedText("guinea pig dogs") | 4 | 2 |
WordDelimiterGraphFilter + KeywordRepeatFilter + PorterStemFilter + RemoveDuplicatesTokenFilter: analyzedText("wi-fi dogs") | 4 | 2 (the stem dog is dropped) |
a stacked position before the multi-word one (canned dog|canine then wifi|wi fi) | 4 | 2 |
With assertions on, every row except the controls throws java.lang.AssertionError while the query is built.
Exposure and likelihood317 words
- Entry points. Both
Intervals.analyzedTextoverloads, and the plain-term and quoted-text operands of the flexibleStandardQueryParserfn:functions (the grammar'sIntervalTextrule,StandardSyntaxParser.jj:681-686, thenAnalyzedText.java:38); the patterns offn:wildcardandfn:fuzzyTermare not analyzed. CoreQueryBuilderand the parsers built on it are not affected. - What it takes. In one query text, a token that spans several positions (a multi-word
SynonymGraphFilterrule, orWordDelimiterGraphFiltercatenation) and a token stacked at another position (a single-word synonym, orKeywordRepeatFilterwith a stemmer). Mixed multi-word and single-word synonym lists are an ordinary query-time setup. - Opt-in. The intervals package is marked experimental, and both executed triggers need query-time filters configured on purpose (Lucene's ready-made analyzers were not checked one by one); the index side needs nothing special (default writer, codec and searcher).
- Deterministic. Every such query text loses all but the first stacked token at each position that no multi-position token spans (stacked tokens inside such a span are kept), in every
(maxGaps, ordered)mode and at any index size; it does not depend on the documents. - Silent in production. With assertions off nothing signals the loss; the lost token can be the word the user typed. With assertions on, building the query throws
java.lang.AssertionError(throughStandardQueryParserit reaches the caller as that Error, not as aQueryNodeException). - Negation flips it. A
MUST_NOTclause or the subtrahend offn:notWithinbuilt this way returns documents it should exclude, including the one with exactly the excluded words (executed). Rated not a security issue: the dropped token is fixed by the query text and the analyzer, so index contents play no part (documents matter only if an application builds its query text from them). - Downstream (code reading only, not executed). Solr's
{!intervals}parser, merged in 2026 and not yet released, callsIntervals.analyzedText. Elasticsearch and OpenSearch carry their own copies of the same branch; a Lucene fix does not change those copies.
The test shows that Intervals.analyzedText over the token graph of "wifi dog" ("wi fi" | "wifi", then "canine" and "dog" stacked) keeps only the first stacked token, so the interval query misses the document "wifi dog".
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.queries.intervals;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.analysis.CannedTokenStream;
import org.apache.lucene.tests.analysis.Token;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestAnalyzedTextGraphStackedToken extends LuceneTestCase {
private static Token token(String term, int posInc, int posLength) {
Token t = new Token(term, 0, 0);
t.setPositionIncrement(posInc);
t.setPositionLength(posLength);
return t;
}
public void testStackedTokenInGraph() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new TextField("f", "wifi dog", Field.Store.NO));
w.addDocument(doc);
try (DirectoryReader r = DirectoryReader.open(w)) {
// query graph for "wifi dog": "wi fi" | "wifi", then "canine" and "dog" stacked
IntervalsSource source =
Intervals.analyzedText(
new CannedTokenStream(
token("wi", 1, 1),
token("wifi", 0, 2),
token("fi", 1, 1),
token("canine", 1, 1),
token("dog", 0, 1)),
0,
true);
// expected 1 hit ("wifi dog"); actual 0, "dog" is dropped (AssertionError with -ea)
IntervalQuery query = new IntervalQuery("f", source);
assertEquals(source.toString(), 1, new IndexSearcher(r).count(query));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-457/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.queries.intervals.TestAnalyzedTextGraphStackedToken
- Expected vs actual
- Expected 1 hit; actual 0 hits with -da (
or(BLOCK(wi,fi,canine),BLOCK(wifi,canine)) expected:<1> but was:<0>), and an AssertionError at IntervalBuilder.analyzeGraph with -ea.
More files, environment, full output30 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError
org.apache.lucene.queries.intervals.IntervalBuilder.analyzeGraph(IntervalBuilder.java:233)
org.apache.lucene.queries.intervals.IntervalBuilder.analyzeText(IntervalBuilder.java:111)
org.apache.lucene.queries.intervals.Intervals.analyzedText(Intervals.java:645)
org.apache.lucene.queries.intervals.TestAnalyzedTextGraphStackedToken.testStackedTokenInGraph(TestAnalyzedTextGraphStackedToken.java:50)- The code's own
assert it.hasNext() == false(IntervalBuilder.java:233) fails on stock filters through public API. - Without a multi-word token the same class keeps
or(canine,dog), and coreQueryBuilder.createPhraseQuerykeeps both on the same graph (4 of 4). - Core's graph test has asserted since 2017 that such a segment has two finite strings (
testGraphWithRegularSynonym).
The full argument222 words
- Javadoc.
analyzedText"Returns intervals that correspond to tokens from a TokenStream returned for text by applying the provided Analyzer" (Intervals.java:601-603). A stacked token is a token of that stream. - The class itself.
analyzeSynonymsORs stacked tokens (IntervalBuilder.java:172-199), and the side-path branch ORs every finite string (:215-228). Whetherdogis honoured should not depend on an unrelated word having a multi-word synonym. - Core parity. Core
QueryBuilderkeeps stacked tokens on the same graph. The executed control,createPhraseQuery, goes throughanalyzeGraphPhrase, one phrase per finite string (QueryBuilder.java:340-343,:566-583); by code reading,analyzeGraphBooleanwalks the same segments and keeps every stacked term of a segment without a side path throughgraph.getTerms(start)(:540-556). - Core test.
TestGraphTokenStreamFiniteStrings#testGraphWithRegularSynonym(fast|speedy wi|wifi fi network) assertshasSidePath(0) == falseand then the two finite stringsfastandspeedy(:467-473), since LUCENE-7638 (2017): the input the interval builder asserts away. - Upstream interval tests stack tokens only inside side paths (
TestIntervalBuilder#testGraphSynonyms*,testGraphTerminatesOnGap), which is why it stayed hidden;testSimpleSynonyms*assert the OR on the non-graph path. - History. The branch came with the method (LUCENE-10220, #427, 2021) and has not changed since; the introducing issue and PR never discuss graphs or synonyms, and no CHANGES, MIGRATE or javadoc text says stacked tokens are dropped.
The devil's-advocate check140 words
Strongest case for "intended": the intervals package is experimental; the javadoc never spells out synonym semantics; SynonymGraphFilter's javadoc says that fully correct positional queries over multi-token synonyms need a TermAutomatonQuery; the code was ported as-is from another engine that still has it; maybe the assert was valid when written.
Why it fails: the TermAutomatonQuery note is about multi-token replacements, while the token dropped here is a single-position one, often the user's own word; the class and core QueryBuilder keep stacked tokens on every other path; hasSidePath has meant "different length" since 2017, so the assert was never valid; and an AssertionError on stock filters through public API is not a design decision. Every checker concluded "not intended". One honest caveat: the miss with assertions off rests on this implicit contract, while the AssertionError with assertions on is unambiguous.
Recommended OR the terms of every finite string of a segment without a side path
In the graph path's branch for segments without a side path, OR the terms of every finite string instead of taking the first one (+11/-3 in IntervalBuilder.java). It passes the new tests, all 120 interval tests, the whole queries module (515 tests) and the highlighter and parser suites that use analyzedText.
} else {
// no side path: every finite string is a single token at this position, and there is
// more than one if synonyms are stacked here
List<IntervalsSource> synonyms = new ArrayList<>();
Iterator<TokenStream> it = graph.getFiniteStrings(start, end);
while (it.hasNext()) {
synonyms.addAll(analyzeTerms(it.next()));
}
if (synonyms.size() == 1) {
clauses.add(synonyms.get(0));
} else {
clauses.add(Intervals.or(synonyms.toArray(IntervalsSource[]::new)));
}
}All options and trade-offs4 options · 632 words
Option A was validated through gradle in a scratch worktree; option B through the standalone harness only.
OR the terms of every finite string of a segment without a side path (validated by the checks) (recommended)
the same hunk as the finder's sketch. In such a segment every transition from the start state reaches the same state, so each finite string is one token; analyzeTerms keeps the existing hole handling. The result is an or(...) of the stacked terms, as on the non-graph path; with a gap before the position, option A extends each term where analyzeSynonyms extends the OR (or(extend(a,h,0), extend(b,h,0)) against extend(or(a,b),h,0)), with the same matches in every tested case (the bounded-exhaustive harness, holes included, finds no graph-plus-stacked mismatch with the fix). A structural test for TestIntervalBuilder (stacked positions before and after a side path) fails on stock and passes with the fix.
} else {
// no side path: every finite string is a single token at this position, and there is
// more than one if synonyms are stacked here
List<IntervalsSource> synonyms = new ArrayList<>();
Iterator<TokenStream> it = graph.getFiniteStrings(start, end);
while (it.hasNext()) {
synonyms.addAll(analyzeTerms(it.next()));
}
if (synonyms.size() == 1) {
clauses.add(synonyms.get(0));
} else {
clauses.add(Intervals.or(synonyms.toArray(IntervalsSource[]::new)));
}
}Trade-offs
- No
maxClauseCountcheck, unlike the side-path branch (IntervalBuilder.java:221); the non-graph path has none either, and the number of alternatives is the number of tokens the analyzer emitted at that position. synonyms.size() == 1is only a shortcut:Intervals.orof one source returns that source. The OR order does not matter, since disjunction equality is set-based.- It relies on each finite string of a no-side-path segment being a single token (the code comment says so and every tested graph agrees, but
getFiniteStringsdoes not document it); if that changed,addAllwould turn a phrase into an OR of its words. The PR can assert one term per finite string, or use option B. - Not reviewed by a committer.
What the checks validated
- Stock: the new test fails 2 of 2 with assertions on and off; the structural test fails (got
ORDERED(term1,or(BLOCK(term3,term4),term2),term5)). - Fix: every interval suite of
lucene/queries, 8 classes, 120 tests (117 upstream plus the 3 new), 0 failures; the whole:lucene:queries:test, 52 classes, 515 tests, 0 failures, with assertions on and off. - Fix: the match and interval highlighter suites (4 classes, 164 tests;
TestMatchHighlighterusesanalyzedTextandfn:functions) andTestStandardQPEnhancements(23 tests), 0 failures;tidypasses (the fix hunk saved after it equals the finder's) and:lucene:queries:check -x testpasses. - Harness with the fix shadowed in: the
SynonymGraphFilter/fn:ortest 3/3, an independent reproducer's 6-test JUnit 6/6, and the bounded-exhaustive harness drops the graph-plus-stacked failures to 0 over 208,009,964 checks; the mismatch categories left, with and without the fix, all involve a term repeated across segments (knownRepeatingIntervalsSourcebehaviours and OR minimal-interval semantics the reference does not reproduce), not this bug.
one extend(Intervals.term(term), posInc - 1) per token returned by graph.getTerms(start), then Intervals.or(...) of them. Mirrors QueryBuilder.analyzeGraphBoolean (QueryBuilder.java:541).
Trade-offs
Skips building one token stream per stacked token, but repeats the hole handling of analyzeTerms by hand and needs one more import.
What the checks validated
Harness only: the new test 2/2, TestIntervalBuilder with the structural test 13/13 and the SynonymGraphFilter test 3/3, with assertions on and off. Not run through gradle or the wider suites.
Add the side-path branch's TooManyClauses check (IntervalBuilder.java:221-223) to either option.
Trade-offs
Consistency only; the non-graph path has no such check today. Untested.
State in the javadoc that stacked tokens are dropped when the text is a token graph.
Trade-offs
Leaves the AssertionError and contradicts the non-graph path and QueryBuilder.
While writing an independent reference semantics for query token graphs, the hunting AI tool suspected the no-side-path branch by reading it. A bounded-exhaustive harness then compared analyzedText with that reference over 24.9 million checks on stock main: apart from known repeated-term behaviours, every failure combined a multi-word token with a stacked one, and that class vanishes with the fix.
The method in full190 words
Method: bounded-exhaustive translation validation (a hunt unit, 2026-10-26, main ea57360912ed). No model checker, SMT solver or prover was involved.
- The finder (an AI tool) wrote an independent reference semantics for query token graphs: segments with alternatives, holes and multi-position side paths, and when a document matches in each
(maxGaps, ordered)mode. While writing it, it suspected the defect by readingIntervalBuilder.analyzeGraph. - The harness (
AtTV.java, public API only) compared every 1- and 2-segment query from a pool of 68 segments against every document of 1 to 4 positions over {a, b, c, d, z} in 6 modes: 24,882,731 checks on stock main with assertions off, plus 315,197,137 in larger runs on separate build machines. The failure category "graph plus stacked token" is this bug; with the fix shadowed it is 0 (208,009,964 checks). - Vacuity check: three deliberately broken copies of
IntervalBuilder(ignore holes, keep only the first synonym on the non-graph path, unordered side paths) were each caught in their own category. - Then a public-API JUnit, a parser reachability probe, independent verifiers, an adjudication, a release sweep, a prior-report search and a security screen (see Independent checks).
No prior report found: 205 read-only query records over GitHub issues and PRs, commits, the LUCENE and SOLR JIRA, the mailing lists, downstream repositories and the web (2026-10-26); 28 hits read, none this bug. No open PR touches IntervalBuilder.java.
Searches4 searches
None of the searched issues, PRs, commits, JIRA entries, list threads or downstream reports describes analyzedText or IntervalBuilder dropping stacked tokens of a token graph, or its assert it.hasNext() == false firing. Not fixed upstream: IntervalBuilder.java on upstream main a9779ef3c193 (2026-10-26) and on branch_10x is byte-identical to the tested commit. The searches show what was found, not that no report exists anywhere.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs, commits (apache/lucene) | 2026-10-26 | four runs of a scripted prior-report search (151 non-web queries in all: class and method names, hasSidePath, articulationPoints, AssertionError, synonym and graph wordings, fn: functions), 8 direct GitHub searches | no report; nearest: #11256 / #427 (LUCENE-10220, the introducing change, no graph discussion), #9523 (LUCENE-8477), #9185 (LUCENE-8137, open), #9743, #9959 (graph-helper fixes of 2019), #12080 (open SynonymGraphFilter position bug) |
| Open PRs | 2026-10-26 | the file lists of all 408 open apache/lucene PRs | none touches IntervalBuilder.java; #16239 and #14262 touch GraphTokenStreamFiniteStrings but not hasSidePath or getFiniteStrings (#16239 does make the articulation-point search behind articulationPoints() iterative, output said to be unchanged, so re-run the test if it merges); #16899, open then, has since been merged (f4cc6ac2097) and does not touch IntervalBuilder.java |
| JIRA archive and mailing lists | 2026-10-26 | LUCENE and SOLR JIRA (scripted prior-report search and 4 manual JQL queries); dev@, java-user@, issues@, commits@ and 9 extra list queries | no report |
| Downstream and web | 2026-10-26 | Elasticsearch, OpenSearch and Solr sources and issues; 19 web searches | no report; the same branch exists in the Elasticsearch and OpenSearch copies, and Solr's unreleased {!intervals} parser calls analyzedText (code reading only) |
Wrong since the method was added by LUCENE-10220 (#427, 2021-11-05; first released in 9.1.0); the branch has never changed. All 32 releases from 9.1.0 to 10.5.2 and main fail the same checks; 9.0.0 has no such API. Not fixed upstream.
Versions tested6 rows
Introduced by LUCENE-10220 "Add an utility method to get IntervalSource from analyzed text (or token stream)" (apache/lucene#427 by @dweiss, approved by @romseygeek; issue #11256), committed 2021-11-05 as 32d7f52446ae on main (first release 10.0.0) and 5de05f355622 on branch_9x (first release 9.1.0). git blame puts every line of the no-side-path branch on that commit. The file header says the code was adopted from the Elasticsearch 7.10 IntervalBuilder, which has the same branch (read only).
The branch's premise was already false then: hasSidePath has meant "side paths of different length", and the core test has asserted two finite strings for such a segment, since LUCENE-7638 (e764d3e0590, 2017-02-07). The flexible parser's fn: functions (LUCENE-10223, #429, 2021-11-11) were also first released in 9.1.0.
| Version or commit | Verdict | Result |
|---|---|---|
| 9.0.0 | no API | Intervals.analyzedText and IntervalBuilder do not exist (probe: API_ABSENT); 8.x and earlier cannot have them (IntervalBuilder was created in November 2021; not run) |
| 9.1.0 | first affected | the probe fails 6 of 6 modes with its control passing; the finder's JUnit and this doc's test fail; AssertionError at IntervalBuilder.java:234 with -ea |
| 9.2.0 to 9.12.3 (19 releases) | affected | the probe fails 6 of 6 modes, control passing, -ea and -da; this doc's test fails 2 of 2 on 9.12.3 |
| 10.0.0 to 10.5.2 (12 releases) | affected | the same; this doc's test fails 2 of 2 on 10.5.2; the AssertionError is at line 233 |
| main ea57360912ed | affected | this doc's test fails 2 of 2 through gradle, assertions on and off; upstream main a9779ef3c193 (2026-10-26) has the same IntervalBuilder.java |
| main with the fix | not affected | this doc's test 2 of 2, 120 interval tests and 515 queries-module tests pass |
An independent reproducer, a devil's advocate, an OpenAI GPT-5.6 reviewer arguing "works as designed" (reading only) and an adjudication say real; an earlier GPT-5.6 refute review said "not real" only because it could not run code. No reviewer from another model family has executed code for this bug.
Checks and verdicts419 words
All checks ran on main ea57360912ed with assertions on and off unless noted; the GPT-5.6 reviews read code only.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | The 3-test JUnit fails 3 of 3 on main, 9.1.0, 9.12.3 and 10.5.2 and passes 3 of 3 with the fix sketch shadowed. |
| Refute review (OpenAI GPT-5.6, reading only) | not real (low; procedural) | It could not run Java, and it voted not real on procedure only; it agreed with the root cause, found the inputs in contract and called the intended-behaviour case weak. |
| Independent reproducer | real | Its own 6-test JUnit, in a package outside Lucene, fails 5 of 6 on main, 9.12.3 and 10.5.2 with the non-graph control passing; it found that the query's own word can be lost, that a stacked position before the side path is hit too, and the WordDelimiterGraphFilter trigger; 6 of 6 pass with the fix. |
| "works as designed" reviewer (OpenAI GPT-5.6, reading only) | real (medium) | Weighed the experimental package, the TermAutomatonQuery note and the unstated synonym semantics and rejected them; separate executed re-runs fail on main, 9.1.0 and 10.5.2 and pass with the fix. |
| Devil's advocate | real | Built the strongest intended readings and showed why each fails; re-ran the finder's test (3 of 3 fail). |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's test with seed C0FFEE01 (5 of 6 fail, assertions on and off) and the finder's (3 of 3) and checked root cause, contract and history in the source. |
| Follow-up and audit re-runs | reproduced | The 32-release sweep, the security probe (1,542 lost and 0 added matches over 12,432 checks), and two re-runs of the finder's test on main. |
| This doc's test and fix validation | reproduced; fix validated | The new 2-test JUnit fails through gradle and the harness on main, 10.5.2, 9.12.3 and 9.1.0; option A passes 702 tests: the whole queries module (515, the 3 new tests included), 164 highlighter and 23 queryparser tests. |
| Independent fact checks of this doc | reproduced; wording fixed | A fact checker re-ran the gradle test (2 of 2 fail, assertions on and off), the harness on main, 10.5.2 and 9.1.0, seed C0FFEE01 x 10 (20 of 20 fail) and the fix (120 interval tests pass), and checked the cited lines, commits and counts; an OpenAI GPT-5.6 reviewer (reading only) found the bug real as described. Their wording findings (a wrong log path, the parser reach, the fix's form, scope and caveats) are fixed. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 484 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,437 words
- Registry title
- Intervals.analyzedText keeps only the first of the tokens stacked at a position whenever the analyzed text is a token graph (a multi-word synonym or a WordDelimiterGraphFilter catenation elsewhere in the text), so interval queries silently miss documents, even documents with exactly the query words (AssertionError under -ea)
- Area
- queries / intervals (Intervals.analyzedText: IntervalBuilder.analyzeGraph; flexible StandardQueryParser fn: interval functions)
- Priority
- Medium, rank 43: Intervals.analyzedText and the flexible parser's fn: functions have deterministically dropped stacked synonyms on token-graph text since 9.1, but Elasticsearch and OpenSearch use their own copies.
- Impact class
- silent wrong results
- Impact note
- Applications that build interval queries from analyzed text, through Intervals.analyzedText (both overloads), through every term or quoted argument of the flexible StandardQueryParser fn: interval functions, or (by code reading only) through Solr's still unreleased {!intervals} match rule, with a query-time analyzer that both builds a token graph (a multi-word SynonymGraphFilter rule, or WordDelimiterGraphFilter catenation) and stacks tokens at another position (a single-word synonym, or KeywordRepeatFilter with a stemmer), silently lose matches: every stacked alternative except the first one emitted is dropped, which can be the query's own word, in every (maxGaps, ordered) mode, at any index size, with the default writer, codec and searcher, with -da in every release from 9.1.0 through 10.5.2 and on main; with -ea building the query throws AssertionError instead; the index is not affected.
- Summary
- You build an interval query with Intervals.analyzedText("wifi dog", analyzer, "f", 0, true) (or the flexible StandardQueryParser's fn:or("wifi dog")), where the query analyzer's SynonymGraphFilter has the rules wifi => 'wi fi' and dog => canine. You expect it to match 'wifi dog', 'wifi canine', 'wi fi dog' and 'wi fi canine', as QueryBuilder's phrase query with the same analyzer does and as analyzedText("dog") alone does with or(canine,dog). Instead, because 'wifi' has a two-position synonym, the builder keeps only the first token stacked at the 'dog' position, builds or(BLOCK(wi,fi,canine),BLOCK(wifi,canine)) and misses the two 'dog' documents, including the one that holds exactly the query text; with assertions enabled it throws AssertionError.
- Introduced
- 32d7f52446ae363b45817b16d8f8ac106e1b461c (main; first released in 10.0.0) and its branch_9x backport 5de05f3556224fc68302f38848d60fe208e9875b (first released in 9.1.0), both 'LUCENE-10220: Add an utility method to get IntervalSource from analyzed text (or token stream) (#427)' (apache/lucene#427 by @dweiss, approved by @romseygeek; issue migrated as apache/lucene#11256) [read: git blame on main attributes every line of the no-side-path branch, IntervalBuilder.java:229-234, to 32d7f52446ae; the five later commits on the file (#11760, #13286, #13385, #13389, #16056) do not change that branch]. Not a regression: present since the method was added. The issue says the code was adopted from the Elasticsearch 7.10 IntervalBuilder, which has the identical branch [read, not executed]. The branch's premise was already false when written: GraphTokenStreamFiniteStrings.hasSidePath has returned false for equal-length stacked tokens since LUCENE-7638 (e764d3e0590, 2017-02-07), and the same commit's test asserts it [read]. Second entry point: the flexible StandardQueryParser fn: interval functions, LUCENE-10223 (#429; f725b27e12a on main, ff9ee28c603 on branch_9x, 2021-11-11), also first released in 9.1.0. (2021-11-05)
- First affected version
- 9.1.0 [exec: the finder's JUnit fails 3 of 3 with -ea (AssertionError at IntervalBuilder.java:234) and -da (2 of 4 hits each); the follow-up probe fails 6 of 6 modes with its control passing; the cross-model review's re-run with -da fails 3 of 3]. 9.0.0 is the last release without it: Intervals.analyzedText and IntervalBuilder do not exist there [exec: probe API_ABSENT]; the commit went to main and branch_9x after 9.0 was frozen [read]. First release from the main line: 10.0.0 [exec].
- Affected versions
- every release from 9.1.0 through 10.5.2 (9.1.0-9.12.3 and 10.0.0-10.5.2, 32 releases) and main ea57360912ed; not fixed upstream (IntervalBuilder.java on apache/lucene main 49483e82b357 and branch_10x is byte-identical to ea57360912ed [read 2026-10-26]). [exec, bug present]: the follow-up's test-framework-free probe (AnalyzedTextGraphProbe, public API through reflection) fails 6 of 6 (maxGaps, ordered) modes, control passing, on all 32 releases and main, with -ea (AssertionError) and -da (2 of 4 hits); the finder's JUnit fails 3 of 3 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main with -ea and -da; the independent reproduction's JUnit fails 5 of 6 on 9.12.3, 10.5.2 and main with -ea and -da. [exec, absent]: 9.0.0 (no API). [read]: Lucene 8.x and earlier have no IntervalBuilder. The AssertionError is reported at line 234 on 9.x and line 233 on 10.x and main (same statement).
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-26, the prior-report record verdict new: four scripted prior-report searches (finder H, A, B and C; 151 non-web queries over apache/lucene GitHub issues and PRs, commit messages, the migrated LUCENE and SOLR JIRA, the dev@, java-user@, issues@ and commits@ lists and the Elasticsearch, OpenSearch and Solr repositories), 19 web searches, 8 direct GitHub searches, 4 manual JQL queries, 9 extra list queries and the file lists of all 408 open apache/lucene PRs; 205 query records in all): no issue, PR, commit, JIRA entry, list thread or downstream report describes analyzedText or IntervalBuilder dropping stacked tokens of a token graph, or its 'assert it.hasNext() == false' firing; not fixed: IntervalBuilder.java on upstream main 49483e82b357 and branch_10x is byte-identical to ea57360912ed and no open PR touches it (the open PRs on GraphTokenStreamFiniteStrings, #16239 and #14262, change neither hasSidePath nor getFiniteStrings; #16899 changes other intervals files); nearest items, not reports of this bug: #11256 and #427 (LUCENE-10220, the introducing change, no graph discussion), #9523 (LUCENE-8477, disjunction minimisation), #9185 (LUCENE-8137, open: gaps in multi-word synonyms in the graph helper and QueryBuilder), #9743 (LUCENE-8697) and #9959 (LUCENE-8916), graph-helper bugs fixed in 2019, #12080 (open SynonymGraphFilter position bug), #11759 (NO_INTERVALS docID); downstream code, not reports [read only]: Elasticsearch main and OpenSearch main carry their own IntervalBuilder copies with the same branch and assert, and Solr's IntervalsQParserPlugin (apache/solr#4582, SOLR-13764, merged 2026-07, unreleased) calls Intervals.analyzedText; gaps: search is lexical, two 2022 StackOverflow questions (73240494, 73239047) could not be read (by title and a java-user thread of the same weeks they concern index-time synonyms with ordinary phrase queries; not verified), and the Elastic and OpenSearch forums were covered only through web search)
- Lucene commit tested
- main
ea57360912ed(commit of 2026-10-24; tested 2026-10-26), seed DEADBEEF, assertions on and off, through gradle in a scratch worktree and through a standalone JUnit harness; the doc's test also on the Maven Central jars of 10.5.2, 9.12.3 and 9.1.0; a test-framework-free probe on all 32 releases from 9.1.0 to 10.5.2 and on 9.0.0 (API absent).
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalBuilder.java:69 (analyzeText), :92-98 (position increment 0 sets hasSynonyms; position length > 1 sets isGraph), :109-111 (the whole text goes to analyzeGraph), :172-199 (analyzeSynonyms ORs stacked tokens), :201-237 (analyzeGraph; :215-228 side-path branch with the clause check at :221; :229-234 no-side-path branch: it.next() once, then 'assert it.hasNext() == false' at :233)lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:600-622 (analyzedText(String, Analyzer, String, int, boolean): 'Returns intervals that correspond to tokens from a TokenStream returned for text by applying the provided Analyzer'), :623-644 (analyzedText(TokenStream, int, boolean))lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21 (experimental classes)lucene/core/src/java/org/apache/lucene/util/graph/GraphTokenStreamFiniteStrings.java:88-106 (hasSidePath: 'start of multiple side paths of different length'; false when every transition reaches the same destination)lucene/core/src/test/org/apache/lucene/util/graph/TestGraphTokenStreamFiniteStrings.java:439-475 (testGraphWithRegularSynonym: assertFalse(graph.hasSidePath(0)) at :467, then getFiniteStrings(0, 1) yields 'fast' and 'speedy')lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:504-560 (analyzeGraphBoolean: the no-side-path branch keeps every stacked term through graph.getTerms(start) at :541), :566 (analyzeGraphPhrase)lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/AnalyzedText.java:35-43 (every fn: term or quoted string goes through Intervals.analyzedText, :38)
- The finding in 30 seconds
lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalBuilder.java:69-122 (analyzeText; :95-98 isGraph; :109-111 graph path), :172-199 (analyzeSynonyms), :201-237 (analyzeGraph; :215-228 side-path branch; :229-234 no-side-path branch)lucene/core/src/java/org/apache/lucene/util/graph/GraphTokenStreamFiniteStrings.java:88-106 (hasSidePath: 'side paths of different length')
- Who is affected
lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:641-686 (fn:wildcard -> Wildcard :655, fn:fuzzyTerm -> FuzzyTerm :675, IntervalText -> AnalyzedText :684-685)lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/AnalyzedText.java:35-43lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:600-644 (both analyzedText overloads); lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21 (experimental)
- Why it looks unintended
lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:600-613 (javadoc)lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalBuilder.java:172-199, :215-228, :229-234lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:340-343 (graph + quoted -> analyzeGraphPhrase), :504-563 (analyzeGraphBoolean; :541 graph.getTerms(start)), :566-583 (analyzeGraphPhrase)lucene/core/src/test/org/apache/lucene/util/graph/TestGraphTokenStreamFiniteStrings.java:439-475 (:467 assertFalse(graph.hasSidePath(0)))lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervalBuilder.java:120, :138, :161, :181, :205lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/SynonymGraphFilter.java:69-72 (TermAutomatonQuery note)
- Suggested fix
lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:43-50, :58-68, :108-113 (single source returned; set-based equality)
- TestAnalyzedTextGraphStackedToken.java (JUnit test):
repro/LFV-457/src/org/apache/lucene/queries/intervals/TestAnalyzedTextGraphStackedToken.java(65 lines)
A picture of the query's token graph and of the builder's walk over its two segments, with the values of an executed probe on main; made in parallel with this doc.
assets/LFV-457/bug.png(484 KB)
