All findings · Lucene finding · LFV-820 · taxonomy-getalldims-comparator-not-nan-safe

Taxonomy getAllDims misorders float dims, and can throw, when one dim's value is NaN

With float taxonomy facets, one dimension whose value is NaN makes getAllDims list the other dimensions out of value order (a=1.0 above c=2.0) and disagree with getTopDims; with 32 or more dimensions it can throw "Comparison method violates its general contract!". Every release since 4.7.0 and main are affected.

Summary

Priority
Low, rank 587A NaN float facet value is a rare boundary input that silently misorders dims, makes getTopDims disagree with getAllDims and makes getAllDims throw at 32 or more dims, while hits and counts stay correct; it has the same NaN-boundary profile as LFV-427 but is milder than that row's empty groups.
Impact
silent wrong results
Since
4.7.0 (2014); the default float-association path since 9.11.0introduced 2013-11-18 · over 12 years
Affected
every release from 4.7.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: an exhaustive differential search plus solver queries

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

getAllDims sorts the dims with a comparator that answers "tie" whenever NaN is involved and then orders by name. That breaks transitivity, so the sort can return finite dims out of order.

Background: float taxonomy facets

  1. Each document carries labels such as a/x; the first component (a) is the dimension, or dim.
  2. TaxonomyFacetFloatAssociations adds up (SUM) or takes the maximum (MAX) of a float per label, from FloatAssociationFacetField weights or a DoubleValuesSource; a dim's value is the aggregate of its labels.
  3. getAllDims(n) returns one result per dim, sorted by value, highest first. getTopDims(k, n) returns the top k dims and is documented to equal the first k entries of getAllDims(n).
Diagram of the getAllDims sort on Lucene main with dims a=1.0, b=NaN, c=2.0: the dims are visited as c, b, a; the comparator answers b before c and a before b by name because every > with NaN is false, so the sort returns [a=1.0, b=NaN, c=2.0] without ever comparing a with c; getTopDims, which uses Float.compare, returns [b=NaN, c=2.0, a=1.0]; with 32 dims and one NaN, getAllDims throws; the fix sorts getAllDims with valueComparator.
The three-dim example: the order getAllDims visits the dims in, the two comparisons the sort makes, the circular order that results, getTopDims disagreeing, the 32-dim exception and the fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index three documents, one FloatAssociationFacetField each: dim a with weight 1.0, dim b with NaN, dim c with 2.0. Aggregate them with TaxonomyFacetFloatAssociations and SUM.
  2. You expect getAllDims(10) to list the dims by value, highest first, so c=2.0 before a=1.0, and getTopDims(1, 10) to return its first entry.
  3. Instead getAllDims(10) returns [a=1.0, b=NaN, c=2.0], and getTopDims(1, 10) returns [b=NaN].
  4. Why: the sort tests x > y both ways and falls back to the name. With NaN both tests are false, so b ties with everything: a before b, b before c by name, yet c before a by value. No order satisfies that.
  5. With 32 or more dims the same sort can throw an IllegalArgumentException. NaN needs no NaN input: a clicks / views weight of 0/0, or weights whose SUM overflows to +Inf and -Inf in one dim, do the same.

Who is affected

Applications that call getAllDims on float taxonomy facets (float associations or a DoubleValuesSource, SUM or MAX) when one dim aggregates to NaN. Three dims suffice for the wrong order; the exception needs 32 or more, which the documented "sparse faceting" use invites. Hits, counts, drill-down and int facets are unaffected. Rare, but deterministic once such a value is indexed.

Mechanism, terms and numbers713 words

TaxonomyFacets#getAllDims sorts the per-dim results with a.value.doubleValue() > b.value.doubleValue(), then the reverse, and otherwise the dim name (TaxonomyFacets.java:580-590). Every > with NaN is false, so a dim whose value is NaN ties with every value, is placed by name only, and the comparator is no longer transitive. With dims a=1, b=NaN, c=2 (float associations, SUM), getAllDims(10) returns [a=1.0, b=NaN, c=2.0] while getTopDims(1, 10) returns [b=NaN]: getTopDims ranks with the NaN-safe valueComparator (Float.compare, FloatTaxonomyFacets.java:48) since #12966, and its javadoc says it should equal the first entries of getAllDims. With 32 or more dims, List.sort can detect the broken contract and getAllDims throws IllegalArgumentException: Comparison method violates its general contract!. NaN also comes from ordinary inputs: a clicks / views weight with 0/0, or finite weights whose float SUM overflows to +Inf and -Inf inside one dim. Hits, counts, drill-down and int facets are not affected. Released since 4.7.0; the default association path reaches it since 9.11.0. The fix sorts with valueComparator, then the dim name.

Mechanism (Lucene main 645d4fe14a20):

  1. Collect. getAllDims walks the taxonomy root's children and siblings and adds one FacetResult per dim (TaxonomyFacets.java:564-578). The taxonomy lists the youngest child first (TaxonomyIndexArrays.java:253-255, :268-269), so dims added as a, b, c are visited as [c=2.0, b=NaN, a=1.0]. A dim's value is a fold over its labels that starts at 0f (FloatTaxonomyFacets.java:129-150), so one NaN label makes the dim NaN.
  2. Sort. TaxonomyFacets.java:580-590:
// Sort by highest value, tie break by dim:
results.sort(
    (a, b) -> {
      if (a.value.doubleValue() > b.value.doubleValue()) {
        return -1;
      } else if (b.value.doubleValue() > a.value.doubleValue()) {
        return 1;
      } else {
        return a.dim.compareTo(b.dim);
      }
    });
  1. Wrong order. For a=1, b=NaN, c=2 the comparator says a<b (name), b<c (name) and c<a (value): a cycle, so it is not a valid Comparator. On the visiting order [c, b, a], the JDK's ArrayList.sort asks only compare(b, c) = -1 and compare(a, b) = -1, takes the run as strictly descending, reverses it and returns [a=1.0, b=NaN, c=2.0]; it never compares a with c. (Shown with a verbatim copy of the comparator that logs its calls; the real getAllDims returns the same list.)
  2. Exception. From 32 elements the JDK's TimSort merges sorted runs and, on some arrangements, meets the contradiction and throws IllegalArgumentException("Comparison method violates its general contract!"), which List.sort documents for a comparator that breaks its contract. Below 32 elements it never merges and never throws.
  3. getTopDims uses another order. Its queue orders dims by valueComparator, then the name (TaxonomyFacets.java:610-614), and admits dims with it (:652-664). For float facets that is Float.compare (FloatTaxonomyFacets.java:48), a total order that ranks NaN above +Infinity, so it returns [b=NaN, c=2.0, a=1.0]. Int facets compare ints and cannot be NaN.
  4. Where NaN comes from. Nothing checks finiteness: FloatAssociationFacetField stores any float (FloatAssociationFacetField.java:33-40), TaxonomyFacetFloatAssociations.java:151 casts any DoubleValuesSource double to float, SUM is + and MAX is Math.max (AssociationAggregationFunction.java:40-65).

Executed triggers (main 645d4fe14a20, assertions on and off; the NaN-free controls pass). Every row also failed on 10.6.0, assertions on and off; the first three also on 9.11.0 and the last three also on 10.5.2 (assertions on):

triggergetAllDims(10) on main
weights a=1.0, b=NaN, c=2.0 (the minimal test)[a=1.0, b=NaN, c=2.0]
c gets two documents of 1.0 (2 hits, value 2.0), a one of 1.0[a=1.0, b=NaN, c=2.0]: c loses on hits and on value
expression weight clicks / views with MAX; b's only document has 0 clicks and 0 views[a=1.0, b=NaN, c=2.0]
finite weights only: in dim b, 3e38 twice (+Inf) on one label and -3e38 twice (-Inf) on another[a=1.0, b=NaN, c=2.0]
32 dims with hits, one of them NaNIllegalArgumentException: Comparison method violates its general contract!
the 6 taxonomy insertion orders of a, b, cvalue order broken in 2 of 6; getTopDims prefix broken in 4 of 6

How often a NaN dim breaks the order depends on the arrangement: a simulation of the verbatim comparator (one NaN, distinct values, random order, 100,000 lists per size) misorders finite dims in 5.45% of 3-dim, 21.32% of 10-dim and 39.37% of 32-dim lists, and throws in 0.28% of 32-dim lists.

Exposure and likelihood193 words
  • Who. Users of TaxonomyFacetFloatAssociations over FloatAssociationFacetField weights or a DoubleValuesSource, with SUM or MAX, who call getAllDims, or compare it with getTopDims. These are opt-in facet types, and the facet classes are @lucene.experimental.
  • Settings. Otherwise defaults: default IndexWriterConfig, codec, taxonomy and FacetsConfig on the association path since 9.11.0; the value-source path with the default dim config was run on 9.11.0, 10.6.0 and main, and with multiValued + requireDimCount dims it is affected since 4.7.0.
  • Scale. Three dims for the wrong order; 32 or more dims with hits for the exception. getAllDims is documented for "sparse" faceting "where many different dimensions were indexed" (Facets.java:53-55).
  • Effect. The "top" dim shown can be a lower-valued one; code that truncates or pages getAllDims gets the wrong dims; getTopDims and getAllDims disagree; with many dims the facet call fails. getTopDims does not use this comparator (it ranks dims with valueComparator), but the child lists it returns have a separate NaN issue (LFV-825); nothing is written to the index.
  • Likelihood. Rare: ordinary weights (scores, prices, ratings) do not aggregate to NaN, but computed weights can (0/0), and so can SUM overflow.

Minimal repro

One JUnit test in lucene/facet, public API: three documents with float weights a=1.0, b=NaN, c=2.0, SUM, then getAllDims(10). It asserts only that c comes before a, nothing about where NaN goes. Deterministic; it fails with assertions on and off.

TestGetAllDimsNaN.java (lucene/facet, one test)67 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.facet.taxonomy;

import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.facet.Facets;
import org.apache.lucene.facet.FacetsCollector;
import org.apache.lucene.facet.FacetsCollectorManager;
import org.apache.lucene.facet.FacetsConfig;
import org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyReader;
import org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyWriter;
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.search.MatchAllDocsQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestGetAllDimsNaN extends LuceneTestCase {

  public void testGetAllDimsOrdersNonNaNDims() throws Exception {
    FacetsConfig config = new FacetsConfig();
    try (Directory dir = new ByteBuffersDirectory();
        Directory taxoDir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
          DirectoryTaxonomyWriter tw = new DirectoryTaxonomyWriter(taxoDir)) {
        String[] dims = {"a", "b", "c"};
        float[] values = {1f, Float.NaN, 2f};
        for (int i = 0; i < dims.length; i++) {
          Document doc = new Document();
          doc.add(new FloatAssociationFacetField(values[i], dims[i], "x"));
          w.addDocument(config.build(tw, doc));
        }
      }
      try (DirectoryReader r = DirectoryReader.open(dir);
          DirectoryTaxonomyReader tr = new DirectoryTaxonomyReader(taxoDir)) {
        FacetsCollector fc =
            new IndexSearcher(r).search(new MatchAllDocsQuery(), new FacetsCollectorManager());
        Facets facets =
            new TaxonomyFacetFloatAssociations(tr, config, fc, AssociationAggregationFunction.SUM);
        List<String> all =
            facets.getAllDims(10).stream().map(fr -> fr.dim + "=" + fr.value).toList();
        // expected: c=2.0 ranked before a=1.0 (getAllDims sorts by value); actual: a=1.0 first
        assertTrue(
            "expected c=2.0 before a=1.0, got " + all,
            all.indexOf("c=2.0") < all.indexOf("a=1.0"));
      }
    }
  }
}
Command
cp $REPRO/TestGetAllDimsNaN.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/taxonomy/
cd $LUCENE && ./gradlew :lucene:facet:test --tests org.apache.lucene.facet.taxonomy.TestGetAllDimsNaN -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and the same with -Ptests.asserts=false
Expected
getAllDims(10) lists c=2.0 before a=1.0: sorted by value, highest first.
Observed
[a=1.0, b=NaN, c=2.0], with assertions on and off: a=1.0 is listed above c=2.0.

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

More files, environment, full output264 words

One test class, one method, public API only: ByteBuffersDirectory, default IndexWriterConfig and FacetsConfig, single thread, nothing randomized by the test body (the seed drives only the test framework's own randomized defaults, such as the codec, locale and time zone; the test failed under three seeds, with assertions on and off). Copy it into lucene/facet/src/test/org/apache/lucene/facet/taxonomy/ of a Lucene checkout and run the command.

Before using it as the regression test of a PR, run ./gradlew :lucene:facet:tidy: it joins the two assertTrue arguments onto one line, the only change, after which the format check passes.

Left out on purpose: the getTopDims comparison, the 32-dim exception and the other NaN sources; they follow from the same comparator and were executed separately (see the mechanism above).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 and 10.5.2 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestGetAllDimsNaN.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/taxonomy/
cd $LUCENE && ./gradlew :lucene:facet:test --tests org.apache.lucene.facet.taxonomy.TestGetAllDimsNaN -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and the same with -Ptests.asserts=false

Observed output

# main 645d4fe14a20, gradle, -Ptests.asserts=true (the same with false)
TestGetAllDimsNaN > testGetAllDimsOrdersNonNaNDims FAILED
    java.lang.AssertionError: expected c=2.0 before a=1.0, got [a=1.0, b=NaN, c=2.0]
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:3C0FEC18EB78D5B1]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.facet.taxonomy.TestGetAllDimsNaN.testGetAllDimsOrdersNonNaNDims(TestGetAllDimsNaN.java:61)
:lucene:facet:test (FAILURE): 1 test, 1  failure

# with the fix (option A), assertions on and off
:lucene:facet:test (SUCCESS): 1 test

# the same three dims, traced (main jars, -ea and -da)
visiting order: [c=2.0, b=NaN, a=1.0]
  compare(b=NaN, c=2.0) = -1
  compare(a=1.0, b=NaN) = -1
getAllDims(10): [a=1.0, b=NaN, c=2.0]
getTopDims(1, 10): [b=NaN]
getTopDims(3, 10): [b=NaN, c=2.0, a=1.0]

Why it looks unintended

  • The code says "Sort by highest value, tie break by dim", yet lists 1.0 above 2.0.
  • The javadoc says getTopDims should equal the first entries of getAllDims; here it returns [b=NaN] while getAllDims starts with a=1.0.
  • #12966 moved getTopDims to a NaN-safe order and left getAllDims behind; Lucene accepts NaN weights.
The full argument202 words
  • The code's intent. TaxonomyFacets.java:580: "Sort by highest value, tie break by dim". No rule for where NaN goes explains 1.0 above 2.0.
  • The javadoc. Facets.java:52-57: getAllDims returns dims "sorted by the number of hits that dimension matched"; :59-62: getTopDims is "sorted by the number of hits/aggregated values ... Results should be the same as calling getAllDims and then only using the first topNDims". The base getTopDims is literally getAllDims(topNChildren).subList(0, ...) (:69-72).
  • The tests. TestTaxonomyFacetAssociations.java:327-329, :359-361 and :380-382 assert that getTopDims(10, 10) equals getAllDims(10) for float associations, with finite values only. No facet test uses NaN, and the test helper FacetTestCase#sortFacetResults (:209-221) copies the same comparator.
  • The JDK contract. The exception is List.sort reporting a broken Comparator, not an input check.
  • History. #12966 (2024; 9.11.0 through #13358) added valueComparator and moved getTopDims to it, but left getAllDims on the 2013 comparator; none of its 55 comments and reviews mentions NaN. The same change is the 9.11.0 bug fix "Aggregation facets no longer assume that aggregation values are positive" (lucene/CHANGES.txt:2922).
  • NaN is accepted. The facet module rejects NaN only for range bounds (range/DoubleRange.java:61-69, rangeonrange/DoubleRange.java:43-51, facetset/DimRange.java:62-94); weights are not checked.
The devil's-advocate check188 words

The case for "intended": NaN is garbage in, as with NaN scores in core search (LUCENE-2271); the facet APIs are @lucene.experimental; the getAllDims javadoc speaks of "number of hits" and says nothing about NaN; AssociationAggregationFunction.java:25-28 assumes "valid values are always positive"; and in the minimal test every dim has one hit, so a literal "by hits, then by name" reading would accept [a, b, c].

Why it fails: (1) when c has 2 hits and value 2.0 and a has 1 hit and 1.0, the result is still [a=1.0, b=NaN, c=2.0], wrong under both readings (executed on main, 10.6.0 and 9.11.0); (2) getTopDims and getAllDims disagree wherever NaN goes, against the javadoc's stated expectation ("Results should be the same") and the upstream equality tests (finite inputs); (3) the exception is a Comparator contract violation, which experimental status does not excuse; (4) 0/0 and SUM overflow produce NaN from ordinary inputs, so rejecting explicit NaN does not prevent it; (5) #12966 already chose a NaN-safe order for getTopDims and dropped the positive-values assumption as a bug fix. The cross-model refute review rated the intended argument "weak".

Suggested fix

Recommended Sort getAllDims with valueComparator, as getTopDims does

Sort getAllDims with valueComparator.compare(b.value, a.value), then the dim name, the order getTopDims already uses (+4/-6 lines in TaxonomyFacets.java). A NaN dim is then listed first, as in getTopDims. The repro passes, and so do all 307 lucene/facet tests and the format check.

// Sort by highest value, tie break by dim:
results.sort(
    (a, b) -> {
      int cmp = valueComparator.compare(b.value, a.value);
      if (cmp != 0) {
        return cmp;
      }
      return a.dim.compareTo(b.dim);
    });
All options and trade-offs3 options · 524 words

Option A was built and run in a scratch worktree of 645d4fe14a20; B was run on 4.7.0 only; C was not built. Rejecting NaN weights at index time is not a fix on its own: NaN also comes from SUM overflow and from a DoubleValuesSource at query time.

Sort getAllDims with valueComparator, as getTopDims does (validated by the checks) (recommended)

Replace the two > tests in the getAllDims comparator (TaxonomyFacets.java:580-590) by valueComparator, then the dim name. Full diff:

--- a/lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java
+++ b/lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java
@@ -580,13 +580,11 @@
     // Sort by highest value, tie break by dim:
     results.sort(
         (a, b) -> {
-          if (a.value.doubleValue() > b.value.doubleValue()) {
-            return -1;
-          } else if (b.value.doubleValue() > a.value.doubleValue()) {
-            return 1;
-          } else {
-            return a.dim.compareTo(b.dim);
+          int cmp = valueComparator.compare(b.value, a.value);
+          if (cmp != 0) {
+            return cmp;
           }
+          return a.dim.compareTo(b.dim);
         });
     return results;
   }
// Sort by highest value, tie break by dim:
results.sort(
    (a, b) -> {
      int cmp = valueComparator.compare(b.value, a.value);
      if (cmp != 0) {
        return cmp;
      }
      return a.dim.compareTo(b.dim);
    });

Trade-offs

  • One order for both methods; a NaN dim is listed first, where getTopDims already puts it.
  • Int facets: Comparator.comparingInt gives today's order. Float facets: Float.compare and > differ only on NaN and on a -0.0/+0.0 pair; the built-in SUM and MAX folds start at +0.0 and cannot produce -0.0 (solver queries; reasoned, not executed).
  • A custom AssociationAggregationFunction (public abstract class) that returns -0.0f would now be ordered below +0.0 instead of by name (read, not run).
  • Scope: this fixes the dim order of getAllDims only (and with it the disagreement with getTopDims and the exception). The child labels inside each dim come from getTopChildren, whose queue can still pick the wrong children when a label is NaN: that is LFV-825, with its own fix.
  • Optional: change the test helper FacetTestCase#sortFacetResults the same way (identical for finite values).

What the checks validated

Scratch worktree of 645d4fe14a20, gradle --no-daemon, OpenJDK 25.0.3, seed DEADBEEF:

  • stock + test: fails with assertions on and off; git apply of the fix is clean;
  • fix + test: passes with assertions on and off;
  • fix + test, --tests 'org.apache.lucene.facet.taxonomy.*': 139 tests, 6 skipped, 0 failures;
  • fix + test, all of :lucene:facet:test: 307 tests, 8 skipped, 0 failures (40 classes, including TestTaxonomyFacetAssociations, TestTaxonomyFacetValueSource, TestTaxonomyFacetCounts and TestTaxonomyFacetCounts2);
  • fix + tidied test: :lucene:facet:checkGoogleJavaFormat passes;
  • earlier, shadowed onto the main jars: every verifier's and the tie-break's tests pass. Not run: the full Lucene suite, nightly tests, benchmarks.

A self-contained comparator for backports (validated by the checks)

Double.compare(b.value.doubleValue(), a.value.doubleValue()), then the dim name: the same order as A, for branches without valueComparator (before 9.11.0).

Trade-offs

On main, A keeps one source of truth with getTopDims.

What the checks validated

4.7.0 release jars with only the comparator replaced: the history program passes.

Rank NaN last in both methods (not built)

Make the float valueComparator (FloatTaxonomyFacets.java:48) NaN-last and use it in getAllDims as in A.

Trade-offs

Arguably friendlier output, but it changes what getTopDims returns today and its admission test (TaxonomyFacets.java:652-664).

Downstream note

A product that lets untrusted documents set float association weights can see one facet call return dims out of order or fail with an exception; hits, counts and the index are unaffected.

How it was found

A bounded, exhaustive differential search compared Lucene's facet ordering with an exact reference ordering on small inputs that include NaN and infinities: every failure had a NaN dim, none without one. Solver queries showed the comparator is not transitive once NaN is allowed, and a simulation of the comparator found the 32-dim exception.

The method in full161 words

The hunt targeted NaN and other special values in the facet top-k paths. A sweep over the stock jar (1-4 labels and 4 dims, inputs {1, 2, 3, NaN, +Inf, -Inf, -0.0}, association and value-source facets, SUM and MAX, dense and sparse, 1 or 2 segments, assertions on and off) found 928 getAllDims-order failures and 11,022 getTopDims-vs-getAllDims failures, all with a NaN dim and none without one. SMT queries (cvc5 and z3) showed that the getAllDims order is not transitive with NaN and transitive without it, and that it differs from the getTopDims order only on NaN or a signed-zero pair, which the SUM and MAX folds cannot produce. A pure-Java simulation of the verbatim comparator found that 12,843 of 200,000 random lists throw, the smallest with 32 dims, and a seeded search then reproduced the exception on the stock jar. An independent scan of the compiled classes for comparators without a NaN branch flagged the same site.

Prior reports, history and checks

Prior reports

No prior report found: three searches (2026-10-28 and twice on 2026-10-30, over 600 logged queries) of Lucene's GitHub issues and PRs, the JIRA archive, the mailing lists, downstream projects and the web.

Searches3 searches

The documented public searches found no issue, PR, review comment, JIRA entry, list thread, downstream issue or web page that reports the NaN-unsafe getAllDims comparator or its symptoms. Nearest items, none of them this bug: #6403 (LUCENE-5339, where the comparator came from; no NaN in its comments), #12966 (added valueComparator for getTopDims only), #747 / #11361 (added getTopDims and the "same as getAllDims" javadoc), #806 / #11524 (a getTopDims performance change), #13287 and #12862 (the only open PRs that edit TaxonomyFacets.java; neither touches the getAllDims sort), #6596 (LUCENE-5533, int association SUM overflow, nothing on NaN or ordering), #3347 (LUCENE-2271, NaN scores in core search).

Limits: keyword search; private reports to security@lucene.apache.org are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene issues, PRs and commits; patches of the 20 open facet PRs2026-10-30two search-tool runs (56 queries), 28 manual queries, commit and file-history checks, 10.6.0 tag and upstream branchesno report; #6403, #12966, #747, #806, #13287, #12862 related only
dev@, java-user@, issues@, commits@; JIRA archive; Elasticsearch, OpenSearch, Solr, Lucene.NET; web2026-10-30list queries and the last two days of mail, 10 JQL queries, downstream queries, 10 web queriesno report
the same sources2026-10-30 and 2026-10-28145 and 282 logged queries; threads of #6403, #12966, #747, #806, #13287 and #13795 read in fullno report

History

Introduced with the Facets API on the LUCENE-5339 branch in 2013 and first released in 4.7.0 (2014); the default float-association path reaches it since 9.11.0 (#12966). Every release from 4.7.0 to 10.6.0 and main are affected; not fixed upstream.

Versions tested7 rows

5458dbf2484 (2013-11-14, "LUCENE-5339: current patch", Michael McCandless) wrote the first dim sort with > in both directions, in the value-source class; 51265435699d (2013-11-18) moved it, on doubleValue(), into the new base class TaxonomyFacets#getAllDims, today's code site. Both reached a release through the branch_4x commit 7477fde1cb0 (2014-01-04), in 4.7.0. Later edits never handled NaN: on the branch, f8ae207e806 (2013-11-24) replaced the dim-name tie-break that both commits already had with return 0 and 034595f5e9f (2013-12-02) restored it, before any release; after that came a named constant, reformatting, List.sort in #915 and an inlined lambda in #13795. #12966 (9ba4af7b883, 2024; 9.x backport #13358, 9.11.0) stopped dropping labels whose value is not > 0, so a NaN dim reaches getAllDims on the default association path, and moved getTopDims to the NaN-safe valueComparator but not getAllDims. getTopDims exists since 9.2.0, so the disagreement starts there. All runs used JDK 25; the exception needs TimSort, the JDK's default object sort since Java 7. Upstream main, branch_11x, branch_10_6 and the 10.6.0 tag still have the comparator (2026-10-30).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails through gradle, assertions on and off
10.6.0, 10.5.2 (release jars) affectedthe minimal test fails, assertions on and off
9.11.0 affectedthe minimal test fails; the value-source path fails too
4.7.0, 4.7.2, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.10.0, 9.12.3, 10.0.0 affectedthe history program fails on the value-source path (multiValued + requireDimCount dims)
9.10.0 (association path) NaN label droppedthe minimal test passes: before #12966 labels whose value is not > 0 were dropped
4.6.1 no Facets APIthe repro does not compile: the API does not exist
with the fix (main; 4.7.0 with option B) not affectedthe minimal test, all lucene/facet tests and the format check pass; 4.7.0 passes

Independent checks

Every verifier and the adjudication rated it real; a reproducer and a tie-break re-ran it on main, 10.6.0 and 10.5.2. The reviewers from another model family could not execute code: their one "not real" was procedural, and they agreed on the root cause.

Checks and verdicts342 words

The reviewers from another model family (OpenAI GPT-5.6) ran reading only (an operator re-ran the repro for the refute review afterwards; it fails on main); the finder, verifiers and judge ran on main a9779ef3c193, whose lucene/facet equals 645d4fe14a20's; the executed evidence comes from the finder, independent verifiers, the adjudication, the reproducer, the tie-break and this doc's own gradle runs. Not done: a cross-model review that executes code.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateSweep, solver queries and simulation; its JUnit fails on main a9779ef3c193, 10.5.2, 10.0.0 and 9.12.3 and passes with the fix.
cross-model review, refute lens not real, low, proceduralCould not run any command; agreed on the root cause and that the inputs are in contract; intended argument "weak". An operator re-ran the repro for this record afterwards: it fails on main 645d4fe14a20.
Independent reproducer realOwn 5-test JUnit: 4 of 5 fail on main a9779ef3c193, 10.5.2, 9.12.3 and 9.11.0; the control passes; 5 of 5 with the fix.
Cross-model review, intended lens real, lowIntended argument weak; the operator's finite-input JUnit fails 2 of 2 on main a9779ef3c193 and passes with the fix.
Devil's advocate real, medium-highBuilt the "NaN is garbage in" case and rejected it; own JUnit fails 4 of 5 on main a9779ef3c193, 9.11.0 and 10.5.2.
adjudication confirmed, highRe-ran the independent reproduction's test on main a9779ef3c193 with a new seed: 4 of 5 fail with assertions on and off; not a duplicate.
current-main reproducer realThe minimal test fails on main and 10.5.2, assertions on and off; passes with the fix.
Second adjudication real, highFour separately written test classes fail on main 645d4fe14a20, assertions on and off. On releases the coverage differs by class: the minimal test and a new 4-test class also fail on 10.6.0 and 9.11.0, an earlier 6-test class on 10.6.0 and 10.5.2, and the finder's class ran on main only. Their controls pass; all pass with the fix.

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

Finding record

LFV-820, snapshot of 2026-11-06

Registry title
Taxonomy Facets#getAllDims sorts float-valued dims with a NaN-unsafe < / > comparator, so one dim whose value is NaN puts the other dims out of value order, breaks the documented rule that getTopDims equals the first entries of getAllDims, and with 32 or more dims can make getAllDims throw 'Comparison method violates its general contract!' (released since 4.7.0)
Area
facet / taxonomy facets (TaxonomyFacets#getAllDims ordering of float-valued dims: float association and value-source facets)
Priority
Low, rank 587: A NaN float facet value is a rare boundary input that silently misorders dims, makes getTopDims disagree with getAllDims and makes getAllDims throw at 32 or more dims, while hits and counts stay correct; it has the same NaN-boundary profile as LFV-427 but is milder than that row's empty groups.
Impact class
silent wrong results
Impact note
Applications that call Facets#getAllDims on float-valued taxonomy facets (TaxonomyFacetFloatAssociations, fed by FloatAssociationFacetField weights or a DoubleValuesSource, SUM or MAX) silently get the finite dims out of value order (a=1 listed before c=2) and getTopDims(k, n) results that differ from getAllDims(n).subList(0, k), and with 32 or more dims with hits getAllDims can throw IllegalArgumentException, as soon as one dim's aggregate is NaN, which finite inputs can also produce (a float SUM that overflows to +Inf and -Inf inside one dim, or finite doubles beyond the float range on the DoubleValuesSource path); default IndexWriterConfig, codec, taxonomy and FacetsConfig on the association path since 9.11.0, value-source dims set multiValued + requireDimCount since 4.7.0, -ea and -da, no scale needed; getTopDims never throws, hits and drill-down are correct, and nothing is written to the index.
Summary
You aggregate float values per taxonomy dim (FloatAssociationFacetField weights or a DoubleValuesSource) and call getAllDims, documented to sort dims by value, and getTopDims, documented to return the same as the first entries of getAllDims. If one dim's value is NaN (dims a=1, b=NaN, c=2), getAllDims can return [a=1.0, b=NaN, c=2.0], with a ahead of c, while getTopDims(1, 10) returns [b=NaN]; with 32 or more dims, getAllDims can instead throw IllegalArgumentException 'Comparison method violates its general contract!' from List.sort. The getAllDims comparator compares doubleValue() with > in both directions and falls back to the dim name, so NaN ties with every value and the order is not transitive, while getTopDims has used the NaN-safe Float.compare (valueComparator) since #12966 (9.11.0).
Introduced
51265435699d (LUCENE-5339 development branch, svn r1543202, author Michael McCandless, 'LUCENE-5339: add base class for taxo facet impls; catch wrong index field name for a given dim'): it added TaxonomyFacets.getAllDims with the value-descending < / > comparator on doubleValue(); the per-class getAllDims comparators it replaced compared value.intValue(), a total order. It reached releases through the trunk merge 9d0b60388da and the branch_4x backport 7477fde1cb0 ('LUCENE-5371, LUCENE-5339: ... simplify facet APIs'); JIRA LUCENE-5339 (GitHub mirror #6403), no PR (svn era). Later edits kept the logic: 034595f5e9f (2013-12-02, final tie-break by dim name), cfe57ec320b (2013-12-31, BY_VALUE_THEN_DIM), 8db1e41fc07 (LUCENE-10585, #915, 2022, List.sort) and 644feeb02a9 (#13795, 2024, inlined as a lambda). The default association path reaches the comparator since 9ba4af7b883 (#12966, 2024-04-05; 9.x backport 42884fab46d / #13358, first released in 9.11.0), which stopped dropping labels whose value is not > 0 and moved getTopDims and the queues, but not getAllDims, to valueComparator. [read: git log -S, git log -L and git blame in the history clone, git merge-base --is-ancestor on the 4.6.1 and 4.7.0 tags; exec: 4.6.1 has no Facets API (compile error), 4.7.0 fails, and 4.7.0 with only this comparator replaced passes] (2013-11-18 (author date of 51265435699d); 2014-01-04 (merged to trunk and branch_4x); the default association path since 2024-04-05 (#12966))
First affected version
4.7.0 [exec] (released 2014-02-25, the first release with the Facets/getAllDims API; there on the value-source path with multiValued + requireDimCount dims, whose value is the dim ordinal's own aggregate); the default single-valued FloatAssociationFacetField path from 9.11.0 [exec] (9.10.0 drops NaN labels on that path [exec]); 4.6.1 has no such API [exec: compile error]
Affected versions
Every release from 4.7.0 to 10.6.0 and main a9779ef3c193; not fixed upstream. [exec] The follow-up's portable main() repro (a value-source path and an association path, with controls that replace NaN by 1.5 and pass everywhere) fails on 18 sampled releases (4.7.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.10.0, 9.11.0, 9.12.3, 10.0.0, 10.5.2) and main: the value-source path misorders and throws on all of them, the association path from 9.11.0 on, and the getTopDims prefix check fails from 9.2.0, the first release with getTopDims. The finder's JUnit fails 3 of 3 on main (-ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.11.0; the independent reproduction's JUnit fails 4 of 5 on main (-ea and -da), 10.5.2, 9.12.3 and 9.11.0, and the independent reproduction's value-source JUnit fails on main and 9.10.0; the intended-behaviour check's JUnit fails 4 of 5 on main (-ea and -da), 10.5.2 and 9.11.0. Releases between the sampled ones were not run; git log -L shows the comparator logic unchanged across them. [read] upstream main 20ced71c1e6e and branch_10_6 4ae946e5a9b9 have the same comparator, so the released 10.6.0 has it; its release jars fail the tie-break tests with -ea and -da [exec, 2026-10-30]. All runs on JDK 25 (Linux aarch64); the exception needs a sort that checks the Comparator contract (TimSort, the default since Java 7), and symptoms on the Java 6 runtimes of the 4.x era were not executed.
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 adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check; also reached by Z3/cvc5
Upstream and prior reports
none found (2026-10-28, the prior-report record, verdict new: 282 logged queries: a scripted prior-report search in four runs over GitHub issues and PRs, commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 25 manual gh searches; 7 web searches; the threads of #6403 (LUCENE-5339, 90 JIRA comments), #12966 (55 review items), #747, #806, #13287, #13795 and others read in full; all 411 open apache/lucene PRs with their changed files; git log -S over the full history; plus the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches). Re-checked 2026-10-28: no PR created since 2026-10-26 (#16907-#16926) and no open PR updated since then changes lucene/facet; the newest commit touching TaxonomyFacets.java is a7704f5242d (#14761, author date 2025-06-18). Nearest items, none of them this bug: #6403 (where the comparator came from; nobody mentions NaN), #12966 (added valueComparator, used it for getTopDims only; no NaN discussion), #747 / #11361 and #806 / #11524 (getTopDims and its float optimisation, with the 'same as getAllDims' javadoc), #13287 (open, stale; changes only the getTopDims admission test), #3347 (LUCENE-2271, NaN scores in the core hit queue, a different component). Not fixed upstream: main 20ced71c1e6e and branch_10_6 4ae946e5a9b9 have the same comparator.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test through gradle in a scratch worktree, assertions on and off, stock and with the fix; with the fix also the 139 taxonomy tests, all 307 lucene/facet tests and the format check. Release jars: the minimal test fails on 10.6.0, 10.5.2 and 9.11.0 and passes on 9.10.0 (the association path drops the NaN label there); the history runs cover 4.6.1 (no API), 4.7.0 and 18 sampled releases. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:580-590 (at a9779ef3c193) (getAllDims: 'Sort by highest value, tie break by dim'; results.sort with a.value.doubleValue() > b.value.doubleValue(), then the reverse, else a.dim.compareTo(b.dim)), :610-614 (getTopDims queue ordered by valueComparator, then dim name)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:48 (at a9779ef3c193) (valueComparator = Float.compare on floatValue())
  • lucene/facet/src/java/org/apache/lucene/facet/Facets.java:52-56 (at a9779ef3c193) (getAllDims javadoc: 'sorted by the number of hits that dimension matched'; used for sparse faceting with many dims), :58-63 (getTopDims javadoc: 'Results should be the same as calling getAllDims and then only using the first topNDims')
  • lucene/facet/src/test/org/apache/lucene/facet/FacetTestCase.java:209-221 (at ea57360912ed) (sortFacetResults uses the same < / > comparator) and lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestTaxonomyFacetAssociations.java:238-240 (asserts getTopDims(10, 10) equals getAllDims(10)); lucene/facet is identical at ea57360912ed and a9779ef3c193 (git diff --stat empty)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:564-578, :580-590, :610-614, :652-664 at 645d4fe14a20
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:48, :129-150 at 645d4fe14a20
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/TaxonomyIndexArrays.java:253-255, :268-269 at 645d4fe14a20
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatAssociationFacetField.java:33-40; TaxonomyFacetFloatAssociations.java:151, :191; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/AssociationAggregationFunction.java:40-65 at 645d4fe14a20
  • JDK 25 sources: java/util/List.java sort (@throws IllegalArgumentException), java/util/TimSort.java (MIN_MERGE = 32)
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/Facets.java:25, :52-57 at 645d4fe14a20
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:580 at 645d4fe14a20
  • lucene/facet/src/java/org/apache/lucene/facet/Facets.java:52-57, :59-62, :69-72 at 645d4fe14a20
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestTaxonomyFacetAssociations.java:327-329, :359-361, :380-382, :691-702 at 645d4fe14a20
  • lucene/facet/src/test/org/apache/lucene/facet/FacetTestCase.java:209-221 at 645d4fe14a20
  • lucene/CHANGES.txt:2902, :2922 at 645d4fe14a20 (9.11.0 Bug Fixes, GITHUB#12966)
  • lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:61-69; rangeonrange/DoubleRange.java:43-51; facetset/DimRange.java:62-63, :69-70, :86-87, :93-94 at 645d4fe14a20
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/AssociationAggregationFunction.java:25-28 at 645d4fe14a20
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:48; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/IntTaxonomyFacets.java:49; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:99, :652-664 at 645d4fe14a20
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/AssociationAggregationFunction.java:23, :31 at 645d4fe14a20

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. Every value on screen comes from an executed probe and the minimal test on main 645d4fe14a20; the code panels show verbatim source lines.

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.