All findings · Lucene finding · LFV-825 · topordandfloatqueue-nan-breaks-top-children

Float taxonomy facets: one NaN-valued label makes getTopChildren return a lower-valued label in place of a higher one

With float taxonomy facets, one NaN-valued child label can make getTopChildren return a lower label in place of a higher one: for a=5, b=NaN, c=10, getTopChildren(1) returns [a (5.0)]. The NaN can come from ordinary data (a 0/0 ratio); it is a 9.11.0 regression, still in 10.6.0 and on main.

Summary

Priority
Low, rank 585A NaN aggregate is a rare boundary input, but getTopChildren silently swaps a lower finite label in for a higher one in a released 9.11 regression.
Impact
silent wrong results
Since
9.11.0 and 10.0.0 (#12966, 2024)introduced 2024-04-05 · about 2 years
Affected
9.11.0 to 10.6.0 and main; 9.10.0 and older not affected
Status
documented
Found by
AI tools: a randomized differential probe, then SMT and an exhaustive sweep

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The queue that picks the top labels compares float values with < and >, which are both false for NaN; a NaN label then ties with everything and breaks the queue's order.

Background: float facets and the top-N queue

  1. Taxonomy facets give every label an ordinal, a small integer assigned in the order labels are first indexed. In the repro: root 0, dim 1, then a = 2, b = 3, c = 4.
  2. Float facets (TaxonomyFacetFloatAssociations) add up a float per label: an indexed weight (FloatAssociationFacetField) or a per-document value from a DoubleValuesSource, with SUM or MAX.
  3. getTopChildren(k) keeps the k best labels in a small priority queue: each new label replaces the current worst one if it compares as better. Better means a larger value; on equal values the smaller ordinal wins.
Summary of the bug: three documents with float facet weights a=5, b=NaN and c=10 under one dim. getTopChildren(1) visits c, b, a. c enters the one-slot queue; against NaN both value tests (< and >) are false, so the ordinal decides and b replaces c, then a replaces b. The result is [a (5.0)] instead of c (10.0), while getTopChildren(2) returns [b (NaN), c (10.0)]. The fix compares with Float.compare and keeps equal values tied.
Three labels a=5, b=NaN, c=10: the one-slot queue of getTopChildren(1) keeps c, swaps it for b because NaN ties and the ordinal decides, then swaps b for a, and returns [a (5.0)]. 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 with a float weight on one facet dim, a=5, b=NaN, c=10 (FloatAssociationFacetField), and sum them with TaxonomyFacetFloatAssociations. A NaN also appears with no NaN in the data, e.g. a clicks / impressions weight for a document with 0 impressions (0/0).
  2. Ask for the best label: getTopChildren(1, "dim"). You expect c (10.0), since 10 > 5, or b (NaN) if NaN ranks first. Never a.
  3. You get [a (5.0)], with assertions on and off. getTopChildren(2) returns [b (NaN), c (10.0)], so the top-1 is not even in the top-2. getAllChildren still shows all three values correctly.
  4. Why: the top-N queue compares values with < and >. Every comparison with NaN is false, so NaN ties with every label and the ordinal decides: b beats c, a beats b, yet c beats a. Visiting c, b, a, the queue keeps c, swaps it for b, then for a.
  5. Until 9.10.0 only labels with a value > 0 entered the queue, so NaN never did. 9.11.0 (#12966) admits every label with a count, to support zero and negative values, and NaN comes along.

Who is affected

Applications that rank the labels of float-valued taxonomy facets (TaxonomyFacetFloatAssociations, SUM or MAX) with getTopChildren, getAllDims or getTopDims, once a label's value is NaN: a NaN weight, a 0/0 ratio, or +Inf and -Inf in one label. Default settings otherwise; released 9.11.0 to 10.6.0. On large taxonomies the wrong answer can change from call to call.

Mechanism, terms and numbers718 words

Float taxonomy facets (TaxonomyFacetFloatAssociations, fed by FloatAssociationFacetField weights or a DoubleValuesSource) pick the top child labels with TopOrdAndFloatQueue, whose comparator tests value < other, then value > other, then the ordinal (TopOrdAndFloatQueue.java:36-45). Both tests are false for NaN, so a NaN-valued label ties with every value, the order is no longer transitive, and the heap keeps the wrong labels: with a=5, b=NaN, c=10, getTopChildren(1) returns [a (5.0)] and getTopChildren(2) returns [b (NaN), c (10.0)]. The NaN can come from data with no NaN in it (a 0/0 ratio expression, sqrt of a negative, +Inf and -Inf in one label). It is a 9.11.0 regression from #12966, which let every label with a count into the queue (before, only value > 0), and it is still on main and in 10.6.0. A Float.compare-based comparator that keeps equal values tied fixes it without changing the order of any non-NaN values.

The bug

  1. Values reach the queue unchecked: FloatAssociationFacetField encodes any float (FloatAssociationFacetField.java:33-40), and TaxonomyFacetFloatAssociations casts a DoubleValues value to float (:151) or reads the stored one (:191) and folds it with SUM (+) or MAX (Math.max), both of which propagate NaN (AssociationAggregationFunction.java:40-66).
  2. TaxonomyFacets.getTopChildrenForPath (:467-503) puts every child with count > 0 (sparse :482, dense :495) through insertIntoQueue (:421-431) into PriorityQueue.insertWithOverflow, which replaces the current worst entry when lessThan(top, element) (PriorityQueue.java:231-243). getTopChildren, getAllDims and getTopDims all use it.
  3. TopOrdAndFloatQueue.OrdAndFloat#lessThan (TopOrdAndFloatQueue.java:36-45): if (value < other) return true; if (value > other) return false; return ord > other.ord;. For NaN both tests are false, so only the ordinal decides.
  4. Hand trace for top-1. The taxonomy has 5 ordinals, so counting is dense and the children are visited newest first: c, b, a. c enters the empty queue. lessThan(c, b): 4 > 3, so b replaces c. lessThan(b, a): 3 > 2, so a replaces b. Result [a (5.0)]. For top-2, c and b fill the queue and a loses to c by value: [b (NaN), c (10.0)].
  5. Large taxonomies can vary from call to call. Counting uses a hash table only when a FacetsCollector is given, the taxonomy has 1024 or more ordinals and the hits number fewer than maxDoc / 10 (integer division; TaxonomyFacets.java:103-123, :132), and the child walk iterates it (:479). Every iterator starts at a newly seeded slot and step (IntIntHashMap.java:106, :463, :480-482), so each call can visit a, b, c in another order; for top-1 with b=NaN, 2 of the 6 orders end at a.
  6. The same change that let NaN in (#12966) ranks the dims of getTopDims with the NaN-safe Float.compare (FloatTaxonomyFacets.java:48), but kept < / > for the child labels. getAllDims sorts dims with its own NaN-unsafe comparator (TaxonomyFacets.java:580-590, the sibling LFV-820).

Other executed shapes

  • Weights -3, NaN, 7: top-1 [low (-3.0)].
  • Plain FacetField labels with the weight DivFloatFunction(LongFieldSource("clicks"), LongFieldSource("impressions")), clicks / impressions 5/100, 0/0, 50/100: top-1 [low (0.05)] instead of high (0.5); with 0/10 instead of 0/0 the result is right.
  • Finite double weights through DoubleValuesSource.fromDoubleField: a 5.0, b 1e39 and -1e39, c 10.0. The float casts give +Inf and -Inf, b sums to NaN, and top-1 is [a (5.0)]; with 1e30 and -1e30 (sum 0) the result is right.
  • Sparse counting (1100 filler labels, a query matching 3 of 1103 documents): 14 to 19 of 60 identical getTopChildren(1) calls on one Facets object return [a (5.0)] in the tie-break runs (12 to 15 in the fact check's re-runs), the rest c or b; with b=7, 0 of 60.
  • MAX with 5, NaN, 10: [a (5.0)]. MAX over 6 labels, NaN from a double doc value (the review's own JUnit, on main a9779ef3c193, 10.5.2 and 9.12.3; re-run on 645d4fe14a20 by the fact check): top-2 [L2 (20.0), L0 (2.0)] while L5 (10.0) and L4 (9.0) outrank L0.
  • getTopDims(1, 1) on the a=5, b=NaN, c=10 index: the dim's child list is [a (5.0)].
  • Not every visiting order misorders: 8,524 of 97,184 sweep checks with a NaN input fail, 0 of 119,424 without (+-Inf and -0.0 alone are safe).
Exposure and likelihood330 words
  • Who is hit. Users of TaxonomyFacetFloatAssociations (an opt-in, @lucene.experimental API) fed by FloatAssociationFacetField weights or a DoubleValuesSource (an expression, a function query, a double doc-values field), with SUM or MAX, through getTopChildren, getAllDims or getTopDims (all three executed). Default IndexWriterConfig, codec, taxonomy writer and FacetsConfig. getTopDims ranks the dims NaN-safely, but its child lists come from this queue; getAllDims gets its child lists from getTopChildren and also sorts the dims with its own NaN-unsafe comparator (the sibling LFV-820).
  • When. Once one child label aggregates to NaN. That takes a NaN weight (accepted without a check), a NaN double doc value, a weight that computes 0/0 (a ratio for a document with no denominator), sqrt or ln of a negative value, or finite double weights of opposite sign beyond the float range in one label. Then only some visiting orders misorder.
    • Dense counting (fewer than 1024 taxonomy ordinals, no FacetsCollector, or at least maxDoc / 10 hits): the order comes from the ordinals, and in the tested dense cases the same query on the same index gave the same wrong top-k on every run.
    • Sparse counting (a FacetsCollector, 1024 or more ordinals and fewer than maxDoc / 10 hits, integer division): the visiting order can change from call to call, so identical calls can disagree (12 to 19 of 60 wrong in the runs).
    • Other queries over the same labels may or may not be affected, depending on which labels they match and on k.
  • What goes wrong. Silently: a lower finite label is shown in place of a higher one (5 for 10, -3 for 7), top-k lists for different k contradict each other, and on the sparse path repeated calls can disagree. No exception, with assertions on or off.
  • Not affected. getAllChildren, the aggregated values, which documents match, drill-down, and the index.
  • How likely. Rare: typical scores, prices or ratings do not normally give NaN; ratio or square-root weights over sparse data can.

Minimal repro

One JUnit class for lucene/facet/src/test/org/apache/lucene/facet/taxonomy/, one method, public API only: 3 documents, default IndexWriterConfig and FacetsConfig, MatchAllDocsQuery. It accepts any rank for NaN and fails only when the lower label a wins. It fails with assertions on and off and is also the suggested regression test.

TestFloatFacetTopChildrenNaN.java (JUnit for Lucene's build)66 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.Arrays;
import org.apache.lucene.document.Document;
import org.apache.lucene.facet.FacetResult;
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.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFloatFacetTopChildrenNaN extends LuceneTestCase {

  public void testNaNLabelDoesNotDisplaceHigherValue() throws Exception {
    try (Directory dir = newDirectory();
        Directory taxoDir = newDirectory()) {
      FacetsConfig config = new FacetsConfig();
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
          DirectoryTaxonomyWriter tw = new DirectoryTaxonomyWriter(taxoDir)) {
        String[] labels = {"a", "b", "c"};
        float[] values = {5f, Float.NaN, 10f};
        for (int i = 0; i < labels.length; i++) {
          Document doc = new Document();
          doc.add(new FloatAssociationFacetField(values[i], "dim", labels[i]));
          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());
        FacetResult top1 =
            new TaxonomyFacetFloatAssociations(tr, config, fc, AssociationAggregationFunction.SUM)
                .getTopChildren(1, "dim");
        String got = top1.labelValues[0].label;
        // expected c (10.0 outranks a's 5.0) or b (if NaN ranks first); actual a (5.0)
        assertTrue(
            "expected top-1 c or b, but was " + Arrays.toString(top1.labelValues),
            got.equals("c") || got.equals("b"));
      }
    }
  }
}
Command
cp $REPRO/TestFloatFacetTopChildrenNaN.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/taxonomy/
cd $LUCENE && ./gradlew :lucene:facet:test --tests org.apache.lucene.facet.taxonomy.TestFloatFacetTopChildrenNaN -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
c (10.0), because 10 outranks 5, or b (NaN) if NaN ranks first.
Observed
[a (5.0)] on main and 10.6.0 with assertions on and off, and on every release back to 9.11.0 (assertions on; 10.5.2 and 9.11.0 also with assertions off).

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

More files, environment, full output188 words

The test indexes 3 documents, each with one FloatAssociationFacetField(weight, "dim", label): a=5, b=NaN, c=10. It counts MatchAllDocsQuery with TaxonomyFacetFloatAssociations (SUM) and asserts that getTopChildren(1, "dim") returns c, or b if NaN ranks first. Deterministic (3 ordinals, so dense counting) and single-threaded; it draws no random values itself, and it failed under every seed tried (10 derived seeds, 5 more from 5EED1530, and the tie-break's). Set -Ptests.asserts explicitly: with a fixed seed and no value, Lucene's build picks the assertion mode from the seed.

The ratio trigger (clicks / impressions with 0/0) needs the queries module, the top-1 vs top-2 contradiction a second call, and the call-to-call variation a taxonomy of more than 1024 labels; all three are executed evidence in the collapsed explanation above, not part of the repro.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); 10.6.0 and every release back to 9.11.0 also fail; 9.10.0 passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

TestFloatFacetTopChildrenNaN > testNaNLabelDoesNotDisplaceHigherValue FAILED
    java.lang.AssertionError: expected top-1 c or b, but was [a (5.0)]
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:5737D7D41F8E5B8F]: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.TestFloatFacetTopChildrenNaN.testNaNLabelDoesNotDisplaceHigherValue(TestFloatFacetTopChildrenNaN.java:60)

1 test completed, 1 failed

# -Ptests.asserts=false: the same failure and message

Why it looks unintended

  • getTopChildren "Returns the topN child labels"; the queue "Keeps highest results, first by largest float value". No facet javadoc excludes NaN.
  • A 9.11.0 regression: before #12966 only labels with value > 0 entered the queue, and the same change made the getTopDims dim order NaN-safe.
  • No rank for NaN explains the output: top-1 [a] is not in top-2 [b, c].
The full argument271 words
  • Contract. Facets#getTopChildren: "Returns the topN child labels under the specified path." (Facets.java:39-41). TopOrdAndFloatQueue: "Keeps highest results, first by largest float value, then tie-break by smallest ord." (TopOrdAndFloatQueue.java:19). Only getAllChildren disclaims order: "Users should make no assumptions about ordering of the children" (Facets.java:32-36). The gap, said plainly: neither text says where NaN ranks or promises in so many words that a smaller top-k is a prefix of a larger one, so the case rests on the finite labels: whatever rank NaN gets, 5 must not be returned in place of 10.
  • NaN is accepted, not rejected. No javadoc in taxonomy/, TopOrd*.java or Facets.java mentions NaN. Where the module does not accept NaN it throws: "min cannot be NaN" / "max cannot be NaN" (range/DoubleRange.java:62, :69; rangeonrange/DoubleRange.java:44, :51; facetset/DimRange.java:63, :70, :87, :94).
  • History. 9.10.0 admitted a child only if (value > 0) and only above the queue's bottom (FloatTaxonomyFacets.java:218, :221), both false for NaN. #12966 (9ba4af7b883c) switched to count > 0 to keep zero and negative labels (CHANGES.txt:2922, 9.11.0 Bug Fixes: "Aggregation facets no longer assume that aggregation values are positive."); its commit message says "The only change in behaviour users should notice is the fix for non-positive aggregation values". NaN is not discussed in #12585, #12966 or #13358.
  • Tests. TestTaxonomyFacetAssociations#testNonPositiveAggregations asserts value order including 0.0 and -1.0 (:562-565). No facet test uses NaN.
  • Precedent. LUCENE-4937 (CHANGES.txt:14181, "Fix incorrect sorting of float/double values (+/-0, NaN).") fixed the same < / > pattern in sort comparators with Float.compare.
The devil's-advocate check225 words

The strongest case for "intended": NaN is not a valid value. Core says so for scores (TopScoreDocCollector.java:30-31: "The values Float.NaN and Float.NEGATIVE_INFINITY are not valid scores", LUCENE-2271 / #3347); "largest float value" is undefined for NaN; PriorityQueue only promises a "partial ordering" (PriorityQueue.java:30-32); the facet APIs are @lucene.experimental; and AssociationAggregationFunction has a TODO that valid values are assumed positive (:25-28).

Why it fails: the misordered labels are finite (5 displaces 10, -3 displaces 7), so no rank for NaN fits, and none explains identical calls returning different labels; the NaN comes from ordinary data: finite inputs to Lucene's own arithmetic (0/0 in DivFloatFunction, or two finite doubles that the float cast turns into +Inf and -Inf); the core note covers hit scores in one collector, and the facet module documents no such rule and rejects NaN explicitly where it does not accept it; "partial ordering" describes the heap, not a licence for a non-transitive lessThan, and @lucene.experimental marks unstable API, not permission for wrong results; the TODO dates from 2022 (LUCENE-10444), is about MIN support, and was overtaken by #12966, which made non-positive values supported; and 9.10.0 kept the finite labels in order. Verdict: not intended, a regression. Expect the "NaN is not valid" reply and answer it with the finite labels and the 0/0 trigger.

Suggested fix

Recommended A: Float.compare, with equal values kept tied (validated)

Use Float.compare in TopOrdAndFloatQueue.OrdAndFloat#lessThan, but keep equal values (including -0.0 and +0.0) tied by ordinal: the order of non-NaN values does not change, and NaN ranks first, as getTopDims ranks dims. With it the repro and the whole facet test module pass, and the exhaustive sweep's 8,524 failures drop to 0.

@Override
public boolean lessThan(OrdAndValue other) {
  OrdAndFloat otherOrdAndFloat = (OrdAndFloat) other;
  // Float.compare gives NaN a consistent rank (above every number); the != test keeps -0.0
  // and +0.0 tied, so the order of non-NaN values is unchanged
  if (value != otherOrdAndFloat.value) {
    int cmp = Float.compare(value, otherOrdAndFloat.value);
    if (cmp != 0) {
      return cmp < 0;
    }
  }
  return ord > otherOrdAndFloat.ord;
}
All options and trade-offs5 options · 651 words

Option A was applied in a scratch worktree of 645d4fe14a20 and run with gradle (the worktree was removed after the runs), and shadowed onto the stock jars for the targeted checks and the sweep. Option B is the study's fix, validated by shadow runs. C, D and E were not built. On 9.11-9.12 the queue in use is TopOrdAndFloatNumberQueue (9.12.3 TopOrdAndFloatNumberQueue.java:41-50, the same comparator), so a 9.x backport changes that class.

A: Float.compare, with equal values kept tied (validated) (recommended)

One method, TopOrdAndFloatQueue.java:36-45. For two non-NaN values it decides exactly as today (-0f != 0f is false); NaN ranks above every number, two NaNs tie by ordinal. Unlike the plain Float.compare of getTopDims, it keeps -0.0 and +0.0 tied.

@Override
public boolean lessThan(OrdAndValue other) {
  OrdAndFloat otherOrdAndFloat = (OrdAndFloat) other;
  // Float.compare gives NaN a consistent rank (above every number); the != test keeps -0.0
  // and +0.0 tied, so the order of non-NaN values is unchanged
  if (value != otherOrdAndFloat.value) {
    int cmp = Float.compare(value, otherOrdAndFloat.value);
    if (cmp != 0) {
      return cmp < 0;
    }
  }
  return ord > otherOrdAndFloat.ord;
}

Trade-offs

  • Ranks NaN first, consistent with FloatTaxonomyFacets.valueComparator, which getTopDims uses for dims. Being a strict total order, it also makes the result independent of the visiting order, so the sparse path stops varying. Upstream may prefer NaN last (C) or dropped (D); the repro accepts any of them.
  • Two more code lines than B, plus a two-line comment. For 9.12, the same change goes into TopOrdAndFloatNumberQueue.

What the checks validated

  • Gradle, stock 645d4fe14a20 (seed DEADBEEF): the repro fails with -Ptests.asserts=true and false.
  • Gradle with the fix, assertions on: the repro and the whole facet test module, 40 classes, 307 tests, 0 failures, 7 skipped; :lucene:facet:checkGoogleJavaFormat passes. Assertions off: the repro and the two upstream float-facet test classes, 23 tests, 0 failures.
  • Shadowed onto the 645d4fe14a20 jars: the repro passes with -ea and -da; a 7-method check (4 NaN cases incl. the 0/0 ratio, 3 controls) passes 7 of 7 and an 8-method check (MAX, the sparse path with 60 calls, NaN from finite double weights, getTopDims, 4 controls) passes 8 of 8, each with -ea and -da; the bounded-exhaustive sweep drops from 8,524 failing child checks (of 97,184 with a NaN input) to 0, 0 of 119,424 without NaN before and after, every other sweep family unchanged.

B: plain Float.compare with the ordinal tie-break (the study's fix) (validated by the checks)

Same place: int cmp = Float.compare(value, otherOrdAndFloat.value); if (cmp != 0) return cmp < 0; return ord > otherOrdAndFloat.ord;. Simplest; the same order as valueComparator and as float sorting since LUCENE-4937.

Trade-offs

Also orders -0.0 below +0.0, a change for that pair. The built-in SUM and MAX fold from +0.0 and never produce -0.0, but AssociationAggregationFunction is public, so a custom function could (raised by the cross-model reviewer and the adjudication). TopOrdAndFloatQueue and OrdAndFloat are public classes too (TopOrdAndFloatQueue.java:20, :28, :30), so code outside Lucene can fill the queue with -0.0 directly.

What the checks validated

Shadowed: the repro, the finder's, the independent reproducer's and the reviewer's end-to-end tests, the 7-method and 8-method checks, the sweep (8,524 to 0), and the upstream TestTaxonomyFacetAssociations and TestTaxonomyFacetValueSource (22 of 22) pass. Not run through gradle.

C: rank NaN below every number (not built)

Same shape as A, with if (Float.isNaN(value) != Float.isNaN(other)) return Float.isNaN(value); before the value test.

Trade-offs

Keeps NaN labels out of short top-k lists, but then children and dims (getTopDims, NaN first) order NaN differently.

D: leave NaN labels out of the queue (not built)

The effective 9.10.0 behaviour.

Trade-offs

Changes childCount and makes getTopChildren disagree with getAllChildren, which reports NaN labels.

E: document NaN as unsupported (docs only)

As TopScoreDocCollector does for scores.

Trade-offs

Leaves the 0/0 expression path silently wrong; nothing rejects such a value today.

Downstream note

A workaround until a fix ships: services whose facet weights are ratios, square roots or logs of document fields, which can evaluate to NaN, can replace a NaN weight with a number (for example 0) before it reaches the facet aggregation.

How it was found

A randomized differential probe written to test comparator laws reported 22 wrong top-k results in 300 random float facets with one NaN-capable label (its raw output was not kept). An SMT solver (cvc5 and z3) then showed the comparator is not transitive once NaN is allowed, and a bounded-exhaustive sweep found 8,524 failing checks, all with a NaN input.

The method in full201 words

Method: non-formal first signal, then formal and exhaustive checking.

  • A planning AI tool wrote a randomized differential probe of the stock main jar for a comparator-law oracle: 300 random TaxonomyFacetFloatAssociations SUM facets with one NaN-capable label; the planning record reports 22 with a wrong top-k among the non-NaN labels (the probe's source is kept, its raw output is not).
  • The hunting AI tool checked the comparator with SMT (cvc5 1.4.2 and z3 4.12.2, floating point and bit vectors): lessThan is not transitive with NaN (with a witness of the repro's shape), is a strict order without NaN, and the Float.compare variant is a strict total order over all floats (for distinct ordinals). It also showed that the built-in SUM and MAX folds never produce -0.0.
  • A bounded-exhaustive sweep of the stock jar (1-4 labels, inputs {1, 2, 3, NaN, +Inf, -Inf, -0.0}, every k, association and value-source SUM and MAX, dense and sparse counting, 1 or 2 segments) found 8,524 failing child checks of 97,184 with a NaN input and 0 of 119,424 without; re-run on 645d4fe14a20 for this page with the same numbers.
  • The case was reduced to a public-API JUnit with three triggers, and later to the one-test repro.

Prior reports, history and checks

Prior reports

No prior report found in the logged keyword searches of GitHub issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers and the web: 302 queries on 2026-10-28 and 354 on 2026-10-30. The nearest item, #3347 (LUCENE-2271), is about NaN scores in the core hit queue.

Searches3 searches

No report of this bug was found, and nothing upstream fixes it: upstream main 2f424fd428da, branch_11x, branch_10_6 and the 10.6.0 release (on Maven Central since 2026-10-29) carry the same TopOrdAndFloatQueue.java. Closest items: #3347 (LUCENE-2271, open since 2010: NaN scores break the core hit queue's order; answered with the "NaN is not a valid score" note; no facets); #12966 and #13358 (made NaN reachable, no NaN discussion); #12585 (non-positive values only); #6403 (LUCENE-5339, where the comparator comes from); #14872 (open, "Remove the last PriorityQueue subclasses"; no NaN); #6001 (LUCENE-4937) and #4656 (LUCENE-3582), NaN-ordering precedents in core. The sibling LFV-820 (getAllDims dim sort) has the same trigger at another fix site.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, commits (apache/lucene incl. the migrated JIRA archive)2026-10-28 and 2026-10-30 (morning and evening)a scripted prior-report search rounds plus manual searches, e.g. TopOrdAndFloatQueue, OrdAndFloat, "getTopChildren NaN", "NaN facet", "NaN association", "Float.NaN" facet, "facet non-transitive", label:module:facet NaNno report; context only (#3347, #12966, #13358, #12585, #6403, #14872, #6001, #4656)
Open and recent PRs, issues, fix-site commits2026-10-28 to 2026-10-30changed-file lists of the open PRs that touch lucene/facet and of every PR updated since 2026-10-28; issues updated since 2026-10-28; newest commits on the fix-site files and PriorityQueue.javanone touches OrdAndFloat#lessThan; the file's newest commit is still #12966
Mailing lists, JIRA, downstream, web2026-10-3092 lists.apache.org queries over dev@, java-user@, issues@, commits@ and a scan of the last 3 days; 18 JQL queries; Elasticsearch, OpenSearch, Solr, Lucene.NET, nrtsearch; 24 web queriesno report

History

Introduced by #12966 (9ba4af7b883c, 2024-04-05; 9.x backport #13358), which let every label with a count into the queue; first released in 9.11.0 and 10.0.0. 9.10.0 and older pass (NaN labels were left out). Executed on 36 releases from 4.7.0 to 10.6.0 (19 affected), a branch_9x commit pair and main.

Versions tested8 rows

#12966 "Reduce duplication in taxonomy facets; always do counts" (Stefan Vodita; main 9ba4af7b883c, 2024-04-05; backport #13358, 42884fab46de, 2024-05-14) replaced the child admission test value > 0 with count > 0. The < / > comparator dates from the LUCENE-5339 rewrite (5458dbf2484f, 2013, then in facet.simple.TopOrdValueQueue, renamed to TopOrdAndFloatQueue in f6cd7fda747; first released in 4.7.0) and could not see NaN before. On branch_9x the facet module built at the backport fails and at its parent 8580d50dbb91 passes, both on the 9.11.0 core jar. Older releases were run with an old-API copy of the same test. The boundary cases (9.10.0, 9.11.0, 10.0.0, 10.6.0, main and the branch_9x pair) were re-run with a second seed with the same results.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (unreleased 11.0) affectedfails, -ea and -da (gradle and standalone runner)
10.6.0 (4ae946e5a9b9) affectedfails, -ea and -da
10.0.0 to 10.5.2 (12 releases) affectedfail
9.11.0, 9.11.1, 9.12.0 to 9.12.3 affectedfail
branch_9x 42884fab46de (#13358) vs parent 8580d50dbb91 bisectthe backport fails, its parent passes
9.10.0 (-ea and -da), 9.2.0 to 9.9.2 not affectedpass: the NaN label is left out (value > 0)
4.7.0, 8.11.4, 9.0.0, 9.1.0 not affectedpass (old-API copy): the NaN label is left out
4.6.1 n/ano such API

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate, an adjudication, an audit, a current-main reproducer, two tie-break checks and this page's gradle runs all reproduce it. Cross-model reviews (OpenAI GPT-5.6) could run no code and returned not real for procedure only, while agreeing with the root cause.

Checks and verdicts475 words

Every executing check ran on stock jars and voted real. The only dissent is procedural: refute-lens reviews by an independent reviewer from another model family could not start any command and voted not real on procedure only at low confidence; by reading they agreed with the root cause and found the inputs in contract.

Each independent check and its verdict
CheckVerdictWhat it found
First signal (planning probe) first signal22 wrong top-k results in 300 random float facets with one NaN-capable label, as the planning record reports (the probe's raw output was not kept).
Finder (finder) candidateSMT (cvc5, z3) and the exhaustive sweep; public-API JUnit with three triggers fails 3 of 3 on main (-ea, -da), 10.5.2, 10.0.0 and 9.12.3; passes with the Float.compare fix.
Independent reproducer realOwn JUnit, five NaN triggers: 5 of 8 fail on main, 10.5.2 and 9.12.3, controls pass; the queue-level test shows the cycle.
Cross-model review, refute lens (OpenAI GPT-5.6, read-only) not real, low (procedural)Could not run code; agreed with the root cause, inputs in contract, intended argument weak; raised the -0.0 detail. Its operator's own JUnit fails 1 of 4 (MAX, top-2).
Intended-behaviour devil's advocate real, highBuilt the 'NaN is not a valid score' case and rejected it; the 0/0 ratio probe fails on main and 9.11.0 and passes on 9.10.0.
Adjudication confirmed, highRe-ran the independent reproduction's tests with a new seed; corrected the affected versions to 9.11.0 and later.
Credibility audit keepRe-ran the repro and the ratio probe on main; re-checked the trace and the regression commit.
Current-main reproducer realRe-run 2026-10-30: the one-test repro fails on main 645d4fe14a20 and 10.5.2 with -ea and -da, 5 of 5 with --iters 5; passes with the fix.
Tie-break 1 realOwn 7-method test: the 4 NaN cases (incl. -3/NaN/7 and the 0/0 ratio) fail on main, 10.6.0, 10.5.2 and 9.11.0, pass on 9.10.0 and with the fix; 3 controls pass.
Tie-break 2 real, highOwn 8-method test: MAX, the sparse path (14 to 19 of 60 calls wrong), NaN from finite doubles and getTopDims fail on main and 10.6.0 (-ea, -da); 4 controls pass; 8 of 8 pass with the fix. The repro fails on 10.6.0 and 9.11.0, passes on 9.10.0.
Cross-model refute reviews of the current candidate (OpenAI GPT-5.6) not real, low (procedural)Seven sessions; none could execute code. The last agreed with the root cause, found the inputs in contract and rated the intended argument weak. The operators' own re-runs fail on main and 10.5.2.
This page's runs reproduced, fix validatedGradle on a scratch worktree: stock fails with assertions on and off; option A passes the repro and the facet module; option A shadowed passes the 7-method and 8-method checks; sweep re-run stock and with option A.

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

Finding record

LFV-825, snapshot of 2026-11-06

Registry title
Float taxonomy facets: if one child label aggregates to NaN, getTopChildren can return a lower-valued label in place of a higher one (a=5, b=NaN, c=10 gives top-1 [a]), because TopOrdAndFloatQueue compares values with < and >; a regression in 9.11.0 from #12966
Area
facet / taxonomy facets (TopOrdAndFloatQueue order of float-valued child labels in getTopChildren: float association and value-source facets)
Priority
Low, rank 585: A NaN aggregate is a rare boundary input, but getTopChildren silently swaps a lower finite label in for a higher one in a released 9.11 regression.
Impact class
silent wrong results
Impact note
Applications that rank the child labels of a float-valued taxonomy dim with getTopChildren (TaxonomyFacetFloatAssociations fed by FloatAssociationFacetField weights or a DoubleValuesSource, SUM or MAX; opt-in float facets, otherwise the default IndexWriterConfig, codec, taxonomy and FacetsConfig, -ea and -da, no scale needed) silently get a lower-valued finite label in place of a higher one, and top-k lists for different k that contradict each other, once one child label aggregates to NaN, which also happens from non-NaN data (a 0/0 ratio expression such as clicks / impressions, sqrt of a negative value, or finite double weights beyond the float range that become +Inf and -Inf and sum to NaN); released since 9.11.0 (a regression from #12966), it shows only for some label ordinals (22 of 300 random probe trials, 8,524 of 97,184 exhaustive-sweep checks with a NaN label), getAllChildren, matching documents and drill-down are unaffected, and nothing is written to the index.
Summary
You aggregate a float weight per taxonomy label (FloatAssociationFacetField, or a DoubleValuesSource such as the expression clicks / impressions) and call getTopChildren(k), documented to return 'the topN child labels'. If one label's value is NaN (a=5, b=NaN, c=10), getTopChildren(1) returns [a (5.0)] although c=10 outranks a, and getTopChildren(2) returns [b (NaN), c (10.0)], so the top-1 is not even in the top-2, whatever rank NaN should get. TopOrdAndFloatQueue.OrdAndFloat#lessThan compares with < and > and otherwise falls back to the ordinal, so NaN ties with every value, the order is not transitive and the heap keeps the wrong labels; #12966 (9.11.0) let NaN-valued labels into this queue and, in the same change, ordered dims with the NaN-safe Float.compare.
Introduced
9ba4af7b883c (GITHUB#12966 'Reduce duplication in taxonomy facets; always do counts', by @stefanvodita, squash-merged to main; 9.x backport 42884fab46de / GITHUB#13358): it replaced the child admission test 'value > 0' (false for NaN) with 'count > 0' in TaxonomyFacets.getTopChildrenForPath, so a NaN-valued child now enters TopOrdAndFloatQueue (on 9.x the copy TopOrdAndFloatNumberQueue), whose < / > comparator with ordinal tie-break dates from the LUCENE-5339 facet rewrite (5458dbf2484f, by @mikemccand, merged as 9d0b60388da / 7477fde1cb0, in every release since 4.7.0) and could not see NaN before. The CHANGES entry of #12966 ('Aggregation facets no longer assume that aggregation values are positive', closes #12585) shows the wider admission was deliberate for non-positive values; NaN is not mentioned in #12585, #12966 or #13358. [read: git log -S and git blame in the history clone; exec: the finder's JUnit passes on 9.10.0 and fails on 9.11.0, and so does the intended-behaviour check's ratio probe] (2024-04-05 (author and committer date of 9ba4af7b883c; PR opened 2023-12-22); backport 2024-05-14; the comparator itself 2013-11-14 (author date of 5458dbf2484f), latent until 2024)
First affected version
9.11.0 [exec] on the 9.x line, 10.0.0 [exec] on the 10.x line; 9.10.0 passes [exec], because its 'value > 0' admission test kept NaN labels out of the queue (it silently omitted them and every non-positive label, the older behaviour that #12585 / #12966 changed on purpose, not this bug); 4.6.1 has no such API [exec: compile error]
Affected versions
9.11.0, 9.11.1, 9.12.3, 10.0.0, 10.5.2 and main a9779ef3c193 [exec: the finder's JUnit fails 3 of 3 on each; main also with -da]; 9.12.0-9.12.2 and 10.1.0-10.5.1 [read: same code]; upstream main 20ced71c1e6e and branch_10_6 4ae946e5a9b9 carry a byte-identical TopOrdAndFloatQueue.java (blob f5c43fd6dca5, as in a9779ef3c193) [read], so the released 10.6.0 has it; the bug doc's repro also fails on the 10.6.0 jars (4ae946e5a9b, -ea and -da) [exec]. Not affected [exec]: 4.7.0, 4.10.4, 5.5.5, 8.11.4, 9.0.0, 9.1.0, 9.9.2 and 9.10.0 (API-adapted copy of the oracle; NaN labels dropped by the old admission test). Fix site per line: main and 10.x TopOrdAndFloatQueue.OrdAndFloat#lessThan; 9.11-9.12 the copy in TopOrdAndFloatNumberQueue.OrdAndFloat#lessThan (the 9.x TopOrdAndFloatQueue is the legacy, unused class). All runs on JDK 25, Linux 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 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
non-formal; also reached by Z3/cvc5, exhaustive check
Upstream and prior reports
none found (2026-10-28, the prior-report record, verdict new: 302 logged queries: a scripted prior-report search in two runs over GitHub issues, PRs and commits of apache/lucene, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and 7 web queries; 30 own GitHub searches, among them all of GitHub, nrtsearch, Lucene.NET, Solr and OpenSearch; the changed-file lists of all 411 open apache/lucene PRs and of the 59 PRs updated in the 48 h before, incl. #16913; the module:facet-labelled items; the full discussions of 32 issues and PRs; 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 or updated since 2026-10-26 changes a file under lucene/facet; the newest upstream commit on TopOrdAndFloatQueue.java is 9ba4af7b883c (#12966, author date 2024-04-05), and the file is byte-identical on upstream main 20ced71c1e6e, branch_10_6 4ae946e5a9b9 and a9779ef3c193; keyword search was not repeated there (search API quota exhausted), one web search found nothing. Nearest items, none of them this bug: #3347 (LUCENE-2271, open since 2010: NaN scores break the order of the core hit queue's lessThan; the same defect class in a different component with a different fix site, no facets), #12966 and its backport #13358 (made NaN reachable; no NaN discussion), #12585 (non-positive aggregation values, never NaN), #14872 (open: convert the remaining PriorityQueue subclasses, TopOrdAndNumberQueue among them, to comparators; no PR, no NaN).
Lucene commit tested
main 645d4fe14a20 (2026-10-28; TopOrdAndFloatQueue.java is the same blob on upstream main 2f424fd428da, 2026-10-30, and no commit in between touches lucene/facet): the minimal repro with gradle in a scratch worktree, assertions on and off, and with the recommended fix together with the whole facet test module (the worktree was removed after the runs); the standalone runner on main and on the release jars 9.2.0 to 10.6.0 with -ea (and -da on main, 9.10.0, 10.5.2 and 10.6.0), and an old-API copy on 4.7.0, 8.11.4, 9.0.0 and 9.1.0; a branch_9x commit pair; shadow runs of both fix variants with the repro, a 7-method and an 8-method check (MAX, sparse counting, NaN from finite doubles, getTopDims) and a bounded-exhaustive sweep; the earlier tests of the finder, an independent reproducer, a devil's advocate, an adjudication and an audit on main a9779ef3c193 (no lucene/facet change up to 645d4fe14a20). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/TopOrdAndFloatQueue.java:19 (at a9779ef3c193) (class javadoc 'Keeps highest results, first by largest float value, then tie-break by smallest ord.'), :36-45 (OrdAndFloat#lessThan: value < other -> true; value > other -> false; else ord > other.ord)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:467-503 (at a9779ef3c193) (getTopChildrenForPath admits every child with count > 0, lines 482 and 495, into the queue through insertWithOverflow)
  • lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:231-243 (at a9779ef3c193) (insertWithOverflow replaces the top when lessThan(heap[1], element))
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:48 (at a9779ef3c193) (valueComparator = Float.compare on floatValue(), used for dims)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetFloatAssociations.java:151 (at a9779ef3c193) and :191 (values reach the queue unchecked: (float) of the DoubleValues value, or the stored association float)
  • lucene/facet/src/java/org/apache/lucene/facet/Facets.java:39-41 (at a9779ef3c193) (getTopChildren: 'Returns the topN child labels under the specified path.') and lucene/CHANGES.txt:2905 (GITHUB#12966: 'Aggregation facets no longer assume that aggregation values are positive.')
The finding in 30 seconds
  • Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/TopOrdAndFloatQueue.java:19, :36-45
  • Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:103-123 (useHashTable), :132, :421-431, :467-503 (:479, :482, :495), :519-532, :557-575, :580-590, :595, :613, :645, :652-657, :683
  • Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:48, :103-105, :125-127
  • Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetFloatAssociations.java:151, :155-157, :191, :193-195; FloatAssociationFacetField.java:33-40; AssociationAggregationFunction.java:40-66
  • Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:231-243; lucene/core/src/java/org/apache/lucene/internal/hppc/IntIntHashMap.java:106, :463, :480-482; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/directory/TaxonomyIndexArrays.java:176-187
Who is affected
  • Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetFloatAssociations.java:36-49 (@lucene.experimental), :151, :191
  • Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:103-123 (useHashTable: dense vs sparse counting)
Why it looks unintended
  • Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/Facets.java:32-36, :39-41; TopOrdAndFloatQueue.java:19
  • Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:62, :69; rangeonrange/DoubleRange.java:44, :51; facetset/DimRange.java:63, :70, :87, :94
  • a Lucene clone releases/lucene/9.10.0: lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:218, :221
  • Lucene main 645d4fe14a20: lucene/CHANGES.txt:2922, :14181
  • Lucene main 645d4fe14a20: lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestTaxonomyFacetAssociations.java:516-571
  • Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31; lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:30-32; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/AssociationAggregationFunction.java:25-28
Suggested fix
  • Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/TopOrdAndFloatQueue.java:20, :28, :30 (public classes and field)
  • a Lucene clone releases/lucene/9.12.3: lucene/facet/src/java/org/apache/lucene/facet/TopOrdAndFloatNumberQueue.java:41-50

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 page: a summary card (PNG).

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.