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.
- Taxonomy facets give every label an ordinal, a small integer assigned in the order labels are first indexed. In the repro: root 0,
dim1, then a = 2, b = 3, c = 4. - Float facets (
TaxonomyFacetFloatAssociations) add up a float per label: an indexed weight (FloatAssociationFacetField) or a per-document value from aDoubleValuesSource, with SUM or MAX. 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.
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.- Index three documents with a float weight on one facet dim, a=5, b=NaN, c=10 (
FloatAssociationFacetField), and sum them withTaxonomyFacetFloatAssociations. A NaN also appears with no NaN in the data, e.g. a clicks / impressions weight for a document with 0 impressions (0/0). - Ask for the best label:
getTopChildren(1, "dim"). You expectc (10.0), since 10 > 5, orb (NaN)if NaN ranks first. Nevera. - 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.getAllChildrenstill shows all three values correctly. - 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. - 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.
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.
- Values reach the queue unchecked:
FloatAssociationFacetFieldencodes any float (FloatAssociationFacetField.java:33-40), andTaxonomyFacetFloatAssociationscasts aDoubleValuesvalue 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). TaxonomyFacets.getTopChildrenForPath(:467-503) puts every child withcount > 0(sparse:482, dense:495) throughinsertIntoQueue(:421-431) intoPriorityQueue.insertWithOverflow, which replaces the current worst entry whenlessThan(top, element)(PriorityQueue.java:231-243).getTopChildren,getAllDimsandgetTopDimsall use it.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.- 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)]. - Large taxonomies can vary from call to call. Counting uses a hash table only when a
FacetsCollectoris given, the taxonomy has 1024 or more ordinals and the hits number fewer thanmaxDoc / 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. - The same change that let NaN in (#12966) ranks the dims of
getTopDimswith the NaN-safeFloat.compare(FloatTaxonomyFacets.java:48), but kept</>for the child labels.getAllDimssorts dims with its own NaN-unsafe comparator (TaxonomyFacets.java:580-590, the sibling LFV-820).
- Weights -3, NaN, 7: top-1
[low (-3.0)]. - Plain
FacetFieldlabels with the weightDivFloatFunction(LongFieldSource("clicks"), LongFieldSource("impressions")), clicks / impressions 5/100, 0/0, 50/100: top-1[low (0.05)]instead ofhigh (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 oneFacetsobject 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 maina9779ef3c193, 10.5.2 and 9.12.3; re-run on645d4fe14a20by 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.experimentalAPI) fed byFloatAssociationFacetFieldweights or aDoubleValuesSource(an expression, a function query, a double doc-values field), with SUM or MAX, throughgetTopChildren,getAllDimsorgetTopDims(all three executed). DefaultIndexWriterConfig, codec, taxonomy writer andFacetsConfig.getTopDimsranks the dims NaN-safely, but its child lists come from this queue;getAllDimsgets its child lists fromgetTopChildrenand 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),
sqrtorlnof 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 leastmaxDoc / 10hits): 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 thanmaxDoc / 10hits, 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.
- Dense counting (fewer than 1024 taxonomy ordinals, no
- 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.
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.
/*
* 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"));
}
}
}
}
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, orb (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).
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); 10.6.0 and every release back to 9.11.0 also fail; 9.10.0 passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
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 messagegetTopChildren"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
getTopDimsdim 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). OnlygetAllChildrendisclaims 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*.javaorFacets.javamentions 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 tocount > 0to 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#testNonPositiveAggregationsasserts 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 withFloat.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.
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.
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, whichgetTopDimsuses 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=trueandfalse. - Gradle with the fix, assertions on: the repro and the whole facet test module, 40 classes, 307 tests, 0 failures, 7 skipped;
:lucene:facet:checkGoogleJavaFormatpasses. Assertions off: the repro and the two upstream float-facet test classes, 23 tests, 0 failures. - Shadowed onto the
645d4fe14a20jars: the repro passes with-eaand-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-eaand-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.
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.
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.
The effective 9.10.0 behaviour.
Trade-offs
Changes childCount and makes getTopChildren disagree with getAllChildren, which reports NaN labels.
As TopScoreDocCollector does for scores.
Trade-offs
Leaves the 0/0 expression path silently wrong; nothing rejects such a value today.
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.
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
TaxonomyFacetFloatAssociationsSUM 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):
lessThanis not transitive with NaN (with a witness of the repro's shape), is a strict order without NaN, and theFloat.comparevariant 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
645d4fe14a20for 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| 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 NaN | no report; context only (#3347, #12966, #13358, #12585, #6403, #14872, #6001, #4656) |
| Open and recent PRs, issues, fix-site commits | 2026-10-28 to 2026-10-30 | changed-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.java | none touches OrdAndFloat#lessThan; the file's newest commit is still #12966 |
| Mailing lists, JIRA, downstream, web | 2026-10-30 | 92 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 queries | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 (unreleased 11.0) | affected | fails, -ea and -da (gradle and standalone runner) |
10.6.0 (4ae946e5a9b9) | affected | fails, -ea and -da |
| 10.0.0 to 10.5.2 (12 releases) | affected | fail |
| 9.11.0, 9.11.1, 9.12.0 to 9.12.3 | affected | fail |
branch_9x 42884fab46de (#13358) vs parent 8580d50dbb91 | bisect | the backport fails, its parent passes |
| 9.10.0 (-ea and -da), 9.2.0 to 9.9.2 | not affected | pass: the NaN label is left out (value > 0) |
| 4.7.0, 8.11.4, 9.0.0, 9.1.0 | not affected | pass (old-API copy): the NaN label is left out |
| 4.6.1 | n/a | no such API |
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.
| Check | Verdict | What it found |
|---|---|---|
| First signal (planning probe) | first signal | 22 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) | candidate | SMT (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 | real | Own 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, high | Built 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, high | Re-ran the independent reproduction's tests with a new seed; corrected the affected versions to 9.11.0 and later. |
| Credibility audit | keep | Re-ran the repro and the ratio probe on main; re-checked the trace and the regression commit. |
| Current-main reproducer | real | Re-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 | real | Own 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, high | Own 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 validated | Gradle 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 516 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,507 words
- 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.javais the same blob on upstream main2f424fd428da, 2026-10-30, and no commit in between toucheslucene/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 maina9779ef3c193(nolucene/facetchange up to645d4fe14a20). OpenJDK 25.0.3, Linux aarch64.
- 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-45Lucene 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, :683Lucene main 645d4fe14a20: lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:48, :103-105, :125-127Lucene 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-66Lucene 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, :191Lucene 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:19Lucene 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, :94a Lucene clone releases/lucene/9.10.0: lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:218, :221Lucene main 645d4fe14a20: lucene/CHANGES.txt:2922, :14181Lucene main 645d4fe14a20: lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestTaxonomyFacetAssociations.java:516-571Lucene 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
- TestFloatFacetTopChildrenNaN.java (JUnit for Lucene's build):
repro/LFV-825/src/org/apache/lucene/facet/taxonomy/TestFloatFacetTopChildrenNaN.java(66 lines)
Made in parallel with this page: a summary card (PNG).
assets/LFV-825/bug.png(516 KB)
![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.](../assets/LFV-825/bug.png)