All findings · Lucene finding · LFV-610 · daterange-tostring-block-end-collapses-to-first-year

DateRangePrefixTree prints [1990 TO 1999] as "[1990 TO 1000]": a range end on the last year of a thousand-year block is printed as the block's first year

DateRangePrefixTree prints [1990 TO 1999] as "[1990 TO 1000]", which its own parseShape rejects, and [* TO 1999-12-31] as "[* TO 1000]", a range 999 years shorter. Searches stay correct; only the printed text (in toString, query toString and logs) is wrong.

Summary

Priority
Low, rank 775DateRangePrefixTree prints ranges that end on a block's last year with the wrong end, which breaks the toString round trip while queries stay correct, so it sits with the niche toString round-trip row LFV-356.
Impact
minor
Since
5.0.0 (LUCENE-5648, 2014), in the first version of the codeintroduced 2014-06-05 · over 12 years
Affected
every release 5.0.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: an exhaustive print-and-re-parse check over edge dates, then a JUnit

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

A range end that is the last year of a thousand-year block is printed as that block's first year.

How the date tree stores a range

  1. DateRangePrefixTree cuts time into nested cells: million-year blocks, thousand-year blocks (years 1000-1999, 2000-2999, ...), single years, then months, days and so on down to milliseconds.
  2. A range is a start cell and an end cell, each first replaced by the coarsest cell that means the same thing. An end of 1999-12-31 is the last day of 1999, and 1999 is the last year of 1000-1999, so the end becomes that thousand-year block.
  3. ISO dates have no way to write a thousand-year block, so the printer writes a block as a single year.
Diagram of the bug: a time line with the thousand-year block 1000-1999. The range [1990 TO 1999] starts at the year 1990 and ends with the whole block 1000-1999, because 1999 is the block's last year. The printer writes the start as 1990 and the end block as its first year, 1000, so toString gives "[1990 TO 1000]", and parseShape rejects that string with 'Wrong order'. The correct end would be printed as 1999.
[1990 TO 1999]: the end 1999 is stored as the thousand-year block 1000-1999 and printed as the block's first year, 1000, instead of its last, 1999; parseShape then rejects "[1990 TO 1000]". Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Create new DateRangePrefixTree(DateRangePrefixTree.DEFAULT_CAL) and parse a decade: parseShape("[1990 TO 1999]"). The shape is right: it contains 1999-06-15, and searches with it match correctly.
  2. You expect shape.toString() to give a string that parseShape reads back as the same range, such as "[1990 TO 1999]": its javadoc says the result "should be parseable by parseShape(String)".
  3. Instead it prints "[1990 TO 1000]". The tree stores the end 1999 as the whole block 1000-1999, because 1999 is that block's last year, and the printer writes any block as its first year: right for a start, wrong for an end.
  4. Parsing that string throws IllegalArgumentException: Wrong order: 1990 TO 1000. When the start lies at or before the block's first year, it parses without error to a shorter range: [* TO 1999-12-31] prints "[* TO 1000]", which no longer contains 1500-06-15.
  5. Query toString shows the same text: queries built from [* TO 1999-12-31] and from its printed text both print queryShape=[* TO 1000] but are not equal. Matching, query equality and the index use the shape object and are unaffected.

Who is affected

Users of the opt-in, experimental DateRangePrefixTree (spatial-extras; Solr's DateRangeField is built on it) with ranges ending on the last year of a thousand-year block: 1999, 2999, 9999, 2999-12-31T23:59:59.999. Query toString and logs show the wrong end; code that stores the text and parses it again gets an exception or a shorter range.

Mechanism, terms and numbers767 words

DateRangePrefixTree (spatial-extras, @lucene.experimental) indexes date ranges. When a range's end is the last year of a thousand-year block (1999, 2999, ..., 9999, at any precision down to 1999-12-31T23:59:59.999) or of a million-year block (2 BC), the range normalization stores that end as the whole block, and toString prints the block as its first year. So [1990 TO 1999] prints as "[1990 TO 1000]", which parseShape rejects with Wrong order, and [* TO 1999-12-31] prints as "[* TO 1000]", which parses to a range that ends 999 years early. The shape object is right, so matching, equality and the index are unaffected; the wrong text shows in the prefix-tree queries' toString, in logs, and in any code that stores or forwards a range as text and parses it again. In every release since 5.0.0.

The mechanism (main 645d4fe14a20)

  1. The tree. Level 1 has 586 million-year cells, level 2 has 1,000 thousand-year cells in each, level 3 has 1,000 years in each (only the cells at the tree's maximum date are partial, getNumSubCells, DateRangePrefixTree.java:172-177), then months, days, hours, minutes, seconds and milliseconds (DateRangePrefixTree.java:128-140, YEAR_LEVEL = 3 at :102). The year count is offset by a whole number of million years (:153-154), so the thousand-year cells hold the years 0-999, 1000-1999, 2000-2999, ... (toShape, :315-322). 1999 is year 999, the last, of the cell 1000-1999.
  2. Parsing. parseShape("[1990 TO 1999]") parses both ends as cells and calls toRangeShape (NumberRangePrefixTree.java:205-215).
  3. Normalization. toRangeShape replaces each end with the coarsest equivalent cell: "this normalization/optimization process is actually REQUIRED based on assumptions elsewhere." (:118-119). truncateEndVals (:124, "chops off trailing max-vals"; :160-166) walks up while a value is the last one of its parent, so the end 1999 becomes the level-2 cell 1000-1999. The same holds for 1999-12, 1999-12-31 and so on down to 1999-12-31T23:59:59.999. An end of ISO year -1 (2 BC) or 999999 is the last year of a million-year cell and becomes that level-1 cell ([1 TO 999999] prints "[0001 TO 0000]", executed by the hunt's probe). The shape [1990, 1000-1999] covers exactly 1990-01-01 to 1999-12-31.
  4. Printing. SpanUnitsNRShape.toString prints both ends with the same per-cell printer (:399-406): "[" + toString(minLV) + " TO " + toString(maxLV) + "]".
  5. The per-cell printer. DateRangePrefixTree.toString(UnitNRShape) is toString(toCalendar(lv)) (DateRangePrefixTree.java:380-383). toCalendar adds up only the values the cell has (:357-363), so a level-2 cell gives the block's first year, at year precision (:364-370). The end 1000-1999 prints as 1000. For a range start that is right: [1000 TO 1998] round-trips.
  6. Re-parsing. parseShape("[1990 TO 1000]") throws IllegalArgumentException("Wrong order: ...") (NumberRangePrefixTree.java:126-129, documented at :114-115). If the start lies at or before the block's first year (*, 0500, 1000-03), there is no error and the range now ends on the last day of the block's first year: 999 years early, or about a million years for a million-year end cell.

Observed on main 645d4fe14a20 (the brief's probe; assertions on and off give the same lines)

input to parseShapetoString()parseShape of that string
[1990 TO 1999][1990 TO 1000]throws Wrong order: 1990 TO 1000
[2014 TO 2999][2014 TO 2000]throws Wrong order: 2014 TO 2000
[2014 TO 2999-12-31T23:59:59.999][2014 TO 2000]throws Wrong order: 2014 TO 2000
[-9999 TO -1] (ends in 2 BC)[-9999 TO -1000000]throws Wrong order: -9999 TO -1000000
[* TO 1999-12-31][* TO 1000]a different shape: contains 1500-06-15 before, disjoint from it after
[1990 TO 2999][1990 TO 2000]a shorter shape
[* TO 9999], [2020 TO 9999][* TO 9000], [2020 TO 9000]shorter shapes
controls: [1990 TO 1998], [1000 TO 1998], [* TO 2014-09-15]the same stringsthe same shapes

IntersectsPrefixTreeQuery built from [* TO 1999-12-31] and from its printed text both print queryShape=[* TO 1000], and equals is false. With the fix every row round-trips ([-9999 TO -1] prints "[-9999 TO -0001]", [* TO 1999-12-31] prints "[* TO 1999]"), and the two queries print queryShape=[* TO 1999] and are equal.

Not part of this bug: a range that is exactly one block ([2000 TO 2999]) normalizes to a single cell and prints as "2000", which parses back as the single year 2000. The date syntax has no token for such a cell and an upstream test accepts a date-like string for a bare coarse cell, so that may be intended; the fix leaves it unchanged.

Exposure and likelihood328 words
  • Who. Applications that use DateRangePrefixTree from the opt-in spatial-extras module (@lucene.experimental), for example through NumberRangePrefixTreeStrategy. Downstream, Apache Solr's DateRangeField is built on it (read from Solr's code, not run).
  • Trigger. Either built-in calendar (DEFAULT_CAL or JAVA_UTIL_TIME_COMPAT_CAL) and ordinary input: decades or centuries ending in a year X999, [* TO 1999-12-31], and far-future open-end values such as 2999-12-31T23:59:59.999 or 9999. Deterministic, assertions on or off.
  • Misleading text. A query over [* TO 1999-12-31] prints queryShape=[* TO 1000] in IntersectsPrefixTreeQuery, ContainsPrefixTreeQuery and WithinPrefixTreeQuery toString; logs and debug output show a range that is not the one searched, and two different queries print identically.
  • Broken round trip. Code that keeps a range as toString() text and parses it later gets IllegalArgumentException when the start lies inside the end's block after its first year, and otherwise a range that silently ends 999 years early (about a million years for a million-year end cell: [* TO -1] prints "[* TO -1000000]", which parses without error). Over 452,984 legal ranges in two trees (the default and the java.time-compatible calendar; grids of 434 and 839 edge values), the hunt's exhaustive check found 10,430 silent interval mismatches and 4,655 parse exceptions, all with a block end (default tree: 2,464 and 1,477), and none with the fix (the grids are not a usage frequency).
  • Downstream reach (read, not run). Solr stores the client's input string for a DateRangeField value and uses shape.toString() only for values that arrive as a Shape object through the Java API; an atomic update of such a document re-parses that stored text.
  • Not affected. Matching, query equals/hashCode, caching and the index (CheckIndex passes on an index with the affected ranges). No extra cost: a parse-print-re-parse cycle takes 5.8 to 11.1 microseconds, flat.
  • How likely: plausible. Such range ends are ordinary, but the harmful half needs code that re-parses the text, and nothing was reported in the 11 years since 5.0.0.

Minimal repro

One JUnit class, one test method, three lines of public API, no index: parse [1990 TO 1999], print it, parse the printed string. Deterministic: the default calendar is UTC with the ROOT locale.

TestDateRangeBlockEndToString.java (one test class)30 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.spatial.prefix.tree;

import org.apache.lucene.spatial.prefix.tree.NumberRangePrefixTree.NRShape;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDateRangeBlockEndToString extends LuceneTestCase {

  public void testRangeEndingOnLastYearOfMillennium() throws Exception {
    DateRangePrefixTree tree = new DateRangePrefixTree(DateRangePrefixTree.DEFAULT_CAL);
    NRShape shape = tree.parseShape("[1990 TO 1999]");
    // expected toString() to parse back to the same shape; actual "[1990 TO 1000]" -> "Wrong order"
    assertEquals(shape, tree.parseShape(shape.toString()));
  }
}
Command
cd $LUCENE && ./gradlew :lucene:spatial-extras:test --tests TestDateRangeBlockEndToString -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
parseShape(shape.toString()) returns the same shape; the printed string would be "[1990 TO 1999]".
Observed
java.lang.IllegalArgumentException: Wrong order: 1990 TO 1000 from parseShape, assertions on and off; the same on 10.6.0 and 9.1.0.

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

More files, environment, full output199 words

The test asserts that parseShape(shape.toString()) equals the shape. On a stock build it fails with the IllegalArgumentException that parseShape throws on the printed string, so the message Wrong order: 1990 TO 1000 shows the printed end (1000, expected 1999). With the fix the assertEquals runs and passes. It failed with both seeds run (DEADBEEF, 5EED1234); all lines are within 100 columns.

Regression test: this same class; it passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 10.6.0 and 9.1.0: fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain Java calendar and string logic)
Command (environment)
cp $REPRO/TestDateRangeBlockEndToString.java $LUCENE/lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/tree/
cd $LUCENE && ./gradlew :lucene:spatial-extras:test --tests TestDateRangeBlockEndToString -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (suite output trimmed; -Ptests.asserts=false identical)
TestDateRangeBlockEndToString > testRangeEndingOnLastYearOfMillennium FAILED
    java.lang.IllegalArgumentException: Wrong order: 1990 TO 1000
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:ACD30FF6D2086A48]:0)
        at org.apache.lucene.spatial.prefix.tree.NumberRangePrefixTree.toRangeShape(NumberRangePrefixTree.java:128)
        at org.apache.lucene.spatial.prefix.tree.NumberRangePrefixTree.parseShape(NumberRangePrefixTree.java:215)
        at org.apache.lucene.spatial.prefix.tree.TestDateRangeBlockEndToString.testRangeEndingOnLastYearOfMillennium(TestDateRangeBlockEndToString.java:28)

1 test completed, 1 failed

# standalone runner, seed DEADBEEF (summary of the runs)
main 645d4fe14a20  -ea: FAIL 1/1   -da: FAIL 1/1
10.6.0             -ea: FAIL 1/1   -da: FAIL 1/1
9.1.0              -ea: FAIL 1/1
message: java.lang.IllegalArgumentException: Wrong order: 1990 TO 1000

Why it looks unintended

  • NRShape#toString: "The result should be parseable by parseShape(String)". "[1990 TO 1000]" is not parseable at all.
  • The class's author stated the intent on LUCENE-5648 (#6710): "you can round-trip toString() and parseShape() ... or a range".
  • Range optimization only swaps a range for an equivalent one (the upstream optimizer test's examples), and an exact string exists: the block's last year, 1999.
The full argument210 words
  • Contract. NRShape#toString: "The result should be parseable by {@link #parseShape(String)}." (NumberRangePrefixTree.java:84). parseShape: the range syntax "is the same thing as the toString() of the range shape, notwithstanding range optimization." (:199). The per-cell printer: "A string representation of the UnitNRShape that is parse-able by {@link #parseUnitShape(String)}." (:174-178).
  • Author's intent. On LUCENE-5648 (now #6710), David Smiley, who wrote the class, described a patch on 2014-05-21: "Parses calendar ranges. This means you can round-trip toString() and parseShape() wether it's a single Calendar value "2014-05" or a range "[- TO 2013]"." The NRShape#toString javadoc came with their refactoring of 2014-12-14.
  • The optimizer test's examples keep the meaning. TestDateRangePrefixTree.testShapeRangeOptimizer (:262-280) asserts exact strings of optimized ranges (strings only, not containment), each denoting the input range ([2014-01-01 TO 2014-12-31] prints "2014"); none has a block as its end.
  • An exact string exists. A thousand-year end block means "up to the end of its last year", which the syntax writes as 1999; the printer already gets the start case right ([1000 TO 1998] round-trips).
  • No acceptance found. No upstream test, CHANGES.txt or MIGRATE.md entry accepts a lossy or unparseable range string, and the prior-art searches found no issue or list thread that does.
The devil's-advocate check173 words

The strongest case for "intended": the thousand- and million-year levels are internal tree levels that ISO dates cannot express; "should be parseable" does not say "parses back to an equal shape"; "notwithstanding range optimization" admits a different string; the classes are @lucene.experimental; Lucene does not promise that Query#toString parses back; and TestDateRangePrefixTree.java:60 accepts a date-like string for a bare level-1 cell.

Why it fails: "[1990 TO 1000]" is not parseable at all, so even the weakest reading breaks; the optimization only replaces a range by an equivalent one, so it cannot license a string that means a shorter range; the author stated round-trip intent for ranges; and the line-60 test is a "test no underflow" check: level-1 cell 0 prints the full-precision minimum date -292269054-12-02T16:47:04.192 and only its BC sign is asserted, so it says nothing about range ends. Verifiers, the adjudication and the cross-model reviewer of the intended lens rejected the intended reading. Expect a maintainer to ask about the bare-block case; it is left out of this report on purpose.

Suggested fix

Recommended Print a block end cell as the block's last year (validated)

Print a thousand- or million-year cell that ends a range as the block's last year: a protected toStringAsRangeEnd hook in NumberRangePrefixTree, used for the end in SpanUnitsNRShape.toString, overridden in DateRangePrefixTree (+26/-1). Bare cells and starts print as before. It passes the repro and the whole spatial-extras module.

// NumberRangePrefixTree: new hook; SpanUnitsNRShape.toString uses it for maxLV
protected String toStringAsRangeEnd(UnitNRShape lv) { return toString(lv); }
// DateRangePrefixTree (condensed)
@Override
protected String toStringAsRangeEnd(UnitNRShape lv) {
  if (lv.getLevel() > 0 && lv.getLevel() < YEAR_LEVEL) { // a block prints as its LAST year
    int[] vals = new int[YEAR_LEVEL];
    for (int level = 1; level <= lv.getLevel(); level++) vals[level - 1] = lv.getValAtLevel(level);
    for (int level = lv.getLevel(); level < YEAR_LEVEL; level++)
      vals[level] = getNumSubCells(toShape(vals.clone(), level)) - 1;
    return toString(toCalendar(toShape(vals, YEAR_LEVEL)));
  }
  return toString(lv);
}
All options and trade-offs3 options · 533 words

One fix was written by the finder and validated again in a scratch worktree of 645d4fe14a20; another fix site is described but not written, and two variants are rejected.

Print a block end cell as the block's last year (validated) (recommended)

The change is +26/-1 lines in NumberRangePrefixTree.java and DateRangePrefixTree.java. SpanUnitsNRShape.toString prints its end with a new protected toStringAsRangeEnd(UnitNRShape), which defaults to toString(lv). DateRangePrefixTree overrides it: a million- or thousand-year cell is filled down to the year level with each level's last child (getNumSubCells(...) - 1, which also handles the partial last million-year block) and printed as that year. [1990 TO 1999] then prints as itself, [-9999 TO -1] as "[-9999 TO -0001]", [* TO 1999-12-31] as the equivalent "[* TO 1999]".

// NumberRangePrefixTree: new hook; SpanUnitsNRShape.toString uses it for maxLV
protected String toStringAsRangeEnd(UnitNRShape lv) { return toString(lv); }
// DateRangePrefixTree (condensed)
@Override
protected String toStringAsRangeEnd(UnitNRShape lv) {
  if (lv.getLevel() > 0 && lv.getLevel() < YEAR_LEVEL) { // a block prints as its LAST year
    int[] vals = new int[YEAR_LEVEL];
    for (int level = 1; level <= lv.getLevel(); level++) vals[level - 1] = lv.getValAtLevel(level);
    for (int level = lv.getLevel(); level < YEAR_LEVEL; level++)
      vals[level] = getNumSubCells(toShape(vals.clone(), level)) - 1;
    return toString(toCalendar(toShape(vals, YEAR_LEVEL)));
  }
  return toString(lv);
}

Trade-offs

  • Changes only the printed end of a SpanUnitsNRShape whose end is a million- or thousand-year cell; bare cells and range starts are unchanged, so TestDateRangePrefixTree.java:60 and the optimizer test still hold. Other NumberRangePrefixTree subclasses keep the default.
  • Affects toString only; nothing on the indexing or search path.
  • Adds one protected method to an @lucene.experimental class.

What the checks validated

Gradle in a scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the minimal test fails with assertions on and off. With the fix:

  • the minimal test passes with assertions on and off;
  • TestDateRangePrefixTree, TestDateNRStrategy and TestNumberRangeFacets pass: 3 classes, 20 tests, 0 failures;
  • the whole :lucene:spatial-extras:test passes: 26 classes, 117 tests, 0 failures, 5 skipped;
  • checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain for spatial-extras pass (the finder's diff as is failed only checkGoogleJavaFormat on one 101-column comment line, rewrapped and lightly reworded in this diff; the code is identical).

Earlier: the upstream date-tree suites passed 100 of 100 (5 iterations), a verifier's 7 tests pass 7 of 7 including the 2 BC end, and both mismatch kinds of the exhaustive check drop to 0 with it.

The same rule at another fix site (not written)

A maintainer may prefer a range-aware printer inside DateRangePrefixTree, or a SpanUnitsNRShape.toString that asks the tree for the last year of a cell (suggested by the adjudication and a verifier). That moves the code, not the behaviour. Any variant must keep toString(UnitNRShape) printing a bare cell as a unit, as its javadoc requires ("parse-able by parseUnitShape").

Rejected: print every block as a range, or stop truncating the end

Printing a bare block as "[first TO last]" (the finder's first variant) breaks the upstream assertion at TestDateRangePrefixTree.java:60 and the unit-string contract of toString(UnitNRShape). Not truncating the end changes the stored shape, its equality and its indexed cells, and the normalization is "REQUIRED based on assumptions elsewhere" (NumberRangePrefixTree.java:118-119); not tried.

Downstream note

An application that keeps an exclusion rule as this text and rebuilds it with parseShape would hide fewer documents (a deny rule [* TO 1999-12-31] rebuilt from "[* TO 1000]" stops hiding 1500-06-15); no product is known to do this.

How it was found

A hunt on spatial-extras' range trees printed every legal range over edge-date grids of two trees (both built-in calendars; BC, leap and far-future years, every precision), parsed the text again and compared it with a java.time reference. All 15,085 failures of this kind had a thousand- or million-year end; the finder then reduced them to a JUnit.

The method in full140 words

Bounded-exhaustive translation validation of the stock jar: for every legal [a TO b] range over 839 edge units of the java.time-compatible tree (31 years including BC, leap and >9999 years, at every precision; 356,472 ranges) and 434 edge units of the default tree (16 years from 1583; 96,512 ranges), 452,984 ranges in all, the harness re-parses toString() and compares the re-parsed interval with a proleptic-Gregorian java.time reference. It found 10,430 interval mismatches and 4,655 parse exceptions (default tree: 2,464 and 1,477), all with a thousand- or million-year END cell, with assertions on and off; both kinds disappear with the fix. The finder then wrote a three-line public-API JUnit, and the hunt later cut it to the one-input test shown here.

The harness's other mismatch kinds belong to other leads of the same hunt and are not part of this bug.

Prior reports, history and checks

Prior reports

No prior report found (213 logged queries on 2026-10-26 over GitHub, JIRA, the Lucene and Solr lists and the web; rechecked 2026-10-31). The design issue #6710 states the round-trip intent; nothing reports this bug.

Searches4 searches

Verdict new. Closest items, none this bug: #6710 (LUCENE-5648, the design issue; states the round-trip intent), #7154 (LUCENE-6092, made the normalization unconditional; does not touch toString), #8865 (LUCENE-7814, years >= 292,000,000), #9686 (LUCENE-8640), #8333 (LUCENE-7278), #9831 (LUCENE-8787), SOLR-6103 (invalid month-00 input), SOLR-16306 (faceting on data with 2999-12-31T23:59:59.999Z ends; about faceting) and two "Wrong order" reports caused by inverted user input. No open PR changes the printing (#12295 only replaces casts). Limits: GitHub search returns at most 50 hits per query; the open web is covered with medium confidence; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/solr, apache/lucene-solr, apache/lucenenet, two other search engines, all of GitHub)2026-10-26a scripted prior-report search (two runs plus the finder's) and 23 manual searches: class names, "Wrong order", toRangeShape, 1999, 2999, millennium, decade, round trip, BC; 7 issues read with comments; changed files of all 407 open PRsonly the context items listed above
JIRA (LUCENE, SOLR) and mailing lists2026-10-269 JIRA searches with 23 issues read in full; dev@, java-user@, issues@, commits@ and the Solr liststwo "Wrong order" threads from inverted user input
Web2026-10-2610 queries; one Q&A page read through an archive copyusage questions only
GitHub (recheck)2026-10-315 apache/lucene issue/PR searches (DateRangePrefixTree, NumberRangePrefixTree, SpanUnitsNRShape, parseShape toString, "Wrong order"); the 50 issues and PRs created since 2026-10-26 by titlenothing new

History

In the first version of the code: LUCENE-5648 (bb267a143e0, 2014-06-05), first released in 5.0.0. Executed failing on 5.0.0, in every major line through 10.6.0 and on main; never fixed.

Versions tested6 rows
  • Introduced by bb267a143e009f7b1375f7fa9755b76fcc82ec4f, "LUCENE-5648: DateRangePrefixTree and NumberRangePrefixTreeStrategy" (David Smiley, 2014-06-05; svn era, no PR; the JIRA issue is now #6710); branch_5x got it through the backport 55594bad9b07 (2014-09-18). The end truncation, the range printer and the first-year cell printer were all in that commit.
  • Kept by 29c90c668cb (LUCENE-6092, #7154: normalization made unconditional, the "REQUIRED" comment), 0ddb7567f1c (2014-12-14: types renamed, NRShape#toString javadoc added) and 2cbf261032d (2021 reformat); git blame shows no behaviour change since.
  • Upstream now: main 2f424fd428da and branch_11x 603af8703753 have the two files byte-identical to 645d4fe14a20; branch_10_6 has 10.6.0's copies (mechanical differences only); branch_10x was decommissioned (#16911).
  • Releases between the executed points rest on git blame.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal repro fails, assertions on and off
9.1.0, 9.12.3, 10.0.0, 10.5.2, main a9779ef3c193 affectedthe finder's JUnit fails (9.1.0 also with the minimal repro)
5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4 affectedversion-neutral probe: 4 of 4 cases broken, 0 of 3 controls
bb267a143e0 (introducing), af748a9254e (the parent of 29c90c668cb), 29c90c668cb shadowthe commit's tree package over the 5.0.0 jars: probe 4 of 4 broken
4.10.4 and earlier not affectedno DateRangePrefixTree (the API did not exist)
main with the fix not affectedminimal repro passes; probe 0 of 4 broken

Independent checks

An independent reproducer, a devil's-advocate verifier and a read-only review by OpenAI GPT-5.6 found it real; a second read-only reviewer said "not real" on procedure only (it could not run Java), and its objection narrowed the claim to range ends. The adjudication confirmed it (high confidence, impact minor).

Checks and verdicts294 words

Every executed check ran on stock builds; the cross-model reviewers could only read code, and separate runs re-ran the repro.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (exhaustive print-and-re-parse check, then a JUnit) candidate10,430 silent mismatches and 4,655 exceptions over 452,984 ranges in two trees, all with a block end, none with the fix; a 3-case JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3.
Refute lens (OpenAI GPT-5.6, read-only) not real (procedural, low)Could not run Java; its static evidence supported the root cause, and its one objection (bare-block printing) led to narrowing the claim and the fix to range ends. A separate run re-ran the repro: fails.
Independent reproducer realOwn 7 tests: 5 fail with -ea and -da on main, 10.5.2 and 9.12.3, the 2 controls pass; adds the 2 BC end and a containment check; its probe fails on 8.11.4, 6.0.0 and 5.0.0; 7/7 with the fix.
Intended lens (OpenAI GPT-5.6, read-only) real (medium)Intended argument weak, root cause agreed, inputs in contract; a separate check fails on main -da and 9.12.3.
Intended-behaviour devil's advocate real (high, range ends only)Re-ran the repro on main and 10.5.2; showed that the upstream test at line 60 checks only the BC sign of a full-precision date.
Adjudication confirmed (high), impact minorRe-ran the independent reproduction's tests on main: 5 of 7 fail with -ea and -da, controls pass.
Minimal repro on 645d4fe14a20 still reproducesFails with -ea and -da, two seeds; passes with the fix.
This doc's runs realGradle in a scratch worktree of 645d4fe14a20: stock fails, the fix passes the repro and the module; standalone runner: 10.6.0 and 9.1.0 fail.

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

Finding record

LFV-610, snapshot of 2026-11-06

Registry title
DateRangePrefixTree prints a date range whose end is the last year of a thousand- or million-year block (a year X999, or 2 BC) with that end shown as the block's FIRST year, e.g. [1990 TO 1999] prints as "[1990 TO 1000]", so parseShape rejects the printed string ('Wrong order') or reads it as a much shorter range ([* TO 1999-12-31] prints as "[* TO 1000]")
Area
spatial-extras / prefix trees (DateRangePrefixTree / NumberRangePrefixTree range-shape string form: SpanUnitsNRShape.toString of a range whose end normalizes to a thousand- or million-year cell; also shown in the 'queryShape=' part of the prefix-tree queries' toString)
Priority
Low, rank 775: DateRangePrefixTree prints ranges that end on a block's last year with the wrong end, which breaks the toString round trip while queries stay correct, so it sits with the niche toString round-trip row LFV-356.
Impact class
minor
Impact note
Hits applications that use the opt-in, @lucene.experimental DateRangePrefixTree of the spatial-extras module (in practice also Solr's DateRangeField, which is built on it) and print, log, store or forward a range shape whose end is the last instant of a year X999 or of 2 BC, with ordinary input and no scale ([1990 TO 1999], [* TO 1999-12-31], [2014 TO 2999], [2020 TO 9999]): matching, query equality and the index are correct because they use the shape object, but the shape's toString and the 'queryShape=' part of IntersectsPrefixTreeQuery / ContainsPrefixTreeQuery / WithinPrefixTreeQuery toString show the wrong end, and code that parses the string again gets IllegalArgumentException 'Wrong order' or, more often, a range that silently ends up to 999 years early (about a million years for an end of 2 BC); deterministic, -ea and -da, every release since 5.0.0.
Summary
You parse an ordinary date range such as [1990 TO 1999] with DateRangePrefixTree.parseShape and print it, expecting a string that parseShape reads back as the same range, as NRShape#toString promises ('The result should be parseable by parseShape(String)'). Instead it prints "[1990 TO 1000]", which parseShape rejects with IllegalArgumentException 'Wrong order', and [* TO 1999-12-31] prints as "[* TO 1000]" ([1990 TO 2999] as "[1990 TO 2000]"), which parses without error to a range that ends up to 999 years early. Range normalization turns an end of 1999 into the thousand-year cell 1000-1999 (an end of 2 BC into a million-year cell), and the per-cell printer writes a cell as its FIRST year, which is right for a range start but wrong for a range end; searches are unaffected, only the text is wrong.
Introduced
bb267a143e009f7b1375f7fa9755b76fcc82ec4f (trunk, svn r1600555, 'LUCENE-5648: DateRangePrefixTree and NumberRangePrefixTreeStrategy', author GitHub dsmiley); branch_5x got it through the bulk backport 55594bad9b07 (LUCENE-5944, 2014-09-18). No PR (svn era; JIRA LUCENE-5648, now GitHub issue #6710). The defect is in the first version of the code, not a regression: toRangeShape already truncated trailing max values of the end, the range toString already printed both ends with the per-cell printer, and that printer already wrote a level-1/2 cell as its block's first year [read: git blame on main, git log --follow and -S on both files; exec: the commit's prefix/tree package (12 of 13 files, QuadPrefixTree.java left out, it does not compile against 5.0.0 core and is not loaded) shadowed over the 5.0.0 jars fails the probe the same way]. Later commits kept the behaviour: 29c90c668cb (LUCENE-6092, #7154) restructured the normalization, 0ddb7567f1c (2014-12-14) renamed the types and added the NRShape#toString javadoc, 2cbf261032d (2021) reformatted (2014-06-05 (trunk commit); 2014-09-18 (branch_5x backport))
First affected version
5.0.0 [exec] (first release with the API); 4.10.4 and earlier have no DateRangePrefixTree or NumberRangePrefixTree [exec: the probe does not compile on 4.10.4; read: absent in 4.9.0, 4.9.1, 4.10.0]
Affected versions
Every release from 5.0.0 through 10.6.0 (10.6.0 by reading) and main a9779ef3c193, never fixed. [exec] the follow-up's version-neutral main() probe breaks 4 of 4 defect cases ([1990 TO 1999], [* TO 1999-12-31], [2014 TO 2999], [-9999 TO -1]) and 0 of 3 controls on 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main, and on commits bb267a143e0, af748a9254e and 29c90c668cb (shadowed over the 5.0.0 jars); the finder's JUnit fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails 5 of 7 on main (-ea and -da), 10.5.2 (-ea and -da) and 9.12.3, and its probe fails on 8.11.4, 6.0.0 and 5.0.0. [read] releases between the executed points by git blame (no behaviour change to NumberRangePrefixTree.java:117-166, 399-406 or DateRangePrefixTree.java:349-383); upstream main a95c754ee003 (2026-10-26) has only mechanical commits on both files since then (latest 2026-06-14, typo checks), and branch_10x, branch_10_6 and branch_9_12 last changed NumberRangePrefixTree.java in 2023-12 (#12880, redundant modifiers), so 10.6.0 (released 2026-10-29) and the unreleased 11.0 line are affected (not executed). Not executed: x86 (pure Java string logic). JDK 25.0.3, aarch64
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: exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record verdict new, 213 logged queries: scripted prior-report searches over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, including the finder's earlier run; manual GitHub searches in apache/lucene, apache/solr, apache/lucene-solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub; ASF JIRA searches over LUCENE and SOLR with full reads of 23 JIRA issues and 7 GitHub issues; Solr mailing-list searches; the changed-file lists of all 407 open apache/lucene PRs). Closest items, none the same bug: #6710 (LUCENE-5648, the design issue, whose author states the toString/parseShape round-trip intent), #7154 (LUCENE-6092, made the normalization that creates the end block mandatory, does not touch toString), #8865 (LUCENE-7814), #9686 (LUCENE-8640), #8333 (LUCENE-7278), #9831 (LUCENE-8787), SOLR-6103 (invalid month-00 input), SOLR-16306 (faceting on data with 2999-12-31 ends, about faceting), and two 'Wrong order' reports caused by inverted user input (a users@solr thread of 2022-06 and Stratio/cassandra-lucene-index#36). Not fixed upstream: upstream main a95c754ee003 has only mechanical commits on both files, branch_10x, branch_10_6 and branch_9_12 last changed NumberRangePrefixTree.java in 2023-12 (#12880), and the only open PR that touches the files (#12295, instanceof pattern matching) only replaces casts. Confidence high for GitHub, JIRA and the ASF lists, medium for the open web (GitHub search returns at most 50 hits per query; one Q&A site was read only through an archive copy)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off through gradle in a scratch worktree and through a standalone runner; with the fix it passes, as do the date-tree suites and the whole spatial-extras module. Releases 10.6.0 (assertions on and off) and 9.1.0 fail with the standalone runner. Earlier, the hunt ran its tests and a version-neutral probe on 5.0.0 to 10.5.2, on the introducing commit and on main a9779ef3c193 (spatial-extras identical to 645d4fe14a20). Upstream main 2f424fd428da (2026-10-30) and branch_11x 603af8703753 carry the same two files [read].

Lucene and upstream sources

Finding record
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/NumberRangePrefixTree.java:58, (at a9779ef3c193) :81 (@lucene.experimental), :84 (NRShape#toString 'The result should be parseable by {@link #parseShape(String)}'), :114-115 (toRangeShape throws IllegalArgumentException on wrong order), :117-147 (toRangeShape; :118-119 'this normalization/optimization process is actually REQUIRED'; :124 end truncation 'chops off trailing max-vals'), :160-166 (truncateEndVals), :174-178 (toString(UnitNRShape) javadoc 'parse-able by parseUnitShape'), :190-199 (parseShape javadoc: the range syntax 'is the same thing as the toString() of the range shape, notwithstanding range optimization'), :399-406 (SpanUnitsNRShape.toString prints minLV and maxLV with the same toString(UnitNRShape))
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:39 (at a9779ef3c193) (@lucene.experimental), :345-378 (toCalendar: yearAdj from the block's start values, year precision), :380-383 (toString(UnitNRShape) = toString(toCalendar(lv)))
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/IntersectsPrefixTreeQuery.java:90, (at a9779ef3c193) ContainsPrefixTreeQuery.java:81, WithinPrefixTreeQuery.java:106 ('queryShape=' + queryShape in toString)
  • lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/tree/TestDateRangePrefixTree.java:59-60 (at a9779ef3c193) ('test no underflow', startsWith("-")), :262-280 (testShapeRangeOptimizer: exact toString of optimized ranges, none with a block END)
  • a Lucene main checkout: git log ea57360912ed..a9779ef3c193 (7 commits, none touching lucene/spatial-extras)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/NumberRangePrefixTree.java:84, :114-129, :160-166, :174-178, :199, :205-215, :399-406
  • a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:102, :128-140, :153-154, :315-322, :349-383
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:39 (@lucene.experimental); a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/NumberRangePrefixTree.java:58
  • a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/IntersectsPrefixTreeQuery.java:90; ContainsPrefixTreeQuery.java:81; WithinPrefixTreeQuery.java:106
Minimal repro
  • a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:62-71 (DEFAULT_CAL: UTC, Locale.ROOT)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/NumberRangePrefixTree.java:84, :174-178, :199
  • a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/tree/TestDateRangePrefixTree.java:59-60, :262-280
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/NumberRangePrefixTree.java:118-119, :174-178
History
  • a Lucene clone: git diff 645d4fe14a20 origin/main, origin/branch_11x, releases/lucene/10.6.0 -- the two files; git log -1 origin/branch_10x

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro.

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.