All findings · Lucene finding · LFV-604 · multiterm-intervals-skip-positions-check

Multi-term interval queries skip the positions check and silently return wrong hits (usually none) on a field without positions

On a field indexed without positions, an interval query built from Intervals.term leaves throws as documented, but the same query built from Intervals.prefix, wildcard, regexp, fuzzyTerm, range or multiterm leaves runs on fake positions and silently returns wrong hits, usually none. Every release since 8.0.0 is affected.

Summary

Priority
Low, rank 629Multi-term interval leaves on a field indexed without positions silently return no hits instead of the documented exception, but this needs a schema mistake that other queries expose and sits in the experimental intervals API.
Impact
wrong error
Since
8.0.0 (LUCENE-8646, 2019)introduced 2019-01-22 · about 7 years
Affected
every release from 8.0.0 to 10.6.0, and main
Status
documented
Found by
code reading, then independently a metamorphic test harness across index options

AI-assisted page: this finding was first signalled by code reading or testing 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 need word positions. Lucene checks that a field has them for plain term leaves, but not for multi-term leaves.

Positions and interval queries

  1. A text field can be indexed at several levels (IndexOptions): DOCS records which documents hold a word, DOCS_AND_FREQS adds how often, DOCS_AND_FREQS_AND_POSITIONS adds where (in "alpha beta", alpha is word 0 and beta word 1).
  2. Positional queries need those positions: PhraseQuery, span queries and interval queries (IntervalQuery with Intervals.ordered, phrase, unordered, containing, ...). On a field without them, the IndexOptions javadoc says they throw an exception.
  3. Interval leaves come in two kinds: Intervals.term("alpha") for one word, and multi-term leaves such as Intervals.prefix("alph") that expand to every matching word of the index.
Summary of the bug: one document 'alpha beta' in a field indexed with DOCS_AND_FREQS (no positions). IntervalQuery ordered(term alpha, term beta) throws IllegalArgumentException 'no indexed positions'. IntervalQuery ordered(prefix alph*, prefix bet*) skips the check, reads both words at position -1, and returns 0 hits with no error.
The same ordered query over one document "alpha beta" stored without positions: with term leaves it throws; with prefix leaves both words read position -1 and it returns 0 hits with no error. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document, body = "alpha beta", with IndexOptions.DOCS_AND_FREQS: Lucene stores which documents hold each word and how often, but not where (alpha is word 0, beta word 1).
  2. Search ordered(term("alpha"), term("beta")), "alpha, then beta". Lucene throws IllegalArgumentException: ... because it has no indexed positions, as the IndexOptions javadoc promises for positional queries.
  3. Write the same query as ordered(prefix("alph"), prefix("bet")); the prefixes expand to exactly alpha and beta. You expect the same exception.
  4. Instead it runs. Both words are reported at position -1, so "beta starts after alpha ends" (-1 after -1) never holds: 0 hits, no error. containing and overlapping err the other way and match documents they should not.
  5. Every interval query whose leaves are all prefix, wildcard, regexp, fuzzy or range leaves skips the check, also through the query parser's fn:wildcard; a plain term leaf in the query usually makes it throw (it throws once it is evaluated).

Who is affected

Applications that run interval queries built only from prefix, wildcard, regexp, fuzzy, range or automaton leaves, directly or through the flexible query parser's fn:wildcard and fn:fuzzyTerm, on a field indexed without positions, such as a StringField or a TextField with reduced index options. A schema mistake that Lucene documents as an error for positional queries goes unnoticed.

Mechanism, terms and numbers594 words

On a field indexed without positions (IndexOptions.DOCS or DOCS_AND_FREQS), positional queries are documented to throw. Interval leaves made with Intervals.term do. The multi-term leaves (Intervals.prefix, wildcard, regexp, fuzzyTerm, range, multiterm, all built on MultiTermIntervalsSource) never call Terms#hasPositions(): they run the interval logic on the position -1 that the postings return for every occurrence. On one document alpha beta, ordered(prefix("alph"), prefix("bet")) returns 0 hits with no error where ordered(term("alpha"), term("beta")) throws. The check was lost when LUCENE-8646 added multi-term interval leaves in 8.0.0 (2019); every release since, through 10.6.0, and main are affected.

The check that exists

TermIntervalsSource.intervals() reads the field's Terms, returns null if the field is missing, then throws IllegalArgumentException("Cannot create an IntervalIterator over field ... because it has no indexed positions") when terms.hasPositions() is false (TermIntervalsSource.java:54-59), and only then calls its static helper intervals(term, te) (:64, :67). matches() does the same (:151-156, helper at :164). PayloadFilteredTermIntervalsSource has the same check (:51, :157).

The check that is missing

Intervals.prefix, wildcard, regexp, range and multiterm return a MultiTermIntervalsSource (Intervals.java:176-180, :206-208, :237-241, :279-295, :369-370); fuzzyTerm goes through multiterm (:335-341). Its intervals() (MultiTermIntervalsSource.java:57-77) reads Terms, returns null for a missing field, and goes straight to automaton.getTermsEnum(terms) and TermIntervalsSource.intervals(term, te) for each expanded term (:64, :68). Its matches() (:79-103) calls TermIntervalsSource.matches(te, doc, field) (:90). Neither calls hasPositions().

Fake positions

The helper asks the postings for positions (TermIntervalsSource.java:68). The default postings reader grants them only when the field has them (Lucene104PostingsReader.java:420) and otherwise returns -1 from nextPosition() (:1293-1296), as PostingsEnum.nextPosition documents ("or -1 if positions were not indexed", PostingsEnum.java:84-88). -1 is also the value IntervalIterator uses for "not positioned yet" (IntervalIterator.java:31-33).

ordered needs each leaf to start after the previous one ends: it skips beta intervals while currentStart <= prevEnd (OrderedIntervalsSource.java:131, :145-151). With alpha ending at -1 and every beta starting at -1, no beta qualifies and the document does not match. Other operators turn the same coinciding -1 positions into other answers.

In a tree that mixes both kinds, leaf order matters: ordered and unordered build their sub-iterators in order and return null at the first leaf that returns null (MinimizingConjunctionIntervalsSource.java:42-52; ConjunctionIntervalsSource.java:47-57 likewise), and a multi-term leaf returns null when it expands to nothing in the segment (MultiTermIntervalsSource.java:73-75). So a term leaf throws only once it is reached: ordered(prefix("zzz"), term("beta")) returns 0 hits with no error, while ordered(term("beta"), prefix("zzz")) throws.

What you see, on one document alpha beta without positions

queryresult
PhraseQuery, span queries (also a lone span prefix), Intervals.term (also for an absent word), ordered(prefix, term)throw
ordered, unordered, phrase, maxgaps over prefix, wildcard, fuzzyTerm, regexp or range leaves; fn:ordered(fn:wildcard(alph*) fn:wildcard(bet*))0 hits, no error
lone prefix("alph")1 hit, no error
Weight#matches of IntervalQuery(prefix("alph"))a match at position -1
a StringField: lone prefix("abc") / term("abc-1")1 hit / throws
any query on body above, with positions indexed1 hit (0 for a word not in the index)

On 8 documents, containing, containedBy and overlapping over two multi-term leaves return the 5 documents that hold both expanded words, none of which the same query matches on a copy with positions, and a MUST_NOT(ordered(alph*, bet?)) filter excludes nothing (a MUST_NOT over containing(alph*, bet*) or a lone prefix(alph*) does exclude).

Exposure and likelihood171 words
  • Who is hit. Users of the opt-in, experimental intervals API (package-info.java:21) whose query has only multi-term leaves and whose field has no positions. The flexible StandardQueryParser reaches it through fn:wildcard and fn:fuzzyTerm (intervalfn/Wildcard.java:38-40, FuzzyTerm.java:44).
  • What goes wrong. No error: positional shapes return no hits, containment shapes return documents their positional meaning excludes, a lone multi-term leaf returns the documents that hold an expanded word. The same query with a plain Intervals.term leaf would usually have thrown and pointed at the schema (it throws once it is evaluated).
  • What it takes. One document, one query; default codec, IndexWriterConfig and searcher. Deterministic, with assertions on or off. The index is not damaged.
  • How likely: rare. It needs both an all-multi-term interval query and a field without positions. Elasticsearch and OpenSearch guard against it in their own code: OpenSearch checks hasPositions(), and Elasticsearch text fields either throw or recompute positions from the field values (source read); Solr main's unreleased intervals query parser applies no such check (source read).

Minimal repro

One JUnit method on the public API, in the intervals package like the upstream tests: one document "alpha beta" in a DOCS_AND_FREQS field and IntervalQuery(ordered(prefix("alph"), prefix("bet"))), expecting the documented IllegalArgumentException. Fails with assertions on and off.

TestMultiTermIntervalsWithoutPositions.java (lucene/queries, package org.apache.lucene.queries.intervals, 1 test)56 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.FieldType;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestMultiTermIntervalsWithoutPositions extends LuceneTestCase {

  public void testPrefixIntervalsThrowWithoutPositions() throws Exception {
    FieldType noPositions = new FieldType(TextField.TYPE_NOT_STORED);
    noPositions.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, newIndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new Field("body", "alpha beta", noPositions));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query query =
            new IntervalQuery(
                "body",
                Intervals.ordered(
                    Intervals.prefix(new BytesRef("alph")),
                    Intervals.prefix(new BytesRef("bet"))));
        // expected: IllegalArgumentException as for Intervals.term; actual: silently 0 hits
        expectThrows(IllegalArgumentException.class, () -> searcher.count(query));
      }
    }
  }
}
Command
cp $REPRO/TestMultiTermIntervalsWithoutPositions.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/intervals/
cd $LUCENE && ./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestMultiTermIntervalsWithoutPositions -Ptests.seed=DEADBEEF
Expected
IllegalArgumentException: Cannot create an IntervalIterator over field body because it has no indexed positions, as with Intervals.term.
Observed
No exception and 0 hits: AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrown at line 52.

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

More files, environment, full output272 words

TestMultiTermIntervalsWithoutPositions (56 lines with the license header, one test method, public API only). It fails at the expectThrows on line 52. The result is the same with two seeds, that is two random codec, directory and IndexWriterConfig choices of the test framework; the mechanism rests on the PostingsEnum.nextPosition contract (-1 without positions), not on those choices.

As a regression test it goes into lucene/queries/src/test/org/apache/lucene/queries/intervals/ unchanged (or as a method of TestIntervals, next to testIntervalsOnFieldWithNoPositions); it passes with the fix. Formatting: google-java-format puts the two Intervals.prefix(...) arguments on one line.

The same test fails on the 10.6.0 release jars. The finder's 4-test class (prefix leaves, a DOCS_AND_FREQS field and a mixed MultiReader) fails on every release from 8.0.0; an independent test of all six factories on DOCS and DOCS_AND_FREQS fields fails on 9.12.0, 9.12.3, 10.5.2 and main. This test shows the missing exception for an ordered pair of prefixes that both expand; the other factories, Weight#matches, the parser path and the over-matching shapes rest on the separate probes.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)
Command (environment)
cp $REPRO/TestMultiTermIntervalsWithoutPositions.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/intervals/
cd $LUCENE && ./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestMultiTermIntervalsWithoutPositions -Ptests.seed=DEADBEEF

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF (verbatim)
TestMultiTermIntervalsWithoutPositions > testPrefixIntervalsThrowWithoutPositions FAILED
    junit.framework.AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrown
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:7C4D00743B2EB14C]:0)
        at app/org.apache.lucene.test_framework@11.0.0-SNAPSHOT/org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
        at app/org.apache.lucene.test_framework@11.0.0-SNAPSHOT/org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
        at app//org.apache.lucene.queries.intervals.TestMultiTermIntervalsWithoutPositions.testPrefixIntervalsThrowWithoutPositions(TestMultiTermIntervalsWithoutPositions.java:52)

# standalone runner, stock jars, seed DEADBEEF (summary of result.json)
main 645d4fe14a20  -ea / -da   tests_run=1 failures=1   same message
10.6.0             -ea         tests_run=1 failures=1   same message

# with the recommended fix, gradle (summary)
TestMultiTermIntervalsWithoutPositions  tests=1 failures=0

Why it looks unintended

  • The IndexOptions javadoc: without positions, "PhraseQuery and other positional queries will throw an exception"; term leaves obey it.
  • Siblings throw on such a field: PhraseQuery, MultiPhraseQuery, span queries (even a lone span prefix), Intervals.term (even for an absent word).
  • Lucene 4.0 made such queries throw since "previously they silently returned no results"; a 2019 refactoring lost the check.
The full argument229 words
  • Contract. IndexOptions.java:31-36 (DOCS): "Phrase and other positional queries on the field will throw an exception"; :37-41 (DOCS_AND_FREQS): "PhraseQuery and other positional queries will throw an exception". It does not name interval queries; they are positional queries, and TermIntervalsSource and PayloadFilteredTermIntervalsSource apply the rule to interval leaves.
  • The symptom was removed once. CHANGES.txt:15444-15446, 4.0.0-ALPHA: "Positional queries (PhraseQuery, *SpanQuery) will now throw an exception if use them on a field that omits positions during indexing (previously they silently returned no results)".
  • Siblings throw. PhraseQuery.java:504-510, MultiPhraseQuery.java:255-262, SpanTermQuery.java:149-155 (so SpanMultiTermQueryWrapper(PrefixQuery) throws even alone), TermIntervalsSource.java:54-59, PayloadFilteredTermIntervalsSource.java:51. Not every positional query checks: the sandbox TermAutomatonQuery has no check either (TermAutomatonQuery.java:423) and returned 0 hits with no error for alpha (beta | gamma delta) on such a field. Within one interval query, a plain term leaf throws once it is evaluated: ordered(prefix("alph"), term("beta")) throws, ordered(prefix("alph"), prefix("bet")) does not.
  • Test. TestIntervals#testIntervalsOnFieldWithNoPositions (TestIntervals.java:260-270) asserts the exact message for Intervals.term("wibble"), a word not in the index. No test covers a multi-term leaf on such a field.
  • History. LUCENE-8196 (7.4.0) added the check; its review asked that "TermIntervalsSource should check whether positions were indexed" (#9243). LUCENE-8646 (#9692, 8.0.0) split TermIntervalsSource.intervals()/matches() into a checked instance method and an unchecked static helper, and built MultiTermIntervalsSource on the helpers only; its discussion never mentions positions. No later commit added the check.
The devil's-advocate check160 words

The strongest case for "not a bug": the intervals package is experimental; IntervalsSource.intervals may return null "if no intervals for this field exist in this segment" (IntervalsSource.java:39), so an empty result could be within contract; LUCENE-9462 deliberately made Weight#matches of TermQuery, SynonymQuery and MultiTermQuery work without positions (CHANGES.txt:4717); a lone multi-term leaf returns the right documents, so a check changes behaviour; and a positional query on a positions-free field is the user's misconfiguration.

Why it fails: the javadoc names the outcome, an exception, not an empty result; the null rule covers a segment without the field or the words, and Intervals.term throws even for an absent word; LUCENE-9462 changed no interval code and IntervalQuery(term).matches still throws, so it can at most argue for leaving the matches() half lenient; the span wrapper over the same PrefixQuery throws even alone; and the misconfiguration is exactly the input Lucene promises to reject. Verdict: not intended, a missing validation.

Suggested fix

Recommended Check positions in both MultiTermIntervalsSource methods

Add the check that TermIntervalsSource has to both MultiTermIntervalsSource methods: when terms.hasPositions() is false, throw the same IllegalArgumentException. 12 added lines; the repro and the queries, highlighter and queryparser suites pass. Main compatibility decision: a lone prefix on a keyword field then throws instead of matching. Guarding matches() too is the maintainers' call (option B).

    if (terms == null) {
      return null;
    }
    if (terms.hasPositions() == false) {
      throw new IllegalArgumentException(
          "Cannot create an IntervalIterator over field "
              + field
              + " because it has no indexed positions");
    }
All options and trade-offs3 options · 536 words

Option A was validated with gradle in a scratch worktree at 645d4fe14a20; option B is its first hunk alone; option C is a refactoring of A that was not run. Offer A and B: the search half is clear-cut, while MatchesIterator allows -1 "if positions are not available" (MatchesIterator.java:48, :55) and LUCENE-9462 kept Weight#matches of non-interval queries lenient, so the matches() half is the maintainers' decision.

Check positions in both MultiTermIntervalsSource methods (validated by the checks) (recommended)

Right after the null check in intervals() (MultiTermIntervalsSource.java:60-62) and in matches() (:83-85), the same check and message as TermIntervalsSource.java:54-59. The change is +12 lines in one file.

    if (terms == null) {
      return null;
    }
    if (terms.hasPositions() == false) {
      throw new IllegalArgumentException(
          "Cannot create an IntervalIterator over field "
              + field
              + " because it has no indexed positions");
    }

Trade-offs

  • Multi-term leaves then behave exactly like Intervals.term, with the same message; one hasPositions() call per segment in intervals() and one per matches() call (per Weight#matches request for a document).
  • Behaviour change, the main compatibility decision: a lone multi-term leaf on a positions-free field (for example a prefix on a StringField) throws where it matches today; worth a CHANGES entry.

What the checks validated

  • Scratch worktree at 645d4fe14a20, JDK 25, gradle, -Ptests.seed=DEADBEEF: the minimal test passes; :lucene:queries:test 52 suites, 513 tests, 0 failures (all 8 intervals suites, including TestIntervals 43 and TestIntervalQuery 34); :lucene:highlighter:test 34 suites, 722 tests, 0 failures (TestMatchHighlighter, TestMatchRegionRetriever, TestUnifiedHighlighterTermIntervals); :lucene:queryparser:test 30 suites, 533 tests, 0 failures (TestStandardQPEnhancements, TestStandardQP). In that run checkGoogleJavaFormat failed on the test file's line wrap only (see the repro), not on the fix; with the test reflowed it passes.
  • Without the fix, the same minimal test fails through gradle.
  • Earlier, on main a9779ef3c193 with the class shadowed: the finder's 4 tests and an independent reproducer's 4 tests pass, and 81 upstream interval tests pass; on 8.0.0 the same two checks in the 8.0.0 source turn 2 of 4 failures into 4 of 4 passes.

Check only in intervals(); leave matches() lenient (validated by the checks)

The first hunk of option A's change alone (the check in intervals()), in the spirit of LUCENE-9462, which made Weight#matches of non-interval queries work without positions.

Trade-offs

  • Search fails loudly as documented, but Weight#matches of a multi-term interval query still reports a match at position -1, while Intervals.term's matches() throws (TermIntervalsSource.java:151-156): the two leaf kinds stay inconsistent there.
  • Code that calls Weight#matches directly for a document (highlighters, for example) still gets a match at position -1.

What the checks validated

  • Scratch worktree at 645d4fe14a20, gradle, -Ptests.seed=DEADBEEF: the minimal test passes (it only searches); the 8 intervals suites of :lucene:queries pass, 118 tests, 0 failures; :lucene:highlighter:test passes, 34 suites, 722 tests, 0 failures. The queryparser suite was not re-run for this option.

One shared check (refactoring of A, not run)

Move the check into a package-private static method of TermIntervalsSource and call it from both methods of TermIntervalsSource, MultiTermIntervalsSource and PayloadFilteredTermIntervalsSource.

Trade-offs

Same behaviour as A with one copy of the message; a larger diff touching three classes.

Downstream note

Downstream: an exclusion rule of a positional shape (ordered, unordered, phrase, maxgaps) over prefix or wildcard leaves on a field without positions, such as MUST_NOT(ordered(alph*, bet?)), excludes nothing, where the term version would throw; containing-type shapes and a lone multi-term leaf still exclude the documents that hold the expanded terms.

How it was found

Found first by reading the intervals code, where the check sits only in the single-term source, and confirmed with a small probe. A test harness that compares queries across index options later found it independently: every positional query threw on fields without positions, except interval queries built from multi-term leaves.

The method in full162 words

Method: code reading, then differential and metamorphic testing. Neither step is formal verification.

  • First finder (2026-10-26, main ea57360912ed): while writing a reference model of the multi-term interval source for a translation-validation harness, an AI tool read MultiTermIntervalsSource next to TermIntervalsSource, saw that only the latter checks hasPositions(), and confirmed it with a public-API probe and a JUnit on the stock jars ("got intervals [-1,-1]" where or(term, term) throws).
  • Second, independent finder (2026-10-26, main a9779ef3c193): a harness indexes the same documents at 29 index-option levels and checks relations between them. Relation R3: a positional query on a level without positions must throw. Over seeds 41-440 it saw 4,665 silent mismatches and 462 more on a mixed MultiReader, all printed ones on multi-term interval leaves, while PhraseQuery, MultiPhraseQuery, span and Intervals.term queries whose terms were present threw on every positions-free level.
  • The two findings were merged into one record; the hunt then ran the checks listed below.

Prior reports, history and checks

Prior reports

No prior report: 91 searches on 2026-10-26 (Lucene issues and PRs including the old JIRA, commits, four mailing lists, downstream projects, the web) and a scan of all 407 open PRs; re-checked 2026-10-30. The nearest items are design intent: the original review asked for this check on term sources.

Searches8 searches

Nearest items, none a report: #9243 (LUCENE-8196 review asked that TermIntervalsSource check for positions), #7288 (LUCENE-6226: positional scorers should throw "the same way" PhraseQuery does), #9692 (LUCENE-8646, the introducing change, never mentions positions), #10479 / #10502 (LUCENE-9439 / LUCENE-9462: lenient Weight#matches for non-interval queries, relevant only to the optional matches() half), #16899 (edited the same two methods for the expansion limit, no check). Tracking issue #16937 does not name it.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs, including the migrated JIRA archive2026-10-2616 tool and 18 manual queries: MultiTermIntervalsSource, hasPositions, "no indexed positions", "Cannot create an IntervalIterator", DOCS_AND_FREQS, "without positions" and symptom wordingno report; design-intent items #9243 and #7288
Open PRs2026-10-26a file-path scan of all 407 open PRs and 3 searchesnone touches MultiTermIntervalsSource, TermIntervalsSource or Intervals
Commits and fix status2026-10-26; re-checked 2026-10-30commit search; per-file history on main, branch_10x and branch_10_5last change #16899 (expansion limit), no check; upstream main 716d6f216f52 unchanged
JIRA archive2026-10-264 tool and 6 manual REST searches; comments of LUCENE-9462, -8646, -8196, -8845, -8893no report
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-2620 archive queriesno report
Elasticsearch, OpenSearch, Solr2026-10-2610 searches and a read of their mappers and intervals parserElasticsearch and OpenSearch guard against it in their own code (OpenSearch checks positions; Elasticsearch throws or recomputes positions; elastic/elasticsearch#40418); Solr main's parser does not; nobody reports the Lucene gap
Web2026-10-267 searchesunrelated results only
GitHub refresh2026-10-30issue search for "no indexed positions" (5 hits), MultiTermIntervalsSource (4 hits), intervals and positions since 2026-10-20 (0); the files of all 410 open PRs; the hunt's own issues and PRsnothing new; no open PR touches the intervals sources (saved record)

History

Introduced in 8.0.0 by LUCENE-8646 (2019), which built the multi-term leaves on helpers that skip the check. All 60 releases from 8.0.0 to 10.5.2 fail the prefix-based tests, and 10.6.0 and main the minimal test; not fixed upstream.

Versions tested8 rows

Introduced by LUCENE-8646 "Add multi-term intervals" (a JIRA patch by romseygeek, no GitHub PR; migrated issue #9692): 7d7ab14776b7 on master and ceadb5f1339f on branch_8x, committed 2019-01-22. It created MultiTermIntervalsSource, Intervals.prefix and Intervals.wildcard, and split TermIntervalsSource.intervals()/matches() into a checked instance method and an unchecked static helper; the new class calls only the helpers. The check itself dates from LUCENE-8196 (7.4.0).

Each factory is affected from the release that added it: prefix and wildcard 8.0.0, multiterm 8.4.0, fuzzyTerm and the parser's fn:wildcard / fn:fuzzyTerm 9.1.0, regexp and range 9.12.0. 8.0.x-8.1.x is sandbox code; from 8.2.0 the class is in lucene-queries. Old releases ran on JDK 25.

Versions and branches executed, with results
Version or commitVerdictResult
7.7.3 before the APIno Intervals.prefix (compile error); Intervals.term throws as documented
8.0.0 first release2 of 4 tests fail with and without assertions: 0 hits on one index, 1 of 2 on a mixed MultiReader
8.0.0 with only MultiTermIntervalsSource patched fix swapthe same two checks: 4 of 4 pass
all 60 releases 8.0.0-10.5.2 affectedthe same 2 of 4 tests (prefix leaves on a DOCS_AND_FREQS field, plus the mixed MultiReader) fail with the same counts; controls pass
9.12.0 all six factoriesprefix, wildcard, regexp, fuzzyTerm, range and multiterm all silent (the only release in the sweep where all six were run)
10.6.0 affectedthe minimal test fails (-ea)
main 645d4fe14a20 affectedthe minimal test fails (-ea, -da, gradle)
main 645d4fe14a20 + fix A not affectedthe minimal test and the queries, highlighter and queryparser suites pass

Independent checks

Independent finders, an independent reproducer with its own test of all six leaf types, a devil's-advocate check, an adjudication, a history sweep and an audit all reproduce it; read-only reviews from another model family agreed on the root cause.

Checks and verdicts406 words

The cross-model reviews only read code; the refute lens returned a procedural "not real" at low confidence while agreeing on the root cause and that the inputs are legal.

Each independent check and its verdict
CheckVerdictWhat it found
first finder lead, same root causeCode reading, a probe and a JUnit on main ea57360912ed: intervals at [-1,-1] where or(term, term) throws.
finder candidateMetamorphic harness (4,665 silent mismatches over 400 seeds, all printed ones on multi-term interval leaves); own JUnit fails 2 of 4 on main (-ea, -da), 10.5.2 and 9.12.3; passes with the fix; 81 upstream interval tests pass with it.
cross-model review, refute lens (OpenAI GPT-5.6) procedural "not real", lowRead-only; root cause agrees, inputs in contract, intended argument moderate for the matches() half only.
cross-model review, intended lens (OpenAI GPT-5.6) real, mediumRead-only; intended argument weak.
independent reproducer realOwn JUnit for all six factories on DOCS and DOCS_AND_FREQS: fails on main, 10.5.2 and 9.12.3; 40 of 40 over 20 iterations; passes with the fix.
intended-behaviour devil's advocate real, highProbe of the phrase, span, interval-term and parser families on the same fields; only pure multi-term interval trees stay silent. Rates the impact a missing validation.
adjudication confirmed, highRe-ran the reproducer's JUnit with a new seed (-ea, -da): fails; passes with the fix.
history sweep affected since 8.0.081 runs over 7.7.3, all 60 releases 8.0.0-10.5.2 and main; a one-class fix swap on 8.0.0.
credibility audit keepThe finder's JUnit with a fourth seed (-ea, -da) fails; code read confirms the missing check.
this doc gradle repro and fixThe minimal test through gradle in a scratch worktree at 645d4fe14a20 (fails), and with the standalone runner on the stock jars of main and of 10.6.0 (fails); fix A and option B in the scratch worktree with the module test suites.
independent fact check holds after wording fixesRe-ran the gradle command on its own extraction of 645d4fe14a20 (-ea, -da) and the standalone runner on main and 10.6.0: fails; fix A passes with the intervals suites and the format check. Its probes of leaf order, MUST_NOT shapes and TermAutomatonQuery narrowed four sentences, now corrected.
cross-model fact check (OpenAI GPT-5.6) substantively soundRead-only; found the central behaviour real and well supported; its corrections are applied or answered.

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,374 words

Finding record

LFV-604, snapshot of 2026-11-06

Registry title
Multi-term interval sources (Intervals.prefix, wildcard, regexp, fuzzyTerm, range, multiterm) skip the has-positions check that Intervals.term performs, so positional interval queries over them on a field indexed without positions silently return wrong hits (usually none) instead of throwing the documented exception
Area
queries / intervals (MultiTermIntervalsSource behind Intervals.prefix, wildcard, regexp, range, fuzzyTerm and multiterm; IntervalQuery search and Weight#matches)
Priority
Low, rank 629: Multi-term interval leaves on a field indexed without positions silently return no hits instead of the documented exception, but this needs a schema mistake that other queries expose and sits in the experimental intervals API.
Impact class
wrong error
Impact note
Applications that run an IntervalQuery built only from multi-term leaves (Intervals.prefix, wildcard, regexp, fuzzyTerm, range or multiterm, also through StandardQueryParser fn:wildcard (executed) and fn:fuzzyTerm (read)) on a field indexed without positions (DOCS or DOCS_AND_FREQS, for example a StringField or a TextField with reduced IndexOptions) get no exception and meaningless hits: ordered, unordered, phrase, maxgaps and similar shapes return no hits, containing, containedBy and overlapping return every document that holds both expansions, a lone source returns the documents containing an expanded term, and a MultiReader that mixes such an index with one that has positions returns only the positioned index's hits; the trigger is a schema misconfiguration that Lucene documents as an error (the Intervals.term, PhraseQuery and span versions throw), default writer, codec and searcher suffice, no scale is needed, and the index is unaffected.
Summary
You index 'alpha beta' into a field with IndexOptions.DOCS_AND_FREQS (no positions) and search IntervalQuery("body", Intervals.ordered(Intervals.prefix("alph"), Intervals.prefix("bet"))). You expect IllegalArgumentException ('... because it has no indexed positions'), because the IndexOptions javadoc says positional queries on such a field throw and the same query written with Intervals.term("alpha") and Intervals.term("beta") does throw. Instead MultiTermIntervalsSource never checks Terms#hasPositions(), runs the interval logic on the position -1 that the postings return, and silently reports 0 hits (1 of 2 on a MultiReader that also holds a positioned copy of the document); wildcard, regexp, fuzzyTerm, range and multiterm leaves behave the same way.
Introduced
7d7ab14776b7257e09679d840182a4286928e452 (master) and its branch_8x backport ceadb5f1339fe5bf09f9818470872d4957b63ba5, both 'LUCENE-8646: Add multi-term intervals' (JIRA patch by GitHub romseygeek, no GitHub PR; migrated issue apache/lucene#9692), which created MultiTermIntervalsSource on top of new static helpers TermIntervalsSource.intervals(BytesRef, TermsEnum) and matches(TermsEnum, int) that start after the hasPositions() guard of the instance methods (the guard and its test date from LUCENE-8196, 7.4.0) [read: git show and git blame in the history clone; git log --follow -S hasPositions on the file is empty; exec: 7.7.3 lacks the API (compile error) and its Intervals.term control throws, 8.0.0 fails with -ea and -da, and 8.0.0 with only MultiTermIntervalsSource replaced by its 8.0.0 source plus the two guards passes 4/4] (2019-01-22 (committed to master and branch_8x; authored 2019-01-17))
First affected version
8.0.0 [exec, -ea and -da: 2 of 4 fail, count 0 on the single index and 1 of 2 on the MultiReader, controls pass], the first release with multi-term interval sources (then in lucene-sandbox, package org.apache.lucene.search.intervals; in lucene-queries from 8.2.0, LUCENE-8856); 7.7.3 has no Intervals.prefix [exec: compile error] and its only interval leaf, Intervals.term, throws as documented [exec]
Affected versions
every release from 8.0.0 through 10.6.0 (10.6.0 [exec: the doc's minimal test on the release jars, -ea, 2026-10-30]) and main a9779ef3c193 [exec: official release jars, SHA-1 checked, JDK 25.0.3, seed DEADBEEF: all 60 releases 8.0.0-10.5.2 fail the same 2 of 4 tests with the same counts while both controls pass; -da also on 8.0.0, 8.11.4, 9.12.3, 10.5.2 and main]. 8.0.x-8.1.x is sandbox code; from 8.2.0 the class is in lucene-queries. Each factory is affected from the release that added it [read; all six executed on 9.12.0]: prefix and wildcard 8.0.0, multiterm 8.4.0, fuzzyTerm 9.1.0 (also StandardQueryParser fn:wildcard / fn:fuzzyTerm), regexp and range 9.12.0. The independent reproduction's JUnit (all six factories on DOCS and DOCS_AND_FREQS) fails on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3 (-ea) [exec]. Not fixed upstream: upstream main's MultiTermIntervalsSource is byte-identical to a9779ef3c193, and branch_10x and branch_10_6 have no guard [read, GitHub API, 2026-10-26]. Tags 8.8.2-8.11.4 are not in the history clone; for those the result comes from executing the release jars.
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
non-formal: code reading; also reached by randomized/MBT
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new, 91 query records, read-only: 53 scripted prior-report search queries over apache/lucene issues and PRs, commit messages, the migrated JIRA, the dev@, java-user@, issues@ and commits@ lists, downstream repositories and the web, plus 38 manual GitHub, JIRA and downstream searches and a scan of the changed files of all 407 open apache/lucene PRs; earlier searches by the hunt unit lead, the independent reproduction and the intended-behaviour check agree): no issue, PR, review comment, commit, JIRA entry, list thread or downstream report describes multi-term interval sources running without the positions check. Nearest items, design intent rather than reports: the LUCENE-8196 review (apache/lucene#9243) asked that TermIntervalsSource check whether positions were indexed, which it does; LUCENE-6226 (apache/lucene#7288) says positional scorers should throw like PhraseQuery; LUCENE-8646 (apache/lucene#9692, the introducing change) never mentions positions; LUCENE-9439 / LUCENE-9462 (apache/lucene#10479, #10502) made Weight#matches of non-interval queries lenient on fields without positions, which bears only on the optional matches() half of a fix; apache/lucene#16899 (merged 2026-10-26, in the tested main) edited the same two methods for the expansion-limit exception and added no check. Elasticsearch and OpenSearch guard against such fields in their own mappers (source read; elastic/elasticsearch#40418). Not fixed: no open PR touches MultiTermIntervalsSource or TermIntervalsSource. Limits: GitHub search is token-based and best effort; web search returned only unrelated results.
Lucene commit tested
main 645d4fe14a20: the minimal test through gradle (fails) and the standalone runner (-ea, -da, fails); the 10.6.0 release jars (fails). The hunt's evidence on main a9779ef3c193 (the six commits up to 645d4fe14a20 do not touch the intervals package): the finder's and an independent reproducer's tests on main, 10.5.2 and 9.12.3 (the first finder's on main ea57360912ed, 10.5.2 and 9.12.3); a history sweep over 7.7.3 and all 60 releases 8.0.0-10.5.2. In a scratch worktree at 645d4fe14a20: the recommended fix with the :lucene:queries, :lucene:highlighter and :lucene:queryparser test suites, and a variant with the intervals and highlighter suites. An independent fact check re-ran the repro and the recommended fix.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/MultiTermIntervalsSource.java:57-77 (at a9779ef3c193) (intervals(): null check, then automaton.getTermsEnum and TermIntervalsSource.intervals(term, te), no hasPositions() check), :79-103 (matches(): same, TermIntervalsSource.matches(te, doc, field))
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/TermIntervalsSource.java:54-59 (at a9779ef3c193) and :151-156 (instance methods throw IllegalArgumentException 'Cannot create an IntervalIterator over field ... because it has no indexed positions'); the static helpers start after these guards
  • lucene/core/src/java/org/apache/lucene/index/IndexOptions.java:31-41 (at a9779ef3c193) (DOCS: 'Phrase and other positional queries on the field will throw an exception'; DOCS_AND_FREQS: 'PhraseQuery and other positional queries will throw an exception')
  • lucene/CHANGES.txt:15427-15429 (at a9779ef3c193) (4.0.0-ALPHA: positional queries now throw on a field that omits positions, 'previously they silently returned no results')
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/intervals/MultiTermIntervalsSource.java:57-77, :79-103
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/intervals/TermIntervalsSource.java:50-68, :147-164; lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:51, :157
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:176-180, :206-208, :237-241, :279-295, :335-341, :369-370
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:420, :1293-1296; lucene/core/src/java/org/apache/lucene/index/PostingsEnum.java:84-88
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalIterator.java:31-33; lucene/queries/src/java/org/apache/lucene/queries/intervals/OrderedIntervalsSource.java:131, :145-151, :190-194
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21; lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/Wildcard.java:38-40, FuzzyTerm.java:44
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexOptions.java:31-41
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:15393 (4.0.0-ALPHA header), :15444-15446, :4717
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/PhraseQuery.java:504-510, MultiPhraseQuery.java:255-262; lucene/queries/src/java/org/apache/lucene/queries/spans/SpanTermQuery.java:149-155
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:260-270; lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalsSource.java:39
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/intervals/MultiTermIntervalsSource.java:57-77; lucene/queries/src/java/org/apache/lucene/queries/intervals/TermIntervalsSource.java:54-59

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

Repro files

About the diagrams

A summary still, made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.

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.