All findings · Lucene finding · LFV-539 · float-valuesource-rollup-double-counts

Float association facets with a value source over-count labels that have hits below them

Sum a per-document value per category with TaxonomyFacetFloatAssociations and a DoubleValuesSource on a hierarchical dimension, and every category with matching documents below it reports a LabelAndValue.count that is too high: two documents under d/a come back as 4, while the value 9.0 is right. Every release since 9.11.0 does this.

Summary

Priority
Medium, rank 172Float association facets with a DoubleValuesSource inflate LabelAndValue.count on every hierarchical request, the same inflated-facet-count profile as LFV-151, LFV-433 and LFV-437, so it joins them.
Impact
silent wrong results
Since
9.11.0 (#12966, exposed by #13414, 2024)introduced 2024-04-05 · about 2 years
Affected
9.11.0 to 10.5.2 and main
Status
documented
Found by
metamorphic testing of the Facets API, confirmed by JUnit

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

Taxonomy facets can aggregate a per-document value, such as a price or the score, per category: TaxonomyFacetFloatAssociations with a DoubleValuesSource. Since 9.11.0 each result label also carries count, the number of matching documents under it, as FastTaxonomyFacetCounts reports.

Summary card. Title: TaxonomyFacetFloatAssociations with a DoubleValuesSource over-counts every label with documents below it in a hierarchical dimension. The setup: config.setHierarchical("d", true), documents d/a/x with weight 7 and d/a/y with weight 2, SUM over DoubleValuesSource.fromLongField("w"). The taxonomy tree d, d/a, d/a/x, d/a/y with each node's count and value; walk 1 (from d) rolls the leaf counts up, walk 2 (from d/a) adds the same counts again. Expected: a, value 9.0, count 2. Actual on main, executed: a, value 9.0, count 4.
The two-document example as a taxonomy tree: each hit counts 1 at its leaf; walk 1 from d adds the counts up (a = 2); walk 2 from d/a adds the same counts again (a = 4). Expected against actual on main: value 9.0 both times, count 2 against 4. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Declare dimension d hierarchical and index two documents: d/a/x with weight 7 and d/a/y with weight 2. Sum the weights per category with TaxonomyFacetFloatAssociations and DoubleValuesSource.fromLongField("w").
  2. You expect category a with value 9.0 (7 + 2) and count 2, the two documents under it. FastTaxonomyFacetCounts reports 2 for the same hits.
  3. You get value 9.0 but count 4, from getTopChildren and getAllChildren. One document at d/a/b/x gives counts 3, 3 and 1 instead of 1, 1 and 1: the deeper the path, the bigger the overcount.
  4. Why: the roll-up first adds up the counts of the whole sub-tree (the super call), then walks the tree again through the same method, so each level adds its children's already rolled-up counts again, and the extra counts compound with depth.
  5. No error, and values, value order, childCount and leaf counts are right (short of an int overflow at extreme depths). A one-line fix makes the second walk roll up values only; the whole facet test suite passes with it.

Who is affected

Applications that sum or max a per-document value per category with TaxonomyFacetFloatAssociations and a DoubleValuesSource, over a dimension set with setHierarchical and paths two or more levels deep, and show LabelAndValue.count. Every category with matching documents below it is wrong, on every such request, on 9.11.0 to 10.5.2 and main; the values look right.

Mechanism, terms and numbers708 words

TaxonomyFacetFloatAssociations with a DoubleValuesSource aggregates a per-document value per taxonomy category, and since 9.11.0 each LabelAndValue also carries count, the "Number of occurrences for this label". On a dimension declared hierarchical (and single-valued, the default), every label that has matching documents below it gets a count that is too high: two documents under d/a give a (9.0) with count 4, and one document at d/a/b/x gives 3 at a and at a/b, where FastTaxonomyFacetCounts gives 2 and 1. FloatTaxonomyFacets#updateValueFromRollup calls super, which rolls up the counts of the whole sub-tree, and then its own recursion, which calls the same method, and so super, again for every descendant. This defect leaves values, value order, childCount and leaf counts right, short of an int overflow at extreme depths. Upstream testRollupValues runs this path but compares only toString(), which omits the count. Every release from 9.11.0 to 10.5.2 and main is affected; a one-line change that makes the private recursion roll up values only fixes it, and the whole lucene/facet test suite passes with it.

Terms

  • Taxonomy facets: each document gets category paths such as d/a/x (FacetField); a side index, the taxonomy, gives every path prefix an ordinal: d, d/a, d/a/x.
  • Hierarchical dimension: FacetsConfig.setHierarchical("d", true), "Pass true if this dimension is hierarchical (has depth > 1 paths)" (FacetsConfig.java:170). If it is also single-valued (the default), only the leaf ordinal is indexed per document; the counts and values of d/a are computed at search time by a roll-up from the leaves (FacetsConfig.java:380-398; TaxonomyFacets.java:261-285).
  • Value-source association facets: TaxonomyFacetFloatAssociations(taxoReader, config, fc, SUM or MAX, valuesSource) aggregates valuesSource per category. The code says "Hierarchical dimensions are supported when using a value source, so we need to rollup" (TaxonomyFacetFloatAssociations.java:164-165).
  • LabelAndValue.count: "Number of occurrences for this label" (LabelAndValue.java:27-28), added by #13414 for 9.11.0 ("Counts are always available in the result when using taxonomy facets", CHANGES.txt:2784).

The bug

  1. Each hit adds its value and count + 1 at its indexed ordinal, the leaf (TaxonomyFacetFloatAssociations.java:149-161, :158). For d/a/x and d/a/y: x = 1, y = 1, a = 0, d = 0.
  2. rollup() calls the virtual updateValueFromRollup(d, a) (TaxonomyFacets.java:281). The float override first calls super.updateValueFromRollup (FloatTaxonomyFacets.java:96), which rolls up the counts of the whole sub-tree in place (TaxonomyFacets.java:242-244, :287-299): a = 2, d = 2. Already right.
  3. The override then calls its own rollup(a) for the values (FloatTaxonomyFacets.java:98, :112-122). That loop calls the virtual updateValueFromRollup(a, firstChild) (:117), so the super count pass runs again under a and adds x + y once more: a = 4. Every node with children repeats this, and each extra pass rewrites the counts below it in place, so the extra counts compound with depth: one document at depth D is counted C(D, k) times at level k. A label whose hits all sit at the label itself keeps its right count, because the extra passes add only zeros.
  4. Values are aggregated once, by the float recursion, and stay right. getTopChildren and getAllChildren put getCount(ord) into each LabelAndValue (TaxonomyFacets.java:325, :333-334, :410-412).

The numbers

documents (dimension d, hierarchical, single-valued)labelvalueexpected countreported count
d/a/x (w = 7), d/a/y (w = 2)a9.024
one document d/a/b/x (w = 5)a, a/b, x5.0 each1, 1, 13, 3, 1
one document at each level of a 20-level pathlabels in the middle of the path (levels 9 and 10 of 20)right12 and 11352,716 each
documents at d/a (w = 1) and d/a/x (w = 2); a query that matches only the d/a documenta1.0 (SUM)11 (right)
the first row's two documents, dimension also multi-valueda9.022 (right)

SUM and MAX give the same counts, and FastTaxonomyFacetCounts on the same hits gives the expected ones. The first two rows were run on main with assertions on and off and on 10.5.2, 10.0.0, 9.12.3 and 9.11.0; the third and fourth on main with assertions off (the fourth with SUM only); the last on main.

Exposure and likelihood440 words
  • Who is exposed. Applications that aggregate a per-document value per category with TaxonomyFacetFloatAssociations and a DoubleValuesSource (revenue per category, or score-weighted facets with DoubleValuesSource.SCORES), over a dimension declared setHierarchical(dim, true) and not multi-valued, and that read LabelAndValue.count, for example to show "Fiction (2 books, 9.00)". On 9.11.0-9.12.x the deprecated TaxonomyFacetSumValueSource (a subclass; count 4, value 9.0) and the deprecated TaxonomyFacetCounts(OrdinalsReader, ...) (count 4, value 2) are hit too; both were removed in 10.0.0.
  • Default vs opt-in. setHierarchical and the value-source constructor are opt-in, and the class is @lucene.experimental. Single-valued is the default for a dimension. The defect is in the roll-up after collection, which reads only the per-ordinal counts; the tests ran under Lucene's randomized codec, IndexWriterConfig and searcher settings and failed with every seed tried, in both the dense and the sparse (hash-table) counter branches.
  • Scale. A label's count is inflated whenever the request's hits include documents below it; labels whose hits all sit at the label itself, and requests with no hits, are unaffected. Nothing in the roll-up depends on index size. The overcount compounds with depth: with one document at each level of a 20-level path the largest count, 352,716, is at levels 9 and 10, while the deepest labels are near-exact (21 and 1).
  • Silent. No exception and no log line, and the values are right, which makes it easy to miss.
  • Extreme inputs. The same double walk visits each sub-tree once per ancestor, so the roll-up costs about the sum of node depths on every request with a hit: one 4,095-level path (Lucene's 8,191-character limit) takes 86.9 ms per request against 0.81 ms with the fix, and 5 paths of depth 4,000 take 412 ms against 3.2 ms (medians of 5 after 2 warm-ups on one shared host, -da; only the ratios matter). The walk covers the dimension's whole sub-tree in the taxonomy, so deep paths indexed by other documents raise every request's cost. At 33 levels with one document per level some counts wrap past 2^31, and those labels vanish from the results although they have hits, so result membership and childCount are wrong too. The fix removes both.
  • Not affected (short of the int overflow): values and value-based ranking; childCount; leaf counts; the dimension's own value and its internal count (FacetResult carries no count for the dimension itself); FastTaxonomyFacetCounts; indexed FloatAssociationFacetField and IntAssociationFacetField (they cannot be hierarchical); multi-valued hierarchical dimensions (not rolled up); flat dimensions. Nothing is stored wrong, so a fix needs no reindex. "Values are right" means this defect does not change them; a separate all-negative MAX bug in the same roll-up code does.

Minimal repro

The test shows that TaxonomyFacetFloatAssociations with a DoubleValuesSource on a hierarchical dimension rolls the counts up twice: one document at d/a/x.

TestFloatValueSourceRollupCounts.java (JUnit test)60 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 org.apache.lucene.document.Document;
import org.apache.lucene.facet.FacetField;
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.DoubleValuesSource;
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 TestFloatValueSourceRollupCounts extends LuceneTestCase {

  public void testRolledUpCountOfIntermediateLabel() throws Exception {
    FacetsConfig config = new FacetsConfig();
    config.setHierarchical("d", true);
    try (Directory dir = new ByteBuffersDirectory();
        Directory taxoDir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
        DirectoryTaxonomyWriter tw = new DirectoryTaxonomyWriter(taxoDir)) {
      Document doc = new Document();
      doc.add(new FacetField("d", "a", "x"));
      w.addDocument(config.build(tw, doc));
      try (DirectoryReader r = DirectoryReader.open(w);
          TaxonomyReader tr = new DirectoryTaxonomyReader(tw)) {
        FacetsCollector fc =
            new IndexSearcher(r).search(MatchAllDocsQuery.INSTANCE, new FacetsCollectorManager());
        TaxonomyFacetFloatAssociations sums =
            new TaxonomyFacetFloatAssociations(
                tr, config, fc, AssociationAggregationFunction.SUM, DoubleValuesSource.constant(1));
        // one document under d/a: expected count 1, actual 2 (the sub-tree is rolled up twice)
        assertEquals("count of d/a", 1, sums.getTopChildren(10, "d").labelValues[0].count);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-539/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/facet/build/libs/lucene-facet-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.facet.taxonomy.TestFloatValueSourceRollupCounts
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected LabelAndValue.count of d/a = 1 (one document under it). Actual: 2, because FloatTaxonomyFacets#updateValueFromRollup calls super (a full count roll-up) and then its own rollup, which rolls the sub-tree counts up again.

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

More files, environment, full output36 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: count of d/a expected:<1> but was:<2>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.facet.taxonomy.TestFloatValueSourceRollupCounts.testRolledUpCountOfIntermediateLabel(TestFloatValueSourceRollupCounts.java:56)

Why it looks unintended

  • LabelAndValue.count is the "Number of occurrences for this label"; since 9.11.0 counts are "always available" for taxonomy facets.
  • The code says hierarchical dimensions "are supported when using a value source"; FastTaxonomyFacetCounts gives 2 for the same hits.
  • Upstream testRollupValues runs this path but checks only toString(), which omits the count; a count assertion fails with 4.
The full argument322 words
  • Contract. LabelAndValue.java:27-28: count is the "Number of occurrences for this label". CHANGES.txt:2784 (9.11.0): "GITHUB#13414: Counts are always available in the result when using taxonomy facets." TaxonomyFacetFloatAssociations.java:42-44: a DoubleValuesSource "can dynamically supply a weight from each doc"; :164: "Hierarchical dimensions are supported when using a value source, so we need to rollup". No reading of "number of occurrences" gives 4 for two documents, or 3 for one.
  • Consistency. FastTaxonomyFacetCounts on the same hits gives 2; so do the same paths on a dimension that is also multi-valued (ancestors indexed, no roll-up), with the same value 9.0. The count should not depend on an indexing detail or on the facet class.
  • Tests. TestTaxonomyFacetValueSource#testRollupValues (:437-485) uses this exact setup (hierarchical dimension, value-source constructor, SUM and MAX) but compares only FacetResult.toString() (:467-469, :479-481); LabelAndValue.toString() prints label and value (LabelAndValue.java:44-47), and equals/hashCode ignore count (:49-60). With a count assertion added, it fails on main: count of 1 (6.0) expected:<2> but was:<4>. TestTaxonomyFacetAssociations#testAggregationCounts (:573-611) asserts counts that mean documents, on a flat dimension only.
  • History. #12966 ("always do counts", 2024-04) introduced three pieces together: the base TaxonomyFacets#updateValueFromRollup (counts of the whole sub-tree), the FloatTaxonomyFacets override that calls super, and a private recursion that calls the overridable method again for every child. Before it, the private float roll-up aggregated values directly, the shape the recommended fix restores. In its 41 comments none notices that the counts are rolled up more than once (the one roll-up thread is about sharing updateValueFromRollup between the Int and Float classes). Until #13414 exposed LabelAndValue.count (2024-05) the counts were only checked for > 0, which inflation cannot change short of the int overflow at extreme depths.
  • Precedent. #14008, wrong association-facet counts from a different cause, was fixed as a bug in 9.12.1 and 10.1.0 (CHANGES.txt:2141, :2510). This bug reproduces after that fix.
The devil's-advocate check175 words

The strongest "intended" case: the classes are @lucene.experimental; LabelAndValue.equals and toString leave count out, so it could look secondary; the base javadoc says "Overrides should probably call this to update the counts" (TaxonomyFacets.java:237-241) and the override does; LUCENE-5536 (#6599, 2014) said association facets should not roll up; and #11282 (counts from association facets) is still open.

Why it fails: the value-source path explicitly supports hierarchical dimensions, and LUCENE-5536 was about indexed association values, before this path and before counts existed. CHANGES promises counts, LabelAndValue itself is not experimental, and @lucene.experimental is about API stability, not wrong numbers. Leaving count out of equals (for backward compatibility, as #13414 says) and of toString explains why tests missed it, not what the number should be. Calling super is exactly what nests the two recursions; nothing documents a depth-dependent count. Verdict: a defect (adjudication: confirmed, high; every verifier rated the intended case weak; the only "not real" vote was a read-only review that could not run Java). It lowers the severity: only the count is wrong.

Suggested fix

Recommended Roll up only values in the private recursion

In the private roll-up of FloatTaxonomyFacets, and of its twin IntTaxonomyFacets, aggregate the values directly instead of calling updateValueFromRollup again; the super call already rolls the counts up once. The repro, upstream testRollupValues with count assertions and the whole lucene/facet suite (308 tests) pass, assertions on and off.

  private float rollup(int ord) throws IOException {
    ...
    while (ord != TaxonomyReader.INVALID_ORDINAL) {
-      updateValueFromRollup(ord, children.get(ord));
+      // Values only: super.updateValueFromRollup already rolled up the counts of this sub-tree.
+      setValue(ord, aggregationFunction.aggregate(getValue(ord), rollup(children.get(ord))));
       aggregatedValue = aggregationFunction.aggregate(aggregatedValue, getValue(ord));
       ord = siblings.get(ord);
     }
// the same line in IntTaxonomyFacets.rollup(int)
All options and trade-offs3 options · 653 words

Counts are then rolled up once (by the base class) and values once (by the private recursion), each walk visiting every node once, so the roll-up is linear again and the extreme-depth cost and overflow disappear (executed: 0.81 ms instead of 86.9 ms for one 4,095-level path; exact counts at 33 and 40 levels). Values are computed exactly as before. The IntTaxonomyFacets half changes nothing on main, where no class rolls it up, but keeps the two copies the same; a 9.x backport needs it for the deprecated TaxonomyFacetCounts. Add count assertions to testRollupValues either way. Workaround until a fix ships: take the counts from FastTaxonomyFacetCounts over the same FacetsCollector; this avoids the wrong counts, not the roll-up cost, which runs in the association facets' constructor. Only query-time numbers change, so no reindex is needed.

Roll up only values in the private recursion (validated by the checks) (recommended)

FloatTaxonomyFacets.java:117 and IntTaxonomyFacets.java:108: replace the call to the virtual updateValueFromRollup(ord, children.get(ord)) with the value aggregation it performed; the super call in updateValueFromRollup keeps rolling up the counts of the sub-tree once, as the base javadoc asks. +2/-1 in each file.

  private float rollup(int ord) throws IOException {
    ...
    while (ord != TaxonomyReader.INVALID_ORDINAL) {
-      updateValueFromRollup(ord, children.get(ord));
+      // Values only: super.updateValueFromRollup already rolled up the counts of this sub-tree.
+      setValue(ord, aggregationFunction.aggregate(getValue(ord), rollup(children.get(ord))));
       aggregatedValue = aggregationFunction.aggregate(aggregatedValue, getValue(ord));
       ord = siblings.get(ord);
     }
// the same line in IntTaxonomyFacets.rollup(int)

Trade-offs

  • The private recursion no longer goes through the overridable hook; only the dimension root does. Both classes are package-private and their in-tree subclasses do not override updateValueFromRollup, so no in-tree code depends on it. The public subclasses are not final and inherit the protected hook: an application subclass that overrides it would see it called only for dimension roots (the classes are @lucene.experimental).
  • The IntTaxonomyFacets change is latent on main; drop it for the smallest diff.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: the new test fails 2 of 2 on stock (assertions on and off) and passes 2 of 2 with the fix; testRollupValues with count assertions fails on stock and passes with the fix (with the rest of TestTaxonomyFacetValueSource, 12 of 12, on and off); the whole :lucene:facet:test, 40 suites and 308 tests, 0 failures, with assertions on (7 skipped) and off (8 skipped); :lucene:facet:check -x test (google-java-format, ecj lint and forbidden APIs) passes.
  • Standalone runner with the fix shadowed: the new test passes with -ea and -da; the cost and overflow probe gives exact counts at every depth and 0.81 ms for the 4,095-level path; on 9.12.3 the same line in its two classes makes this test and both deprecated-class probes pass (4 of 4; 4 of 4 fail stock). An independent reproducer had checked the float half with its own 4 tests.

Drop the super call and add the direct children's counts (validated by the checks)

The finder's sketch: after rollup(childOrdinal) has finished every child sub-tree, add the children's rolled-up counts to ordinal (+9/-1 in FloatTaxonomyFacets.java).

Trade-offs

Same results for the in-tree classes, also linear; it keeps the per-node calls to the protected hook. It goes against the base javadoc ("Overrides should probably call this to update the counts") and duplicates the count logic in the float class; IntTaxonomyFacets would need the same.

What the checks validated

The new test passes (standalone); the whole :lucene:facet:test, 40 suites and 308 tests, passes with assertions on; the finder's harness count mismatches go from 2,977 to 0 with it; 75 upstream facet tests passed with the unit's four fix sketches applied together. (The gradle log label fixA means this alternative fix.)

Workaround: counts from FastTaxonomyFacetCounts

Applications can read counts from FastTaxonomyFacetCounts over the same FacetsCollector and values from the association facets.

Trade-offs

A second facet pass per request; it avoids the wrong counts but not the roll-up cost; not a fix.

Downstream note

Products that let users create very deep category paths (folders, breadcrumbs) also pay a roll-up cost that grows with depth squared on every such facet request, 86.9 ms instead of 0.81 ms for one 4,095-level path, until they upgrade or cap path depth.

How it was found

An AI finder wrote a plain-language spec of the Facets API and a randomized metamorphic test harness over random facet schemas and tiny indexes. The relation "a label's count equals the matching documents under it" failed for value-source float associations; a bounded-exhaustive check pinned it to labels with children, and the finder reduced it to a JUnit test.

The method in full163 words

Method: randomized model-based metamorphic and differential testing (the hunt's "non-formal" tier; the finder called it semi-formal), a hunt unit (facet API metamorphic relations), 2026-10-26, stock main jars.

  • The finder wrote a Facets API specification and a harness that builds random facet schemas and tiny indexes and checks metamorphic relations. Relation F1d, "LabelAndValue.count equals the number of matching documents under the label, cross-checked with FastTaxonomyFacetCounts", failed on the value-source float association path: 1,490 SUM and 1,487 MAX mismatches in 400 iterations (seed 2, non-negative values), none with the fix.
  • A bounded-exhaustive check then ran every sequence of 1-3 documents over the 14 paths of depth 1-3 of one hierarchical dimension (438,872 checks): count mismatches only at labels with children, ratios such as 2/1, 4/2 and 9/3; 0 with the fix (the remaining mismatches are a separate MAX bug).
  • The finder reduced it to a public-API JUnit test with two documents. Then independent AI tools reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (best-effort search, 2026-10-26): the scripted prior-report search's 156 planned queries over GitHub, commits, downstream projects, four mailing lists, JIRA and the web; 27 manual GitHub and 13 list searches; and all 407 open PRs. Nearest: #14008, wrong association-facet counts from a different cause, fixed in 9.12.1 and 10.1.0.

Searches4 searches

No issue, PR, JIRA entry, list thread or web page found describes inflated counts from value-source association facets on hierarchical dimensions, or counts rolled up more than once. Upstream main, branch_10x, branch_10_6 and branch_9_12 still had the double roll-up on 2026-10-26, and no open PR changes it.

Nearest items, none of them this bug: #14008 / #14010 (wrong LabelAndValue.count from association facets on a flat dimension, ordinals read after getBulkPath sorted them; fixed in 9.12.1 and 10.1.0; this bug survives the fix); #12966, #13358 and #13414 (wrote and exposed the code; none notices that the counts are rolled up more than once); #11282 and #13740 (API discussion only); #13191 (childCount and values <= 0, opened before #12966); #6599 / LUCENE-5536 (2014, before counts existed); #13568 and #16122 (sandbox facet engine). Gaps: keyword search only; GitHub search was rate-limited; 3 planned web queries were not run; closed PR diffs and private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26three scripted prior-report search runs (48 issue and PR queries, 9 commit queries) and 27 manual searches: class and method names in apache/lucene and GitHub-wide, symptom phrasings, and full title listings of every facet-labelled item and every facet issue since 2024-05no report; the items listed above
Open PRs2026-10-26the changed files of all 407 open apache/lucene PRs (16 facet-related); the patches of the 4 that touch the fix-site files read#16847, #13948, #12862 and #12547 touch the files but not the count roll-up
Mailing lists and JIRA2026-10-2660 tool queries over dev@, java-user@, issues@ and commits@, 13 direct list queries, 12 JIRA-archive queriesno report
Downstream repositories and web2026-10-266 queries over Elasticsearch, OpenSearch and Solr; 9 web queries (3 more not run: rate limit)no report

History

Introduced by #12966 (2024-04, "always do counts"; 9.x backport #13358) and made visible by #13414, which added LabelAndValue.count; first released in 9.11.0. Every release from 9.11.0 to 10.5.2 and main fails the repro; 9.10.0 has no count field. Not fixed upstream.

Versions tested6 rows

Introduced by #12966 "Reduce duplication in taxonomy facets; always do counts" (@stefanvodita, 9ba4af7b883c on main, 2024-04-05; branch_9x backport #13358, 42884fab46de, 2024-05-14). It introduced three pieces together: the base TaxonomyFacets#updateValueFromRollup (counts of the whole sub-tree), the FloatTaxonomyFacets override that calls super, and a private recursion that calls the overridable method again for every child (the same shape in IntTaxonomyFacets). Before it, FloatTaxonomyFacets had no updateValueFromRollup and its private roll-up aggregated values only, the shape the recommended fix restores; git blame and git log -S find only these two commits. Made visible by #13414 "Allow users to retrieve counts from taxo association facets" (b3dc9153bb5d / 09302078a6eb, 2024-05-29), which added LabelAndValue.count; before it, counts were only checked for > 0. Both reached branch_9x before 9.11.0. FloatTaxonomyFacets.java last changed in #13422 (2024-05-27, an import move).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test: 2 of 2 fail (gradle and the standalone runner, assertions on and off): [4, 4] and [3, 3, 1]
10.5.2, 10.0.0, 9.12.3, 9.11.0 affectedthis doc's test: 2 of 2 fail, the same numbers
9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0-10.5.2, main ea57360912ed release sweepthe finder's test: count 4 instead of 2, value 9.0 right (9.12.3, 10.5.2 and main also with assertions off)
9.11.0, 9.12.3 9.x extrasdeprecated TaxonomyFacetSumValueSource: count 4, value 9.0; deprecated TaxonomyFacetCounts(OrdinalsReader): count 4, value 2
9.10.0 no APIthe test does not compile (no LabelAndValue.count); a version-neutral probe gets the right value
main and 9.12.3 + the fix not affectedevery repro passes; on main the whole lucene/facet suite passes

Independent checks

An independent reproducer, a devil's advocate and an adjudication found it real, each with its own executed test; read-only reviews by another model family agreed, and a third, which could not run Java, gave a procedural "not real", low. The adjudication confirmed it with high confidence; this doc's gradle runs reproduced it on main a9779ef3c193.

Checks and verdicts427 words

Every check that ran code used the stock jars, with assertions on and off; the reviews by the other model family ran read-only (they could not execute code), and a separate run re-ran the finder's test, which failed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnit fails on main (-ea, -da, 10 of 10 iterations), 10.5.2, 10.0.0, 9.12.3 and 9.11.0; with the fix it passes, and 75 upstream facet tests pass with the unit's four fix sketches together.
cross-model review, plain real (high)An independent reviewer from another model family (OpenAI GPT-5.6), read-only: inputs in contract, no intended argument, root cause agrees (traced 2 -> 4 by hand), the fix fixes it.
cross-model review, refute lens not real (low, procedural)Could not run Java, so "not reproduced" by construction; 9 of its 10 evidence items support real; a separate re-run of the finder's test failed as claimed.
Independent reproducer realOwn 4-test class (two documents, depth 3, dense and sparse random trials): 4 of 4 fail on main (-ea, -da), 10.5.2, 9.12.3 and 9.11.0; its own fix (the recommended one, float half) makes it and the finder's test pass.
Cross-model review, intended lens real (high)OpenAI GPT-5.6, read-only: the intended-behaviour argument is weak; a separate variant test fails on main and 10.5.2, including testRollupValues' data (counts 4 instead of 2), and passes with the fix.
Devil's advocate real (high)No doc, test or discussion makes the overcount intended; the multi-valued control gives the right count 2 with the same value.
adjudication confirmed (high)Re-ran the finder's test and the independent reproduction's test with a new seed (1 of 1 and 4 of 4 fail) and checked the trace by hand.
security assessment none (re-evaluated)Wrong counts plus a cost amplification that needs very deep paths, unbounded input the application must limit; no exposure outside the requester's hits (two-tenant probe). The registry's strict re-evaluation rates it out of scope of SECURITY.md.
registry re-runs reproducedFresh seeds on main: the finder's test fails with -ea and -da and passes with the fix; the independent reproduction's test fails 4 of 4 and passes with the finder's fix.
this doc's validation reproduced, fix validatedNew test: 2 of 2 fail on main (gradle and standalone, -ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.11.0; with the fix it passes, and so do testRollupValues with count assertions and the whole lucene/facet suite (308 tests), assertions on and off.

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

Finding record

LFV-539, snapshot of 2026-11-06

Registry title
TaxonomyFacetFloatAssociations with a DoubleValuesSource reports a too-high LabelAndValue.count for every label that has matching documents below it in a hierarchical dimension, because FloatTaxonomyFacets rolls the counts up again at every ancestor level (values are right; every release since 9.11.0)
Area
facet / taxonomy association facets (TaxonomyFacetFloatAssociations value-source path, FloatTaxonomyFacets#updateValueFromRollup count roll-up)
Priority
Medium, rank 172: Float association facets with a DoubleValuesSource inflate LabelAndValue.count on every hierarchical request, the same inflated-facet-count profile as LFV-151, LFV-433 and LFV-437, so it joins them.
Impact class
silent wrong results
Impact note
Applications that aggregate a per-document value (a price, a weight, a score) per category with TaxonomyFacetFloatAssociations and a DoubleValuesSource over a dimension declared hierarchical in FacetsConfig (a non-default setting; single-valued, the default for such a dim) and read LabelAndValue.count get a count that is too high for every label with matching documents below it (two documents under d/a reported as 4; one document at cat/a/b/x counted 3 times at a and at a/b), on every request, with the default codec, IndexWriterConfig and searcher, tiny indexes, -ea and -da, in every release from 9.11.0 to 10.5.2 and main; values, value ranking, childCount, leaf counts and FastTaxonomyFacetCounts are right, and only with very deep paths (about 33 levels with one document each) do the inflated int counts wrap and drop labels that have hits from the result.
Summary
You index documents with a FacetField in a dimension declared hierarchical (FacetsConfig.setHierarchical("d", true)), for example d/a/x with weight 7 and d/a/y with weight 2, and sum a per-document DoubleValuesSource per label with TaxonomyFacetFloatAssociations; you expect label a under d to report value 9 and count 2, the 'Number of occurrences for this label' that FastTaxonomyFacetCounts reports for the same hits. Instead getTopChildren and getAllChildren report a (9.0) with count 4, because FloatTaxonomyFacets#updateValueFromRollup first calls super.updateValueFromRollup, which rolls up the counts of the whole sub-tree, and then its own rollup(childOrdinal), which calls updateValueFromRollup again for every descendant, so the sub-tree counts are added once more at each ancestor level. Values and leaf counts are right; only LabelAndValue.count of labels with descendants is inflated, and the deeper the tree, the larger the overcount.
Introduced
9ba4af7b883c1c5cd3453dc60d8024851e658367 (apache/lucene#12966 'Reduce duplication in taxonomy facets; always do counts', by @stefanvodita, merged to main; branch_9x backport 42884fab46ded7c939c02668bfbdcdf8f05949a9, #13358, the line that shipped first) [read: git blame of FloatTaxonomyFacets.java:95-96 on main and 10.5.2 gives 9ba4af7b883c, of 9.12.3 :115-116 gives 42884fab46de, and git log -S 'super.updateValueFromRollup(ordinal, childOrdinal)' finds only these two; release bracket executed: 9.10.0 has no LabelAndValue.count, 9.11.0 fails]. It added the super call to FloatTaxonomyFacets#updateValueFromRollup, whose own private rollup(int) already recursed through the virtual updateValueFromRollup. The counts became user-visible with #13414 ('Allow users to retrieve counts from taxo association facets', main b3dc9153bb5d, branch_9x 09302078a6eb), which added LabelAndValue.count filled from getCount(ord); before that the counts were used only for count > 0 checks, which the inflation cannot change. (2024-04-05 (#12966 on main); 2024-05-14 (#13358 on branch_9x); counts exposed 2024-05-29 (#13414))
First affected version
9.11.0 [exec] (released 2024-06, the first release with both #13358 and #13414); not affected: 9.10.0 [exec: the repro does not compile, there is no LabelAndValue.count, and a version-neutral probe gets the right value; by code its FloatTaxonomyFacets kept no per-ordinal counts]; first 10.x release 10.0.0 [exec]
Affected versions
every release from 9.11.0 to 9.12.3 and from 10.0.0 to 10.5.2, plus main ea57360912ed; never fixed. [exec] the finder's JUnit, unchanged, fails with count 4 instead of 2 (value 9.0 right) on 9.11.0, 9.11.1, 9.12.0, 9.12.1, 9.12.2, 9.12.3 (-ea and -da), 10.0.0, 10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 (-ea and -da) and main (-ea and -da); the independent reproduction's JUnit fails 4 of 4 on main, 10.5.2, 9.12.3 and 9.11.0. [read] FloatTaxonomyFacets.java is unchanged on upstream main since 2024-05-27 (#13422, import only), and upstream main, branch_10x, branch_10_6 and branch_9_12 still have the double roll-up (2026-10-26). On 9.11.0-9.12.x only, the deprecated TaxonomyFacetCounts(OrdinalsReader, ...) also reports count 4 with value 2 for the same data [exec 9.11.0 and 9.12.3], because IntTaxonomyFacets#updateValueFromRollup has the same shape; that class was removed in 10.0.0, and on 10.x/main no in-tree class rolls IntTaxonomyFacets up
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, finder's run plus, read-only; the prior-report record, verdict new): three scripted prior-report searches (156 planned queries: 48 GitHub issue/PR on apache/lucene, 9 commit, 6 over Elasticsearch, OpenSearch and Solr, 60 mailing-list over dev@, java-user@, issues@ and commits@, 12 JIRA-archive, and web plans run as 9 general web queries, 3 more not run because of rate limiting), 27 manual GitHub searches (class and method names in apache/lucene and GitHub-wide, symptom phrasings, downstream repos, and full title listings of every facet-labelled item and every facet issue since 2024-05), 13 direct mailing-list queries, the changed files of all 407 open apache/lucene PRs (16 facet-related; the patches of the 4 that touch the fix-site files read) and live upstream branch contents. Related, not this bug: #14008 / #14010 (closest: wrong LabelAndValue.count from association facets on a flat dim; different root cause, ordinals read after getBulkPath sorted them in createFacetResult; fixed in 9.12.1 and 10.1.0, and this bug survives that fix); #12966 (introducing change; its body, 18 comments and 23 review comments never mention rolled-up counts), #13358 (backport), #13414 (exposed the counts; its tests cover flat dims only); #11282 (LUCENE-10246, open feature request for counts from association facets), #13740 (closed API redesign, no wrong counts), #13191 (open, childCount and values <= 0, opened before #12966), #6599 (LUCENE-5536, 2014: indexed associations should not roll up, before counts existed); #13568 and #16122 (sandbox facet engine, separate code); open PRs #16847, #13948, #12862 and #12547 touch the fix-site files but not the count roll-up. Not fixed upstream.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2, 10.0.0, 9.12.3 and 9.11.0 release jars (this doc's test; 9.10.0 has no count field). Earlier checks ran on main ea57360912ed, whose facet module is identical, and on every release from 9.11.0 to 10.5.2. The fix was validated in the scratch worktree: the new test, upstream testRollupValues with count assertions, the whole lucene/facet suite (40 suites, 308 tests) with assertions on and off, and :lucene:facet:check -x test.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/LabelAndValue.java:27 (at ea57360912ed) ('Number of occurrences for this label'), :44-47 (toString prints label and value only, so toString-based tests cannot see the count), :48-59 (equals and hashCode ignore count)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:94-100 (at ea57360912ed) (updateValueFromRollup: super.updateValueFromRollup at :96, then aggregate(currentValue, rollup(childOrdinal)) at :98), :112-122 (private float rollup(int): calls the virtual updateValueFromRollup for every child at :117)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:237-241 (at ea57360912ed) (javadoc: 'Overrides should probably call this to update the counts'), :242-244 (base updateValueFromRollup: setCount(ordinal, getCount(ordinal) + rollup(childOrdinal)), a count roll-up of the whole sub-tree), :261-285 (rollup(): every hierarchical single-valued dim of the FacetsConfig), :287-299 (private int rollup(int): recursive in-place count roll-up), :320-334 and :406-414 (getTopChildren and getAllChildren build LabelAndValue with getCount(ord))
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetFloatAssociations.java:36-49 (at ea57360912ed) (class javadoc: FacetField plus a DoubleValuesSource is a supported approach; @lucene.experimental), :130-166 (value-source aggregateValues: count +1 per hit at its indexed ordinal :158, then rollup() :165 after the comment 'Hierarchical dimensions are supported when using a value source, so we need to rollup' :164)
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java:170-177 (at ea57360912ed) (setHierarchical: 'Pass true if this dimension is hierarchical (has depth > 1 paths)'), :301-303 ('AssociationFacetField cannot be hierarchical'), :380-397 (only the leaf ordinal is indexed, plus the ancestors only for multi-valued dims that are hierarchical or require the dim count)
  • lucene/CHANGES.txt:2768 (at ea57360912ed) ('GITHUB#13414: Counts are always available in the result when using taxonomy facets'), :2799, :2843 and :2889 (GITHUB#12966 entries)
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestTaxonomyFacetValueSource.java:437-481 (at ea57360912ed) (testRollupValues: hierarchical dim with the value-source constructor, asserts only toString())
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:94-100, :112-122
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:237-244, :261-299, :320-334, :406-414
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetFloatAssociations.java:36-49, :99-109, :130-166
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java:170-177, :380-398; lucene/facet/src/java/org/apache/lucene/facet/LabelAndValue.java:27-28
  • lucene/CHANGES.txt:2784 (9.11.0, GITHUB#13414)
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetFloatAssociations.java:49 (@lucene.experimental); lucene/facet/src/java/org/apache/lucene/facet/FacetsConfig.java:301-303 (AssociationFacetField cannot be hierarchical)
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:272 (only hierarchical, single-valued dims are rolled up), :365, :379, :482, :495 (count > 0 filters)
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/LabelAndValue.java:27-28, :44-60; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacetFloatAssociations.java:42-44, :164
  • lucene/CHANGES.txt:2784 (GITHUB#13414), :2141 and :2510 (GITHUB#14008)
  • lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestTaxonomyFacetValueSource.java:437-485; lucene/facet/src/test/org/apache/lucene/facet/taxonomy/TestTaxonomyFacetAssociations.java:573-611
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:237-241
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/taxonomy/FloatTaxonomyFacets.java:112-122; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/IntTaxonomyFacets.java:95-113; lucene/facet/src/java/org/apache/lucene/facet/taxonomy/TaxonomyFacets.java:237-244

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

Repro files

About the diagrams

A light summary card, made in parallel with this doc from an executed per-call trace on main a9779ef3c193 (with assertions on and off, and with a fix as control); the PNG is the summary card.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.