Interval queries need word positions. Lucene checks that a field has them for plain term leaves, but not for multi-term leaves.
- 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). - Positional queries need those positions:
PhraseQuery, span queries and interval queries (IntervalQuerywithIntervals.ordered,phrase,unordered,containing, ...). On a field without them, theIndexOptionsjavadoc says they throw an exception. - Interval leaves come in two kinds:
Intervals.term("alpha")for one word, and multi-term leaves such asIntervals.prefix("alph")that expand to every matching word of the index.
- Index one document,
body= "alpha beta", withIndexOptions.DOCS_AND_FREQS: Lucene stores which documents hold each word and how often, but not where (alpha is word 0, beta word 1). - Search
ordered(term("alpha"), term("beta")), "alpha, then beta". Lucene throwsIllegalArgumentException: ... because it has no indexed positions, as theIndexOptionsjavadoc promises for positional queries. - Write the same query as
ordered(prefix("alph"), prefix("bet")); the prefixes expand to exactly alpha and beta. You expect the same exception. - 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.
containingandoverlappingerr the other way and match documents they should not. - 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).
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.
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).
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().
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.
| query | result |
|---|---|
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 indexed | 1 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 flexibleStandardQueryParserreaches it throughfn:wildcardandfn: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.termleaf would usually have thrown and pointed at the schema (it throws once it is evaluated). - What it takes. One document, one query; default codec,
IndexWriterConfigand 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).
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.
/*
* 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));
}
}
}
}
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 withIntervals.term.- Observed
- No exception and 0 hits:
AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrownat line 52.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (nothing platform-specific) |
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
# 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- The
IndexOptionsjavadoc: 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, andTermIntervalsSourceandPayloadFilteredTermIntervalsSourceapply 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(soSpanMultiTermQueryWrapper(PrefixQuery)throws even alone),TermIntervalsSource.java:54-59,PayloadFilteredTermIntervalsSource.java:51. Not every positional query checks: the sandboxTermAutomatonQueryhas no check either (TermAutomatonQuery.java:423) and returned 0 hits with no error foralpha (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 forIntervals.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 builtMultiTermIntervalsSourceon 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.
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.
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; onehasPositions()call per segment inintervals()and one permatches()call (perWeight#matchesrequest 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:test52 suites, 513 tests, 0 failures (all 8 intervals suites, includingTestIntervals43 andTestIntervalQuery34);:lucene:highlighter:test34 suites, 722 tests, 0 failures (TestMatchHighlighter,TestMatchRegionRetriever,TestUnifiedHighlighterTermIntervals);:lucene:queryparser:test30 suites, 533 tests, 0 failures (TestStandardQPEnhancements,TestStandardQP). In that runcheckGoogleJavaFormatfailed 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
a9779ef3c193with 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.
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#matchesof a multi-term interval query still reports a match at position -1, whileIntervals.term'smatches()throws (TermIntervalsSource.java:151-156): the two leaf kinds stay inconsistent there. - Code that calls
Weight#matchesdirectly 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:queriespass, 118 tests, 0 failures;:lucene:highlighter:testpasses, 34 suites, 722 tests, 0 failures. The queryparser suite was not re-run for this option.
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: 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.
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 readMultiTermIntervalsSourcenext toTermIntervalsSource, saw that only the latter checkshasPositions(), and confirmed it with a public-API probe and a JUnit on the stock jars ("got intervals [-1,-1]" whereor(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 mixedMultiReader, all printed ones on multi-term interval leaves, whilePhraseQuery,MultiPhraseQuery, span andIntervals.termqueries 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs, including the migrated JIRA archive | 2026-10-26 | 16 tool and 18 manual queries: MultiTermIntervalsSource, hasPositions, "no indexed positions", "Cannot create an IntervalIterator", DOCS_AND_FREQS, "without positions" and symptom wording | no report; design-intent items #9243 and #7288 |
| Open PRs | 2026-10-26 | a file-path scan of all 407 open PRs and 3 searches | none touches MultiTermIntervalsSource, TermIntervalsSource or Intervals |
| Commits and fix status | 2026-10-26; re-checked 2026-10-30 | commit search; per-file history on main, branch_10x and branch_10_5 | last change #16899 (expansion limit), no check; upstream main 716d6f216f52 unchanged |
| JIRA archive | 2026-10-26 | 4 tool and 6 manual REST searches; comments of LUCENE-9462, -8646, -8196, -8845, -8893 | no report |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 20 archive queries | no report |
| Elasticsearch, OpenSearch, Solr | 2026-10-26 | 10 searches and a read of their mappers and intervals parser | Elasticsearch 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 |
| Web | 2026-10-26 | 7 searches | unrelated results only |
| GitHub refresh | 2026-10-30 | issue 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 PRs | nothing new; no open PR touches the intervals sources (saved record) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 7.7.3 | before the API | no Intervals.prefix (compile error); Intervals.term throws as documented |
| 8.0.0 | first release | 2 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 swap | the same two checks: 4 of 4 pass |
| all 60 releases 8.0.0-10.5.2 | affected | the 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 factories | prefix, wildcard, regexp, fuzzyTerm, range and multiterm all silent (the only release in the sweep where all six were run) |
| 10.6.0 | affected | the minimal test fails (-ea) |
main 645d4fe14a20 | affected | the minimal test fails (-ea, -da, gradle) |
main 645d4fe14a20 + fix A | not affected | the minimal test and the queries, highlighter and queryparser suites pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| first finder | lead, same root cause | Code reading, a probe and a JUnit on main ea57360912ed: intervals at [-1,-1] where or(term, term) throws. |
| finder | candidate | Metamorphic 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", low | Read-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, medium | Read-only; intended argument weak. |
| independent reproducer | real | Own 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, high | Probe 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, high | Re-ran the reproducer's JUnit with a new seed (-ea, -da): fails; passes with the fix. |
| history sweep | affected since 8.0.0 | 81 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 | keep | The finder's JUnit with a fourth seed (-ea, -da) fails; code read confirms the missing check. |
| this doc | gradle repro and fix | The 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 fixes | Re-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 sound | Read-only; found the central behaviour real and well supported; its corrections are applied or answered. |
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 467 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,374 words
- 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 maina9779ef3c193(the six commits up to645d4fe14a20do 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 mainea57360912ed, 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 at645d4fe14a20: the recommended fix with the:lucene:queries,:lucene:highlighterand:lucene:queryparsertest suites, and a variant with the intervals and highlighter suites. An independent fact check re-ran the repro and the recommended fix.
- 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 guardslucene/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-103a 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, :157a 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-370a 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-88a 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-41a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:15393 (4.0.0-ALPHA header), :15444-15446, :4717a 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-155a 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
- TestMultiTermIntervalsWithoutPositions.java (lucene/queries, package org.apache.lucene.queries.intervals, 1 test):
repro/LFV-604/src/org/apache/lucene/queries/intervals/TestMultiTermIntervalsWithoutPositions.java(56 lines)
A summary still, made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.
assets/LFV-604/bug.png(467 KB)
