All findings · Lucene finding · LFV-293 · intervals-before-matches-at-first-position

Intervals.before matches a source at the first position against a reference that also starts there

Intervals.before(source, reference) counts a source on the first token of a field as "before" a reference that also starts there: before(york, york) matches the one-word document york, but not x york. Every tested release since 8.0.0 has it, also through fn:before in the query parser.

Summary

Priority
Low, rank 346Needs a before() query whose source and reference both match only at the first token of a field, a rare query shape.
Impact
silent wrong results
Since
8.0.0 (LUCENE-8629, 2019)introduced 2019-01-07 · about 7 years
Affected
8.0.0 to 10.5.2 (12 releases run) and main
Status
documented
Found by
bounded-exhaustive comparison with an independent 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

Intervals.before(source, reference) keeps the source intervals that end before some reference interval starts. It is built from a package-private helper that puts a one-position marker just before each reference interval, then stretches the marker back to position 0 and keeps the source intervals that fit inside.

Summary card of the Intervals.before bug. Panel 1, the example: before(term new, phrase new york) over the documents 'new york', 'new york city' and 'york': expected 0 hits, actual 2 hits; before(term york, term york) on 'york': expected 0 hits, actual 1 hit; with one word in front, 'x new york' or 'x york', no match. Panel 2, inside before() on 'new york': the reference phrase is [0,1]; the PRECEDING pseudo-interval would be at -1 but Math.max(0, pos - 1) makes it 0; the window [0,0] is the phrase's own first word; the source new at [0,0] is inside it and is returned as before. Panel 3, when it happens: the source is exactly at position 0 and every reference match starts at 0; before() only adds hits, never drops one (366 extra, 0 missed); a reference at 1 or later and Intervals.after are not affected; skipping reference intervals that start at 0 fixes it; present since 8.0.0, also through fn:before and Weight#matches. Footnote: the public-API repro fails on main a9779ef3c193 and on the 12 releases run, 8.0.0 to 10.5.2.
On new york, the phrase new york starts at position 0; the position one before it is clamped from -1 to 0, so the "before" window is the phrase's own first word and the source new fits inside it. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index three documents, york, x york and york york, with default settings, and search an IntervalQuery over Intervals.before(term york, term york).
  2. You expect 1 hit, york york: before() "returns intervals from the source that appear before intervals from the reference", and only there does one york come before another.
  3. Instead you get 2 hits: the lone york matches too. x york, the same text moved one position right, correctly does not match, and ordered(york, york) and after(york, york) give the expected 1 hit.
  4. Why: before() keeps a source that fits in the window [0, p-1] before each reference start p. For p = 0 no such position exists, but the code computes Math.max(0, p - 1) = 0, so the window is [0, 0] and a source on the first token fits.
  5. The same with before(new, phrase(new york)): 3 of 4 documents match instead of 1 (new york and new york city are extra). before() only adds hits, never drops one, from 8.0.0 to 10.5.2 and on main.

Who is affected

Users of Intervals.before or of fn:before in the flexible StandardQueryParser, with default settings, in every release since 8.0.0. Only documents where the source is at position 0 and every reference match starts there: before(X, X), or a reference phrase or synonym that begins with the source term. before() only adds hits; under a negation they hide documents instead.

Mechanism, terms and numbers871 words

Intervals.before(source, reference) "Returns intervals from the source that appear before intervals from the reference"; fn:before in the flexible StandardQueryParser calls it and is documented the same way. It is built as containedBy(source, extend(PRECEDING(reference), Integer.MAX_VALUE, 0)), where PRECEDING is a package-private helper that places a one-position pseudo-interval just before each reference interval, at Math.max(0, pos - 1). For a reference starting at p > 0 the window is [0, p-1], strictly before. For p = 0 there is no position before it, but the clamp gives the window [0, 0], so a source exactly on the first token counts as before a reference that starts on the same token. before(york, york) therefore matches the one-word document york, and before(new, phrase(new york)) matches new york and new york city; moved one position right (x york, x new york), the same texts correctly do not match, and ordered() and after() give the expected answers. before() itself only gains hits, never loses one (under a negating outer query, such as a MUST_NOT clause, the extra interval can hide a document instead); Weight#matches reports the extra [0, 0] interval too, which is what Matches-based highlighting reads. No special settings are needed (StandardAnalyzer or the test framework's whitespace analyzer, the default IndexWriterConfig, a plain or the test framework's randomized searcher; several random test codecs gave the same result). The code dates from LUCENE-8629, which added before(), so every release from 8.0.0 to 10.5.2 and main is affected (12 releases run). Skipping reference intervals that start at position 0 fixes it; two variants were validated with the repro and the whole lucene/queries module.

Terms

  • Intervals: org.apache.lucene.queries.intervals matches positions inside a field. An interval is a range of token positions [start, end]; the first token of a field is position 0. IntervalQuery(field, source) matches a document when the source has at least one interval in it. The package is marked "experimental classes" (package-info.java:21).
  • before(): Intervals.before(source, reference) "Returns intervals from the source that appear before intervals from the reference" (Intervals.java:580). fn:before(source reference) in the flexible StandardQueryParser (since 9.1.0) calls it (intervalfn/Before.java:37) and says "Matches intervals from the source that appear before intervals from the reference" (intervalfn/package-info.java:425).
  • How it is built (Intervals.java:581-584): containedBy(source, extend(PRECEDING(reference), Integer.MAX_VALUE, 0)). containedBy "Returns intervals from the small query that appear within intervals of the big query" (:554). PRECEDING is the package-private OffsetIntervalsSource with before = true: "a pseudo-interval that appears ... one position before ... each interval from the reference" (OffsetIntervalsSource.java:28-31).

The bug

  1. The marker. For a reference interval starting at pos, PRECEDING returns start() = end() = Math.max(0, pos - 1) (OffsetIntervalsSource.java:51-65, the clamp at :63; end() at :94-97). The Matches path (highlighting) wraps the same iterator (:143).
  2. The window. extend(marker, Integer.MAX_VALUE, 0) stretches it back to position 0 (ExtendedIntervalIterator.java:61): [0, pos-1] for pos > 0, which is exactly "strictly before".
  3. The boundary. For pos = 0, Math.max(0, -1) is 0, so the window is [0, 0] instead of absent. containedBy keeps a source interval a when a window b has b.start <= a.start and b.end >= a.end (ContainedByIntervalsSource.java:52-61), so a source at [0, 0] passes.
  4. Why the clamp is there (presumably). A positioned iterator must not return -1 (it means "not positioned yet", IntervalIterator.java:46-52); the history gives no reason. But an iterator may skip such a reference: "it is possible for a document to return NO_MORE_INTERVALS on the first call to nextInterval()" (IntervalIterator.java:35-36).

Which documents are affected

Both must hold: the source has a one-position interval exactly at [0, 0] (a wider source starting at 0, such as a phrase, does not fit in [0, 0]), and the reference has at least one interval in the document and every one of them starts at 0 (any later reference makes [0, 0] legitimately before it). Position 0 is absolute: normally the field's first token, but a first token behind a removed leading stopword sits at position 1 (FilteringTokenFilter.java:50-58), and a token in a later value of a multi-valued field normally sits after 0; neither triggers it. Typical shapes: before(X, X) with one leading X; before(term, phrase or disjunction that starts with that term) at the start of a field; synonyms stacked on the first token, also with two distinct terms (before(a, b) with a and b stacked at position 0 matches; the same stack one position later does not); and a reference that starts at 0 without sharing a token with the source, such as extend(b, 1, 0) on a b (seen in the finder's harness). An exhaustive check over all 363 documents of 1-5 tokens over {a, b, c} and 36 before() queries (13,068 checks) found 366 extra matches, every one with the source and the earliest reference at position 0, and 0 missed.

Intervals.after is not affected: for a positioned reference interval, FOLLOWING saturates only when end + 1 reaches Integer.MAX_VALUE (OffsetIntervalsSource.java:66-83), above the highest position IndexWriter accepts; its other branches only pass the "not positioned" and "exhausted" sentinels through.

Exposure and likelihood292 words
  • Who is exposed. Users of Intervals.before, directly or nested in other interval functions, through IntervalQuery; and of fn:before in the flexible StandardQueryParser (since 9.1.0). The API is public in lucene-queries since 8.2.0 (lucene-sandbox in 8.0.0 and 8.1.x). Downstream engines expose it too: Elasticsearch's intervals query maps its before filter to Intervals.before (read in its source, not run).
  • When it shows. A query whose source can match position 0 (normally the field's first token), on a document whose reference matches all start there: before(X, X); before(term, phrase that starts with it); synonyms stacked on the first token, also two distinct stacked terms (before(a, b) with a and b both at position 0); a reference that starts at 0 for another reason, such as extend(b, 1, 0) on a b. before(a, b) with two distinct single terms, no stacked synonyms and no extension never triggers it.
  • Silent. No exception. Extra hits in IntervalQuery, an extra [0, 0] interval from the iterator and from Weight#matches (its MatchesIterator reports [0, 0], so a Matches-based highlighter would mark it; no highlighter was run), and an extra interval fed into any enclosing interval operator. Under a negating outer query (a MUST_NOT clause; by the same logic the negating interval operators) the extra interval removes a hit instead.
  • No special setup. StandardAnalyzer or a whitespace analyzer, the default IndexWriterConfig, a plain IndexSearcher or the test framework's randomized newSearcher; one document is enough; assertions on or off; several random test codecs gave the same result.
  • How likely. Rare: it needs source and reference to overlap at position 0. For such a query, the affected documents match every time. How often such queries meet such documents in real applications is unknown.

Minimal repro

The test shows that Intervals.before(york, york) matches the one-word document "york", so a term at position 0 counts as before a reference that also starts at position 0.

TestBeforeAtFirstPosition.java (JUnit test)47 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.util.LuceneTestCase;

public class TestBeforeAtFirstPosition extends LuceneTestCase {

  public void testTermIsNotBeforeItself() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new TextField("f", "york", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IntervalsSource york = Intervals.term("york");
        IntervalQuery query = new IntervalQuery("f", Intervals.before(york, york));
        // expected: 0 hits, nothing comes before the only word "york"; actual: 1 hit
        assertEquals(0, new IndexSearcher(reader).count(query));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-293/, 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.TestBeforeAtFirstPosition
Expected vs actual
Expected 0 hits (nothing comes before the only word); actual: 1 hit (expected:<0> but was:<1>), with -ea and -da.

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

More files, environment, full output34 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: expected:<0> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.queries.intervals.TestBeforeAtFirstPosition.testTermIsNotBeforeItself(TestBeforeAtFirstPosition.java:43)

Why it looks unintended

  • before() "Returns intervals from the source that appear before intervals from the reference", as does fn:before; nothing comes before the first token.
  • Shifting the text one position flips the answer (york matches, x york does not); ordered() and after() follow the strict reading.
  • Only the package-private helper's clamp is tested; no test runs before() with a reference at position 0.
The full argument216 words
  • The contract is strict. "Returns intervals from the source that appear before intervals from the reference" (Intervals.java:580); fn:before: "Matches intervals from the source that appear before intervals from the reference" (intervalfn/package-info.java:425). No first-position exception, and at every other position the code is strict ([0, p-1]).
  • Not shift invariant. york matches and x york does not; new york matches and x new york does not. No reading of "appear before" makes york precede itself in one document and not in the other.
  • The design says "ordered". LUCENE-8629, which added before(), describes it as "similar to an ordered near, but returning only intervals from 'a' rather than an interval spanning both a and b" (#9675). ordered() is strict (OrderedIntervalsSource.java:151) and gives the expected answer on the same texts.
  • Precedent. LUCENE-9099 (7c1ba1aebee, 2020, "Correctly handle repeats in ORDERED and UNORDERED intervals") lists "UNORDERED(a, a) will match on documents that just containg a single a" as "somewhat confusing behaviour" and changes it: a source matching relative to itself.
  • No test covers it. TestIntervals#testBefore (TestIntervals.java:909-913) uses before(cold, twelve), and twelve is never at position 0 in its corpus; the fn:before examples in the parser docs, TestStandardQPEnhancements and TestMatchHighlighter never put a reference at position 0.
The devil's-advocate check212 words

The strongest case for "intended" (rated moderate by the intended-lens reviewer from another model family): the clamp is deliberate code, because -1 means "not positioned"; TestIntervals#testOffsetIntervals (TestIntervals.java:541-577), added in the same commit, asserts the helper's clamped [0, 0] output; extend() also clamps at 0 by a recorded decision (LUCENE-8612, #9658: a positional filter was removed because "it ends up breaking notWithin in certain circumstances"); and the package is experimental.

Why it fails: the helper test covers only the package-private helper, never before(). The helper's own javadoc promises a pseudo-interval "one position before" each reference, and none exists before position 0. Skipping such a reference is legal (IntervalIterator.java:35-36) and is what the fix does. The extend() clamp only drops positions below 0 and still contains the reference start; the PRECEDING clamp instead adds the reference's own first position to the "before" window. "Experimental" allows API changes, not wrong answers. LUCENE-8629's only review comment is "+1, the patch looks good"; position 0 was never discussed. Left for the reviewer to weigh: both fixes remove hits that every release since 8.0.0 has returned for the affected documents (a compatibility change of unknown practical reach), and they make an existing iterator-state quirk more frequent (see Fixes). Verdict: not intended, a defect.

Suggested fix

Recommended A before()-only mode of the helper that skips references at position 0

Make the PRECEDING helper skip reference intervals that start at position 0, in a mode that Intervals.before turns on (+23/-3 in 2 files); the helper's own test is unchanged. The repro and the whole lucene/queries module pass with assertions on and off, and so do the fn:before suites in queryparser and highlighter.

   private final IntervalsSource in;
   private final boolean before;
+  // Set by Intervals.before: a reference interval that starts at position 0 has no position
+  // before it, so it produces no pseudo-interval instead of one clamped to position 0
+  private final boolean skipFirstPosition;
 ...
+        @Override
+        public int nextInterval() throws IOException {
+          int pos = in.nextInterval();
+          while (skipFirstPosition && pos == 0) {
+            pos = in.nextInterval();
+          }
+          return start();
+        }
All options and trade-offs3 options · 899 words

Both validated fixes stop PRECEDING from producing a pseudo-interval for a reference interval that starts at position 0, so a document whose every reference starts there has no "before" window. The first option adds a mode that only before() uses and leaves TestIntervals#testOffsetIntervals unchanged; the second changes the helper's PRECEDING mode for every caller (before() is its only production user; after() uses FOLLOWING, which neither fix touches) and updates three rows of that test. Both remove hits that every release since 8.0.0 has returned for the affected documents. Documenting the boundary instead is possible but leaves the shift variance. Until a fix ships, Intervals.ordered(source, reference) gives the expected documents for match/no-match filtering (it gave them in the repro's controls); its intervals span both sources, so highlighting and interval scores differ.

A before()-only mode of the helper that skips references at position 0 (validated by the checks) (recommended)

In OffsetIntervalsSource, add a skipFirstPosition flag (a 3-argument constructor; the 2-argument one passes false; the flag joins equals/hashCode), and in the PRECEDING iterator override nextInterval() to skip reference intervals that start at 0 when the flag is set. Intervals.before constructs the helper with the flag. The finder's sketch, with its comment reworded to fit 100 columns.

   private final IntervalsSource in;
   private final boolean before;
+  // Set by Intervals.before: a reference interval that starts at position 0 has no position
+  // before it, so it produces no pseudo-interval instead of one clamped to position 0
+  private final boolean skipFirstPosition;
 ...
+        @Override
+        public int nextInterval() throws IOException {
+          int pos = in.nextInterval();
+          while (skipFirstPosition && pos == 0) {
+            pos = in.nextInterval();
+          }
+          return start();
+        }

Trade-offs

  • No existing test changes; the price is a flag that only matters in PRECEDING mode: the only PRECEDING construction with the flag off is TestIntervals#testOffsetIntervals (after() also passes false through the 2-argument constructor, with no effect, though the flag now takes part in equals/hashCode). toString() stays PRECEDING(...). Cost: one comparison per reference interval.
  • Side effect: no wrong matches or interval contents were found, but iterator-state protocol violations become more frequent. In a document whose every reference starts at 0, the reference side now has no interval, so FilteringIntervalIterator.start()/end() return NO_MORE_INTERVALS instead of -1 before the first nextInterval() (FilteringIntervalIterator.java:36-50, reset() at :57-60). That pre-existing quirk is already reachable whenever a filter has no interval in a document; the fix makes it happen in more documents: in the finder's harness the before() protocol-check failures rise from 4,668 to 11,436 (documents of up to 4 positions) and from 11,973 to 35,568 (up to 3 positions with stacked synonyms). Worth stating in the PR.
  • Compatibility: the affected documents stop matching, a change of results every release since 8.0.0 has returned; how many applications see such documents is unknown.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the new test, 2 of 3 fail with assertions on and off; + the fix, assertions on: :lucene:queries:check (ecjLint, forbiddenApis, checkGoogleJavaFormat and all 52 suites, 515 tests of the module, including TestIntervals 43 and the new test 3) plus TestStandardQPEnhancements (23) and TestMatchHighlighter (6), both of which use fn:before: 544 tests, 5 skipped, 0 failures; assertions off: the same tests, 6 skipped, 0 failures.
  • Standalone runner: 3 of 3 pass with assertions on and off.
  • Earlier, on ea57360912ed: the finder's, the independent reproducer's, the reviewer's and the devil's advocate's tests and the security probe pass with the finder's version; the exhaustive mismatches drop from 366 to 0; the finder's harness finds no extra before() interval in two before()-only re-runs (1,408,044 checks) and in a smaller re-run with all six of the unit's candidate fixes (287,753,697 checks).

The helper itself skips references at position 0 (for every caller) (validated by the checks)

Override nextInterval() in the PRECEDING iterator to skip reference intervals that start at 0 unconditionally, say so in the class javadoc, and drop the first 0, 0 pair from the three non-empty PRECEDING rows of TestIntervals#testOffsetIntervals (+11/-1, and +4/-3 in the test).

+        @Override
+        public int nextInterval() throws IOException {
+          int pos = in.nextInterval();
+          while (pos == 0) { // nothing precedes the first position
+            pos = in.nextInterval();
+          }
+          return start();
+        }

Trade-offs

Smaller (no flag, constructor or equals/hashCode change) and makes the helper match its javadoc. before() is the only production user of PRECEDING (Intervals.java:583), the only mode it changes; after() (:590) uses FOLLOWING. So results are identical to the first option. It changes an assertion a reviewer may read as deliberate; the same compatibility change and FilteringIntervalIterator side effect apply.

What the checks validated

Same worktree: assertions on, the whole module's tests and the two fn:before suites, 544 tests, 5 skipped, 0 failures (TestIntervals 43 of 43 with the updated rows), and :lucene:queries:check -x test passes; assertions off, the same 544 tests, 6 skipped, 0 failures. Standalone runner: 3 of 3 pass with assertions on and off.

Document the boundary instead

State in the before() and fn:before javadoc that a source at position 0 counts as before a reference that starts at position 0.

Trade-offs

Changes no code, but keeps the shift variance and the disagreement with ordered(). Not built; not recommended.

Downstream note

Downstream rules built on before(), such as an intervals before filter used to allow or deny documents, let these documents through or hide them by the same mistake.

How it was found

An AI finder compared Lucene's interval iterators with an independently written reference semantics on every small document and every 1- and 2-level interval expression, about 1.4 billion checks. Extra before() intervals showed up; none remained in smaller re-runs with references at position 0 skipped, and it reduced them to a two-test JUnit.

The method in full194 words

Method: bounded-exhaustive translation validation of the stock main jar against an independent reference semantics (the hunt's "semi-formal" tier).

  • The hunt unit on spans and intervals wrote a reference semantics for the interval operators (IvRef) and a harness (IvTV) that runs the real iterators on every document of up to 6 positions over {a, b, c} and up to 4 positions with stacked synonyms, for every 1- and 2-level expression over 24 interval operators: 1,401,702,324 checks.
  • The exact-interval check reported extra intervals for 206,037 of 7,200,414 before() checks (alone, under extend, with repeated sources, or both). With PRECEDING skipping reference intervals that start at position 0, none remain in two before()-only re-runs (1,408,044 checks, documents of up to 4 positions and up to 3 with stacked synonyms) and in a smaller re-run with all six of the unit's candidate fixes (287,753,697 checks; it did not cover the whole first run).
  • The finder reports that it first noticed the clamp while reading OffsetIntervalsSource to build the model; the harness then witnessed it, and it was reduced to a public-API JUnit. Independent AI tools reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found: 169 logged queries over GitHub, the JIRA archive, the mailing lists, downstream projects and the web (2026-10-26), a re-check on 2026-10-27, and the changed files of all 413 open PRs. The closest items are the issue that added before() and a fixed bug of the same kind in unordered().

Searches3 searches

No issue, PR, JIRA ticket, list thread or downstream report discusses before() matching a source at position 0 against a reference at position 0. On upstream main 20ced71c1e6e (2026-10-27) OffsetIntervalsSource.java was last changed in 2020 (a reformat) and Intervals.java by #16899, which is in the tested commit; no open PR touches either.

Closest, not this bug: #9675 (LUCENE-8629, added before(); position 0 never discussed); #9658 (LUCENE-8612) and lucene-solr PR #2127 (LUCENE-9633): extend() clamps at 0 by design, and a stale ExtendedIntervalIterator javadoc; #10141 (LUCENE-9099, UNORDERED(a, a) matching a single a, fixed); #11259 (LUCENE-10223, added fn:before).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), Elasticsearch, OpenSearch, Solr, dev@ / java-user@ / issues@ / commits@ lists, JIRA archive, web2026-10-26169 logged queries: 128 from four runs of a scripted prior-report search and 41 manual GitHub, JIRA, list and web searches (8 web queries planned by the tool were not run); every issue or PR with "interval(s)" in the title and all 38 LUCENE JIRA issues with "interval(s)" in the summary scanned by titleonly design context (#9675, #9658, lucene-solr #2127) and the LUCENE-9099 precedent
Open PRs2026-10-26the changed files of all 413 open apache/lucene PRs4 touch interval files; none touches OffsetIntervalsSource or before()
GitHub (apache/lucene), re-check2026-10-275 issue searches (OffsetIntervalsSource, fn:before, intervals position 0, before first position, Intervals.before), 3 open-PR searches, and the commit history of the three files on upstream main0 results; the code is unchanged upstream

History

Introduced with before() itself by LUCENE-8629 (7d34bfdaad37, branch_8x 3593af2ac55f, 2019-01-07), first released in 8.0.0; not a regression. The same test fails on all 12 releases run from 8.0.0 to 10.5.2 and on main.

Versions tested7 rows

Introduced by LUCENE-8629 "New interval functions: before, after, within, overlapping" (@romseygeek; 7d34bfdaad37 on master and 3593af2ac55f on branch_8x, 2019-01-07; a JIRA patch, migrated to #9675, no GitHub PR). The one commit added before() as containedBy(source, extend(PRECEDING(reference), MAX, 0)), the Math.max(0, pos - 1) clamp, testOffsetIntervals and testBefore. Later commits (LUCENE-8633, LUCENE-3041, LUCENE-8477, LUCENE-8856, LUCENE-9050, LUCENE-9099, LUCENE-9570) did not change that logic. In lucene-sandbox for 8.0.0 and 8.1.x, in lucene-queries from 8.2.0, and reachable through fn:before from 9.1.0. Not fixed upstream.

Caveats: every run used JDK 25.0.3 on Linux aarch64; before 9.1.0 the test was ported only in its package line and test-framework imports. Tag containment: 3593af2ac55f is in every 8.x release tag of the local history clone (8.0.0 to 8.8.1) and 7d34bfdaad37 in every release tag from 9.0.0 to 10.5.2; the clone has no tags for 8.8.2 to 8.11.4. Of those, 8.9.0, 8.10.1, 8.11.3 and 8.11.4 were run and fail; 8.8.2, 8.10.0 and 8.11.0 to 8.11.2 were neither run nor covered by containment (they lie between failing releases).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis brief's test 2 of 3 fail (gradle and the standalone runner, assertions on and off); the finder's and the independent reproducer's tests fail as recorded
10.5.2, 9.12.3 affectedthis brief's test 2 of 3 fail (assertions on and off); every earlier test fails too
8.0.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.1.0, 10.0.0 affectedthe finder's test 2 of 2 fail with the same numbers, assertions on and off
8.9.0, 8.10.1, 8.11.3 affectedthe finder's test 2 of 2 fail, assertions on (run by the fact check)
7d34bfdaad37, 3593af2ac55f (LUCENE-8629) introducingadded before() and the clamp (read); 8.0.0 is the first release containing it
7.6.0, 7.7.3 no APIno Intervals.before: the port fails to compile only on that method
8.0.0 + fix; main + fix not affectedthe repro passes; on main the whole lucene/queries module passes too

Independent checks

An independent reproducer, a devil's advocate and reviews by another model family agree on the cause; the reproducer and the devil's advocate reproduced it with their own tests. An adjudication confirmed it (high), and re-runs on fresh seeds and on the current main reproduce it.

Checks and verdicts388 words

Every executed check ran its main repro with assertions on and off, except a separate re-run (-ea only). The cross-model reviews ran read-only on ea57360912ed; separate runs then ran the tests. No review executed code by itself.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateThe exhaustive harness flagged extra before() intervals; the 2-test public-API JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the fix.
cross-model review, refute lens procedural "not real" (low)An independent reviewer from another model family (OpenAI GPT-5.6) could not run Java, so it voted not real on procedure only; it agreed with the root cause, found the inputs in contract and rated the intended case moderate. It recorded fix_fixes_it=false after seeing only the helper-level fix file, and proposed a before()-only variant, which is the first fix option. A separate run re-ran the finder's JUnit: 2 of 2 fail.
Independent reproducer realOwn JUnit from the claim only, outside the intervals package: 4 of 4 fail on main (-ea, -da), 10.5.2 and 9.12.3; Weight#matches non-null; exhaustive 366 extra, 0 missed.
Cross-model review, intended lens real (medium)OpenAI GPT-5.6, read-only; separate controls: 3 of 4 fail on main (-ea, -da), including the raw iterator and fn:before; 4 of 4 pass with the fix.
Devil's advocate realBuilt the intended case and rejected it; own probe: before() not shift invariant and disagrees with ordered(), after() shift invariant in 24 of 24, on main, 10.5.2 and 9.12.3; passes with the fix.
adjudication confirmed (high)Re-ran the reproducer's JUnit (4 of 4 fail, -ea and -da) and the finder's (2 of 2), and the reproducer's with the fix (4 of 4 pass, mismatches 366 to 0); confirmed the root cause from source.
re-runs still reproducesFresh seeds on ea57360912ed and a replay on a9779ef3c193: same failures; the fix passes.
fact checks text correctedAn independent fact check re-ran the gradle command, the standalone runs (main, 10.5.2, 9.12.3, with assertions on and off), both fixes and TestIntervals, and added 8.9.0, 8.10.1, 8.11.3 and a stacked-synonym probe; a read-only review by another model family (OpenAI GPT-5.6) checked the text. Both found wording errors only.

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

Finding record

LFV-293, snapshot of 2026-11-06

Registry title
Intervals.before(source, reference) treats a source interval at position 0 as before a reference interval that also starts at position 0, so for example before(york, york) matches the one-word document 'york'
Area
queries / intervals (Intervals.before, OffsetIntervalsSource PRECEDING)
Priority
Low, rank 346: Needs a before() query whose source and reference both match only at the first token of a field, a rare query shape.
Impact class
silent wrong results
Impact note
Anyone who uses Intervals.before, directly, through IntervalQuery or through StandardQueryParser fn:before (since 9.1.0), with default writer, codec, analyzer and searcher settings, at any index size and in every release since 8.0.0, gets an extra hit, and an extra interval from Weight#matches, on each document where the source matches exactly the first token and every reference match also starts at the first token (before(X, X) with a single leading X, before(term, phrase that starts with that term) at the start of a field, or synonyms stacked on the first token); matches are only ever added, never lost, Intervals.after is not affected, and nothing is written to the index.
Summary
You search with Intervals.before(source, reference), for example before(term new, phrase(new, york)) on the text 'new york', or before(term york, term york) on the one-word text 'york'. You expect no match, because the javadoc says before 'Returns intervals from the source that appear before intervals from the reference' and nothing comes before the first word. Instead both documents match: before() is built from a helper that places a pseudo-interval one position before each reference match, and for a reference at position 0 the helper clamps that position to 0 instead of producing nothing, so a source at position 0 counts as 'before' it; prepend one unrelated word and the same text correctly stops matching.
Introduced
7d34bfdaad37a25bfda5df2bf2c2bd1a31540bc2 (master) and its branch_8x copy 3593af2ac55f12a2ce2cfa76fd58407aa3fafa67 (same day, same diff; the commit the 8.x releases contain), 'LUCENE-8629: New interval functions: before, after, within, overlapping' (romseygeek; a JIRA patch, migrated to apache/lucene#9675, no GitHub PR) [read: git show, git log --follow and git blame of OffsetIntervalsSource.java and git log -L of Intervals.before in the full-history clone]. The one commit created both halves: Intervals.before as containedBy(source, extend(PRECEDING(reference), MAX, 0)) and the PRECEDING start() = Math.max(0, pos - 1). Later commits that touched the class (LUCENE-8633, LUCENE-3041, LUCENE-8477, LUCENE-8856, LUCENE-9050, LUCENE-9099, LUCENE-9570) did not change that logic. Present since the API was created, not a regression (2019-01-07 (7d34bfdaad37 and 3593af2ac55f))
First affected version
8.0.0 [exec] (release tag 2019-03-13; lucene-sandbox artifact, package org.apache.lucene.search.intervals; CHANGES 8.0.0 New Features 'LUCENE-8629: New interval functions: Intervals.before(), Intervals.after(), Intervals.within() and Intervals.overlapping()'); 7.6.0 and 7.7.3 have no Intervals.before [exec: a port of the repro fails to compile only on the missing before(IntervalsSource, IntervalsSource)]; the before()-only fix applied to the 8.0.0 sources makes the 8.0.0 port pass 2 of 2 [exec]
Affected versions
every release from 8.0.0 through 10.5.2 and main ea57360912ed. [exec] by the follow-up history sweep (the finder's 2-test JUnit, ported only in its package line and test-framework imports for releases before 9.1.0; its positive control passes everywhere): 8.0.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main all fail 2 of 2 with identical messages under -ea and -da; the independent reproduction (4 tests including a bounded-exhaustive oracle and the Matches API) and the intended-behaviour check (shift-invariance probe) also fail on 10.5.2 and 9.12.3. [read] 3593af2ac55f is in every 8.x tag of the history clone (8.0.0 to 8.8.1) and 7d34bfdaad37 in every 9.x and 10.x tag (9.0.0 to 10.5.2); 8.8.2 to 8.11.3 were not run individually. Exposure: lucene-sandbox 8.0.0 to 8.1.x, lucene-queries (org.apache.lucene.queries.intervals) from 8.2.0 (LUCENE-8856), query strings through StandardQueryParser fn:before from 9.1.0 (LUCENE-10223; executed on main by a separate check and on main, 10.5.2 and 9.12.3 by the security probe). Not affected: every release up to 7.7.3 (API absent) [exec]. Not fixed: upstream main HEAD was the tested commit at search time, OffsetIntervalsSource was last changed in 2020 (a reformat) on main and branch_10x, and no open PR touches OffsetIntervalsSource or before() [read]; 10.6.0 (released 2026-10-29) was not checked (it was not on Maven Central on 2026-10-26). All runs on aarch64 with JDK 25.0.3
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 (follow-up prior-report record verdict new, final pass 2026-10-26, read-only): 169 logged queries, from a scripted prior-report search in four runs (finder, the intended-behaviour check and two follow-up runs: GitHub issues and PRs on apache/lucene, commits, the downstream repositories Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web) plus manual GitHub, JIRA, list and web searches; every apache/lucene issue or PR with 'interval' or 'intervals' in the title and all 38 LUCENE JIRA issues with 'interval(s)' in the summary were scanned by title, the related ones read in full, and the changed files of all 413 open apache/lucene PRs were checked. Nearest items, none the same bug: LUCENE-8629 / #9675 (introduced before(); the only review comment is '+1, the patch looks good', position 0 never discussed); LUCENE-8612 / #9658 (Intervals.extend: the author tried, then removed, a positional filter, so extend() clamps at position 0 by design); lucene-solr PR #2127 / LUCENE-9633 (notes that the ExtendedIntervalIterator javadoc about positions below n is wrong because extend clamps to 0; still unchanged on main, a separate doc nit); LUCENE-9099 / #10141 (same defect class: UNORDERED(a, a) matching a single a was fixed as a bug); LUCENE-10223 / #11259 (added fn:before). Not fixed upstream: no open PR touches OffsetIntervalsSource or before(). Gaps: GitHub search reads only the first page per query; PR review comments only as far as GitHub's issue search indexes them; JIRA attachments were not read; two tool-planned web queries were not run (throttling)
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars (assertions on and off; 9.12.3 off was run by the fact check). The finder's 2-test JUnit ran earlier on 8.0.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main ea57360912ed (assertions on and off), on 8.9.0, 8.10.1 and 8.11.3 (assertions on, fact check), and was re-run on a9779ef3c193. Two fixes were validated in the scratch worktree: the new test, the whole lucene/queries module (515 tests, including ecjLint, forbiddenApis and checkGoogleJavaFormat), TestStandardQPEnhancements and TestMatchHighlighter.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:578 (before javadoc 'Returns intervals from the source that appear before intervals from the reference'), :579-582 (before = ContainedByIntervalsSource.build(source, extend(new OffsetIntervalsSource(reference, true), Integer.MAX_VALUE, 0))), :584-589 (after)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OffsetIntervalsSource.java:28-31 (class javadoc: 'a pseudo-interval that appears either one position before or one position after each interval from the reference'), :51-65 (PRECEDING start(): -1 and NO_MORE_INTERVALS passed through, else Math.max(0, pos - 1) at :63), :66-82 (FOLLOWING start()), :94-108 (end() = start(); nextInterval advances the reference once)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21 ('experimental classes')
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:541 (testOffsetIntervals asserts the helper's clamped [0,0] output), :909 (testBefore; reference 'twelve' never at position 0)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/Before.java:37 (fn:before calls Intervals.before); intervalfn/package-info.java:423-425 (fn:before 'Matches intervals from the source that appear before intervals from the reference')
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:554, (at a9779ef3c193) :580, :581-584
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OffsetIntervalsSource.java:28-31, (at a9779ef3c193) :51-65 (:63), :66-83, :94-97, :104-108, :143
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ExtendedIntervalIterator.java:61; (at a9779ef3c193) ContainedByIntervalsSource.java:52-61; IntervalIterator.java:35-36, :46-52; package-info.java:21; lucene/core/src/java/org/apache/lucene/analysis/FilteringTokenFilter.java:50-58
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/Before.java:37; (at a9779ef3c193) package-info.java:423-425
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:580; (at a9779ef3c193) OrderedIntervalsSource.java:151; IntervalIterator.java:35-36
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:423-425 (at a9779ef3c193)
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:541-577 (at a9779ef3c193) (testOffsetIntervals), :909-913 (testBefore)
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteringIntervalIterator.java:36-50, (at a9779ef3c193) :57-60; IntervalIterator.java:31-33; OrderedIntervalsSource.java:151
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:548, (at a9779ef3c193) :562 (the only other OffsetIntervalsSource constructions)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:583, (at a9779ef3c193) :590 (the two production OffsetIntervalsSource constructions)

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

Repro files

About the diagrams

The PNG is the final summary card.

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.