All findings · Lucene finding · LFV-457 · intervalbuilder-graph-drops-stacked-synonyms

Intervals.analyzedText drops stacked synonyms when another token of the text spans several positions (e.g. a multi-word synonym)

With the synonym rules wifi => wi fi and dog => canine, Intervals.analyzedText("wifi dog") finds 2 of 4 documents and misses "wifi dog" itself: once one word has a multi-word synonym, each position outside that synonym's span keeps only the first token stacked there. With assertions on it throws AssertionError; since 9.1.0.

Summary

Priority
Medium, rank 43Intervals.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
silent wrong results
Since
9.1.0 (LUCENE-10220, 2021)introduced 2021-11-05 · about 5 years
Affected
9.1.0 to 10.5.2 and main
Status
documented
Found by
bounded-exhaustive translation validation against a reference semantics

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Interval queries built from analyzed text lose stacked synonyms as soon as some other word of the same text has a multi-word synonym.

Token graph of the query "wifi dog" after SynonymGraphFilter with wifi => wi fi and dog => canine: the token wifi spans positions 0-1 above wi and fi, and canine and dog are stacked at position 2. Intervals.analyzedText keeps canine and drops dog, building or(BLOCK(wi,fi,canine), BLOCK(wifi,canine)), so it finds 2 of the 4 documents (wifi canine, wi fi canine) and misses wifi dog and wi fi dog; QueryBuilder with the same analyzer finds all 4.
The query "wifi dog" as a token graph: 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.
  1. Give the query analyzer two SynonymGraphFilter rules, wifi => wi fi (two words) and dog => canine (one word), and index 4 documents: "wifi dog", "wifi canine", "wi fi dog", "wi fi canine".
  2. Search with Intervals.analyzedText("wifi dog", analyzer, "f", 0, true), or fn:or("wifi dog") in the flexible query parser. You expect all 4: QueryBuilder with the same analyzer finds 4, and analyzedText("dog") alone keeps or(canine, dog).
  3. 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)), and dog is gone.
  4. Why: wifi spans 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.
  5. With assertions on, building the query throws AssertionError at IntervalBuilder.java:233; with them off (production) matches are silently lost, in every gap and order mode. WordDelimiterGraphFilter with KeywordRepeatFilter and a stemmer does the same with no synonyms at all.

Who is affected

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.

Terms

  • Stacked token: a token with position increment 0, at the same position as the token before it, for example canine on top of dog. 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 wifi spanning the two positions of wi 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 (wifi versus wi fi). Equal-length stacked tokens (dog, canine) are not a side path: they leave the state together and arrive together.

The bug

  1. IntervalBuilder.analyzeText notes whether any token has position length > 1 (IntervalBuilder.java:95-98). If so, the whole text goes to analyzeGraph (:109-111) instead of analyzeSynonyms, which would OR the stacked tokens (:172-199).
  2. 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 runs TokenStream ts = it.next(); once and then assert it.hasNext() == false; (:230-233).
  3. For wifi dog the last segment holds canine and dog: no side path, two finite strings (the diagram probe prints hasSidePath=false finiteStrings(in order)=["canine", "dog"]). Line 231 keeps canine, line 233 throws under -ea, and under -da dog is dropped. The built source is or(BLOCK(wi,fi,canine),BLOCK(wifi,canine)).
  4. Which token survives is the one the analyzer emits first: SynonymGraphFilter emits the synonym canine before the original dog, so the user's own word is the one lost.

The numbers (main, assertions off)

query, query-time analysisexpectedfound
analyzedText("wifi dog"), rules wifi => wi fi, dog => canine42
control: QueryBuilder.createPhraseQuery("wifi dog"), same analyzer44
control: analyzedText("dog") = or(canine,dog)44
flexible parser fn:or("wifi dog"); fn:ordered("wifi dog" park); fn:phrase("wifi dog" park)4 each2 each
test framework MockSynonymAnalyzer: analyzedText("guinea pig dogs")42
WordDelimiterGraphFilter + KeywordRepeatFilter + PorterStemFilter + RemoveDuplicatesTokenFilter: analyzedText("wi-fi dogs")42 (the stem dog is dropped)
a stacked position before the multi-word one (canned dog|canine then wifi|wi fi)42

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.analyzedText overloads, and the plain-term and quoted-text operands of the flexible StandardQueryParser fn: functions (the grammar's IntervalText rule, StandardSyntaxParser.jj:681-686, then AnalyzedText.java:38); the patterns of fn:wildcard and fn:fuzzyTerm are not analyzed. Core QueryBuilder and the parsers built on it are not affected.
  • What it takes. In one query text, a token that spans several positions (a multi-word SynonymGraphFilter rule, or WordDelimiterGraphFilter catenation) and a token stacked at another position (a single-word synonym, or KeywordRepeatFilter with 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 (through StandardQueryParser it reaches the caller as that Error, not as a QueryNodeException).
  • Negation flips it. A MUST_NOT clause or the subtrahend of fn:notWithin built 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, calls Intervals.analyzedText. Elasticsearch and OpenSearch carry their own copies of the same branch; a Lucene fix does not change those copies.

Minimal repro

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".

TestAnalyzedTextGraphStackedToken.java (JUnit test)65 lines
/*
 * 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));
      }
    }
  }
}
Command
# 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.

All files of this repro: repro/LFV-457/

More files, environment, full output30 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • 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 core QueryBuilder.createPhraseQuery keeps 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. analyzeSynonyms ORs stacked tokens (IntervalBuilder.java:172-199), and the side-path branch ORs every finite string (:215-228). Whether dog is honoured should not depend on an unrelated word having a multi-word synonym.
  • Core parity. Core QueryBuilder keeps stacked tokens on the same graph. The executed control, createPhraseQuery, goes through analyzeGraphPhrase, one phrase per finite string (QueryBuilder.java:340-343, :566-583); by code reading, analyzeGraphBoolean walks the same segments and keeps every stacked term of a segment without a side path through graph.getTerms(start) (:540-556).
  • Core test. TestGraphTokenStreamFiniteStrings#testGraphWithRegularSynonym (fast|speedy wi|wifi fi network) asserts hasSidePath(0) == false and then the two finite strings fast and speedy (: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.

Suggested fix

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 maxClauseCount check, 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() == 1 is only a shortcut: Intervals.or of 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 getFiniteStrings does not document it); if that changed, addAll would 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; TestMatchHighlighter uses analyzedText and fn: functions) and TestStandardQPEnhancements (23 tests), 0 failures; tidy passes (the fix hunk saved after it equals the finder's) and :lucene:queries:check -x test passes.
  • Harness with the fix shadowed in: the SynonymGraphFilter/fn:or test 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 (known RepeatingIntervalsSource behaviours and OR minimal-interval semantics the reference does not reproduce), not this bug.

Use graph.getTerms(start), as core QueryBuilder does (validated by the checks)

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.

Also count the alternatives against IndexSearcher.getMaxClauseCount()

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.

Document the limitation (not recommended)

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.

How it was found

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 reading IntervalBuilder.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).

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs, commits (apache/lucene)2026-10-26four 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 searchesno 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 PRs2026-10-26the file lists of all 408 open apache/lucene PRsnone 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 lists2026-10-26LUCENE and SOLR JIRA (scripted prior-report search and 4 manual JQL queries); dev@, java-user@, issues@, commits@ and 9 extra list queriesno report
Downstream and web2026-10-26Elasticsearch, OpenSearch and Solr sources and issues; 19 web searchesno report; the same branch exists in the Elasticsearch and OpenSearch copies, and Solr's unreleased {!intervals} parser calls analyzedText (code reading only)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
9.0.0 no APIIntervals.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 affectedthe 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) affectedthe 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) affectedthe same; this doc's test fails 2 of 2 on 10.5.2; the AssertionError is at line 233
main ea57360912ed affectedthis 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 affectedthis doc's test 2 of 2, 120 interval tests and 515 queries-module tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateThe 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 realIts 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 realBuilt 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 reproducedThe 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 validatedThe 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 fixedA 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,437 words

Finding record

LFV-457, snapshot of 2026-11-06

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).

Lucene and upstream sources

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-43
  • lucene/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-234
  • lucene/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, :205
  • lucene/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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.