All findings · Lucene finding · LFV-227 · join-sv-min-avg-drops-zero-score

JoinUtil's single-valued term join forgets a from-document that scored exactly 0, so ScoreMode.Min and ScoreMode.Avg give too-high, order-dependent scores

JoinUtil's single-valued term join forgets a from-document scoring exactly 0 (for Avg, only a leading run of zeros) when another with the same join value follows: scores 0 and 2 give 2 for both ScoreMode.Min and ScoreMode.Avg, not 0 and 1. It reads a running score of 0 as "not seen yet" (Avg since 4.0, Min since 5.2).

Summary

Priority
Low, rank 373Only scores and ranking change, and only in the rarely used single-valued term join with ScoreMode Min or Avg, which the major downstream engines route around.
Impact
silent wrong results
Since
4.0.0-ALPHA for Avg (LUCENE-4043, 2012); 5.2.0 for Minintroduced 2012-05-29 · over 14 years
Affected
Avg: 4.0.0-ALPHA to 10.5.2 and main; Min: 5.2.0 to 10.5.2 and main; 10.6.0 very likely too (read, not run)
Status
documented
Found by
code reading of the join score collectors

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 single-valued term join keeps one running score per join value, and uses "the running score is exactly 0" to mean "I have not seen this join value yet". A from-document that really scores 0 trips that test for the next document: for Min after any 0, for Avg while every earlier score of that join value was 0.

Diagram of the repro: two from-documents with join value x score 0 (FILTER-only match) and 2, in that order, and one to-document has ref:x. JoinUtil.createJoinQuery("join", false, "ref", fromQuery, searcher, ScoreMode.Min or Avg). The single-valued collector keeps one running score for x. After the first document it holds 0; Double.compare(existing, 0.0d) == 0 (TermsWithScoreCollector.java:137, :190) then reads the second document as the first, so Min becomes 2 instead of min(0, 2) = 0 and Avg becomes 2 / 1 = 2 instead of (0 + 2) / 2 = 1. The multi-valued join on the same documents gives 0 and 1. In the order [2, 0] both are right. Matches are unchanged; Max and Total are right. Affected: Avg since 4.0.0-ALPHA, Min since 5.2.0, to 10.5.2 and main.
The repro's join value x step by step: the single-valued collector's running score, the 0 it reads as "not seen yet", and the result next to the multi-valued join on the same documents. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You join with JoinUtil.createJoinQuery("join", false, "ref", fromQuery, searcher, ScoreMode.Min). Two from-documents share the join value x, and fromQuery scores them 0 (it matches only a FILTER clause) and 2, in that order.
  2. You expect the to-document with ref:x to score min(0, 2) = 0, or (0 + 2) / 2 = 1 with ScoreMode.Avg: the ScoreMode javadoc says "the min of all child scores" and "the average of all child scores", and the multi-valued join returns exactly 0 and 1 on the same documents.
  3. Instead both modes return 2. The single-valued collector keeps one running score per join value and treats "running score == 0" as "first document for this value". After the 0, the next document looks like the first: Min overwrites the 0, and Avg, while every earlier score of that join value was 0, restarts its sum and count.
  4. It depends on order: [2, 0] gives the right 0 and 1, [3, 0, 5] gives Min 5, [0, 0, 5] gives Min 5 and Avg 5 instead of 1.67. Which documents match never changes; Max, Total and the other join variants are correct.

Who is affected

Applications that call JoinUtil.createJoinQuery(fromField, false, ...) with ScoreMode.Min or Avg and a from query that scores some documents exactly 0: FILTER-only matches next to SHOULD clauses, FunctionScoreQuery over a 0 or missing value, a zero boost. Default settings, any index size. Scores and ranking come out silently too high; matches are unchanged.

Mechanism, terms and numbers739 words

JoinUtil.createJoinQuery(fromField, multipleValuesPerDocument=false, toField, fromQuery, fromSearcher, scoreMode) aggregates the scores of all from-documents that share a join value into one score for the matching to-documents. The single-valued collectors (TermsWithScoreCollector.SV and SV.Avg) keep one running score per join value and test Double.compare(existing, 0.0d) == 0 to decide "this is the first from-document for this join value". But 0 is a legal score: a document that matches only a FILTER clause next to SHOULD clauses scores 0, and so does a FunctionScoreQuery over a value that is 0 or missing. After such a document, the next from-document with the same join value is treated as the first: ScoreMode.Min overwrites the stored 0, and ScoreMode.Avg, while every earlier score of that join value was 0 (a leading run of zeros), restarts the sum and the count. With from scores 0 and 2 both modes return 2, where the ScoreMode javadoc and the multi-valued join on the same documents give 0 and 1. Which documents match does not change; only scores, and so ranking, are wrong, with legal non-negative scores always too high and never above the join value's Max score, and they depend on collection order. Max, Total and None are correct (apart from the sign of a zero), and the multi-valued, numeric and global-ordinals joins are not affected. Avg has been wrong since the scoring join was added in 4.0.0-ALPHA (2012), Min since it was added in 5.2.0 (2015), with assertions on or off. Using BytesRefHash.add's "new value" result as the first-occurrence signal fixes it (+4/-3).

Terms

  • Query-time join: JoinUtil.createJoinQuery(fromField, multipleValuesPerDocument, toField, fromQuery, fromSearcher, scoreMode) runs fromQuery, collects the join values of the matching from-documents from the doc values of fromField, and returns a query that matches the documents whose toField holds one of those values. With a ScoreMode other than None, each join value also gets one score aggregated from its from-documents, and a to-document gets the score of its join value.
  • Single-valued path: multipleValuesPerDocument=false reads SortedDocValues and uses TermsWithScoreCollector.SV (Min, Max, Total) or SV.Avg; true reads SortedSetDocValues and uses MV / MV.Avg (GenericTermsCollector.java:38-52, :116-130). The package example uses false for a single-valued from field (package-info.java:94).

The bug

  1. One slot per join value. SV.collect adds the from-document's value to a BytesRefHash (TermsWithScoreCollector.java:120); add returns an id >= 0 exactly when the value is new (BytesRefHash.java:314-316). New Min slots are filled with +Infinity, Max slots with -Infinity (TermsWithScoreCollector.java:127-131); Total and Avg slots start at 0 (:126, :183-184).
  2. The first-document test uses the score. SV.collect reads existing = scoreSums[ord] and, if Double.compare(existing, 0.0d) == 0 (:136-137), stores the current score as if it were the first (:138); otherwise it applies Total, Min (if (current < existing), :144-148) or Max. SV.Avg.collect does the same at :189-192 and resets the count to 1; the score is sum / count (:203).
  3. Min: the slot starts at +Infinity, so the test does not fire for the first document. It fires once the stored minimum is exactly 0, and the next score replaces it.
  4. Avg: with non-negative scores a sum of exactly 0 means every earlier score was 0, so a leading run of zeros is forgotten; a zero after a positive score is counted.
  5. Max, Total: 0 is their neutral value, so overwriting it changes nothing, apart from the sign of a zero: a +0.0 then a -0.0 (legal: AssertingScorable asserts only score >= 0) leaves -0.0, where Math.max, addition and the multi-valued join give +0.0 (executed); only Float.compare, which HitQueue uses to order hits (HitQueue.java:76), tells them apart. The test is a plain if, so -da changes nothing either.
  6. The multi-valued collectors in the same file have no such test: they take min/max directly against the ±Infinity fill or add (:236-250) and count every score (:277-278).

Executed examples (from scores in collection order)

from scoresMin: expected / single-valuedAvg: expected / single-valued
[0, 2]0 / 21 / 2
[2, 0]0 / 01 / 1
[3, 0, 5]0 / 52.6666667 / 2.6666667
[0, 0, 5]0 / 51.6666666 / 5
[0, 0]0 / 00 / 0

Max and Total are right in every row, and the multi-valued join gives the expected column everywhere.

Exposure and likelihood360 words
  • Who is hit. Callers of JoinUtil.createJoinQuery(fromField, false, toField, fromQuery, fromSearcher, ScoreMode.Min | ScoreMode.Avg) on a SORTED doc-values from field, which is what the package example uses for a single-valued field (package-info.java:94). Default codec, IndexWriterConfig and IndexSearcher; any index size; assertions on or off.
  • When. The from query scores a from-document exactly 0, and that document is collected before another from-document with the same join value (for Avg, before any positive score of that join value). Lucene produces 0 on purpose: FILTER clauses "do not participate in scoring" (BooleanClause.java:35); FunctionScoreQuery returns 0 for a "missing value, negative value or NaN" (FunctionScoreQuery.java:243-251); BoostQuery accepts a boost of 0 (BoostQuery.java:40-42). For example "rank products by their worst review" with a rating that is 0 or missing. Typical text-relevance from queries, for example a TermQuery under the default BM25 similarity, do not score exactly 0 and are unaffected.
  • What happens. Silent wrong scores, with legal non-negative scores always too high and never above the join value's Max score, so to-documents rank too high and a score threshold on the join lets the wrong ones through. Which documents match, deletions and FILTER / MUST_NOT restrictions are unchanged (the follow-up's probe: the match set always equalled a brute-force set). A concrete flip from the probe: product P has reviews [0, 0], Q has [3, 3, 3], and P ranks below Q. After one more review of P with rating 5, P's correct Min and Avg are 0 and 1.67 (the multi-valued join's values), but the single-valued join returns 5 for both, so P ranks above Q (3).
  • Order-dependent. Collection order is segment order, then document id, so the same from-documents indexed in another order give different scores.
  • Downstream (code reading, not run): Solr's score join passes multipleValuesPerDocument=true for term joins; Elasticsearch and OpenSearch has_child/has_parent use the global-ordinals variant; Yelp's nrtsearch CrossIndexQuery can reach the affected collector when a client picks a min or avg score mode on a single-valued field.
  • How common. A rarely used combination; the documented searches found no report since the code was added in 2012. For its users it is deterministic.

Minimal repro

The test shows that JoinUtil.createJoinQuery(..., multipleValuesPerDocument=false, ..., ScoreMode.Min) forgets a from-document that scored exactly 0: the single-valued collector treats a stored score of 0 as "join value not seen yet", so the next from-document with the same join value overwrites the minimum.

TestJoinMinZeroScore.java (JUnit test)71 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.search.join;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.BoostQuery;
import org.apache.lucene.search.ConstantScoreQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestJoinMinZeroScore extends LuceneTestCase {

  public void testMinKeepsZeroScore() throws Exception {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (boolean hot : new boolean[] {false, true}) {
          Document from = new Document();
          from.add(new StringField("tag", "from", Field.Store.NO));
          if (hot) {
            from.add(new StringField("tag", "hot", Field.Store.NO));
          }
          from.add(new SortedDocValuesField("join", new BytesRef("x")));
          w.addDocument(from);
        }
        Document to = new Document();
        to.add(new StringField("ref", "x", Field.Store.NO));
        w.addDocument(to);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query hot = new ConstantScoreQuery(new TermQuery(new Term("tag", "hot")));
        Query fromQuery =
            new BooleanQuery.Builder()
                .add(new TermQuery(new Term("tag", "from")), Occur.FILTER)
                .add(new BoostQuery(hot, 2f), Occur.SHOULD)
                .build();
        Query join =
            JoinUtil.createJoinQuery("join", false, "ref", fromQuery, searcher, ScoreMode.Min);
        // from scores [0, 2] for join value x: expected min 0.0, actual 2.0
        assertEquals(0f, searcher.search(join, 1).scoreDocs[0].score, 0f);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-227/, 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/join/build/libs/lucene-join-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$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.search.join.TestJoinMinZeroScore
Expected vs actual
Expected for two from-documents with join value x scoring 0 then 2 (the 0 is a FILTER-only match): the to-document scores min(0, 2) = 0.0; actual: expected:<0.0> but was:<2.0>, with -ea and -da.

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

More files, environment, full output34 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: expected:<0.0> but was:<2.0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:577)
       org.junit.Assert.assertEquals(Assert.java:701)
       org.apache.lucene.search.join.TestJoinMinZeroScore.testMinKeepsZeroScore(TestJoinMinZeroScore.java:67)

Why it looks unintended

  • The ScoreMode javadoc: "the min of all child scores", "the average of all child scores"; 0 is a legal score.
  • The multi-valued collectors in the same class count a 0, and TestJoinUtil checks both paths against one model.
  • The result flips with order: [0, 2] gives Min 2, [2, 0] gives 0.
The full argument253 words
  • The contract. ScoreMode.java:25-26: Avg is "Parent hit's score is the average of all child scores"; :34-35: Min is "Parent hit's score is the min of all child scores".
  • 0 is legal. AssertingScorable asserts score >= 0 (AssertingScorable.java:36); in the #13697 review a committer wrote "0 is a legal score (but negative values are not legal scores)".
  • The two paths are meant to agree. TestJoinUtil uses the multi-valued join at random for single-valued data, "single val can be handled by multiple-vals" (TestJoinUtil.java:1535-1536), against one reference model, JoinScore, which starts Min at +Infinity and counts every score for Avg (:2046-2081). The numeric and global-ordinals joins count a 0 too (the devil's-advocate check ran all four on the same data).
  • History shows a leftover marker. In LUCENE-4043 (2012) the score arrays started zeroed and 0 meant "not set yet". LUCENE-6389 (2015) added Min and pre-filled its slots with +Infinity, which only makes sense for a true minimum, but kept the 0 test in front of the switch.
  • Why tests never caught it. TestJoinUtil's term-join tests use TermQuery from queries (the random ones on value, TestJoinUtil.java:1524), which are not built to score 0, and no test mixes zero and positive scores for one join value on the single-valued term path. Its only from query meant to produce zeros, testMinMaxScore's score = price with price = random().nextInt(1000) (:508-585, :539), runs the global-ordinals join (:560-562, :572-574), which counts the 0.
The devil's-advocate check213 words

The strongest case for "intended": the JoinUtil javadoc says "only the score from the first encountered join value originating from the 'from' side is mapped into the 'to' side" (JoinUtil.java:68-74).

Why it fails: that sentence is about multipleValuesPerDocument=true and a document with several join values, not about adding up the from-documents of one join value, and this bug is on the false path. @lucene.experimental (JoinUtil.java:54) allows the API to change, not wrong arithmetic. "0 means it did not really match" fails too: a FILTER-only document is a match, and the zero is kept when it comes last. Weaker arguments fail too: the 2012 code may have assumed strictly positive scores, but no javadoc says so and Lucene produces exact zeros by design; ScoreMode speaks of parent and child hits because the block join shares it, while JoinUtil says it "Instructs how scores from the fromQuery are mapped to the returned query" (JoinUtil.java:86); exact zeros are rare and the code is old, which explains why nobody noticed, not that it is meant; the multi-valued path is a workaround, not a contract. The cross-model reviews and the independent reproducer rated the intended case weak; the devil's-advocate check and the adjudication found no intended argument that survives. Verdict: a defect.

Suggested fix

Recommended First occurrence from BytesRefHash.add (validated)

Use BytesRefHash.add's result as the first-occurrence signal: it is >= 0 exactly when the join value is new. In SV and SV.Avg, replace Double.compare(existing, 0.0d) == 0 with that flag (+4/-3 in TermsWithScoreCollector.java). The repro passes, and so do the 104 existing lucene/join tests.

int ord = collectedTerms.add(value);
final boolean firstOccurrence = ord >= 0;   // new
...
if (firstOccurrence) {                       // was: Double.compare(existing, 0.0d) == 0
  scoreSums[ord] = current;
  scoreCounts[ord] = 1;                      // SV.Avg only
} else {
  ...
All options and trade-offs2 options · 516 words

Two fixes were built and validated; the recommended one is the smallest. Whichever is chosen, add a from query that scores some documents exactly 0 to the join tests (the repro as a new test class is the smaller change; fix-with-test.diff = fix A + both repro tests). Workaround until a fix lands: call createJoinQuery with multipleValuesPerDocument=true on the same SORTED field; the multi-valued path wraps it as a single-valued set (DocValuesTermsCollector.java:49-51, DocValues.java:325-337) and counts every score (executed on the repro's documents: Min 0, Avg 1). Open PR #16805 ("Collector manager join", under review) moves this code into per-slice collectors and keeps the test, so the issue can point there and the fix lands once. Not part of this bug: a from-document without a join value is mapped to the empty term (TermsWithScoreCollector.java:118, :176), a separate bug.

First occurrence from BytesRefHash.add (validated) (recommended)

BytesRefHash.add returns an id >= 0 exactly when the join value is new (BytesRefHash.java:314-316). Keep that as firstOccurrence in SV.collect and SV.Avg.collect and test it instead of the score. 1 file, +4/-3.

int ord = collectedTerms.add(value);
final boolean firstOccurrence = ord >= 0;   // new
...
if (firstOccurrence) {                       // was: Double.compare(existing, 0.0d) == 0
  scoreSums[ord] = current;
  scoreCounts[ord] = 1;                      // SV.Avg only
} else {
  ...

Trade-offs

No extra work per document (a boolean instead of a double comparison); no behaviour change, for legal non-negative scores, when no from-document scores 0; the Min/Max ±Infinity fill becomes unnecessary but harmless. No benchmark was run.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock, the repro fails 2 of 2 and the order test 2 of 4, with assertions on and off; with the fix both pass, with assertions on and off.
  • With the fix: the whole lucene/join test task, repro classes included, 18 classes, 110 tests, 3 skipped, 0 failures (the 16 existing classes: 104 tests; TestJoinUtil 16 of 16); :lucene:join:check -x test (format, ecjLint, forbiddenApis) passes with the new tests in place.
  • Standalone runner with the fix shadowed: the independent reproducer's FunctionScoreQuery test 5 of 5, the devil's-advocate sibling-join comparison 4 of 4, the security probe in its strict mode (0 scores too high, against 854 of 1,698 on stock).
  • fix.diff and fix-with-test.diff apply cleanly to a9779ef3c193. Not run: the full Lucene test suite, nightly mode, a benchmark.

Accumulate like the multi-valued collectors (validated)

Drop the first-occurrence branch: Total adds, Min and Max use Math.min / Math.max against the existing ±Infinity fill, Avg adds and counts. fix-b-accumulate.diff, +18/-32.

Trade-offs

SV and MV then share one logic, the one TestJoinUtil's model checks. Larger diff; Math.min and < differ only for NaN, which is not a legal score, and for the sign of a zero.

What the checks validated

Gradle on a9779ef3c193: the two repro classes and TestJoinUtil, 22 of 22 pass with assertions on. Standalone runner with it shadowed: the repro with -da, the reproducer's and the devil's-advocate tests and the strict probe pass. Not run through the whole module task or :check.

Downstream note

By code reading, Solr's score join and Elasticsearch/OpenSearch has_child/has_parent use join variants this bug does not reach, while Yelp's nrtsearch CrossIndexQuery can reach it when a client picks a min or avg score mode on a single-valued field.

How it was found

AI-assisted code reading of the join module's score collectors, while checking a recent change to their accumulation precision, then a public-API JUnit with the multi-valued join as the reference. No formal tool flagged it, and no upstream test mixes zero and positive from scores on the single-valued term path.

The method in full119 words

Method: code reading (the hunt's non-formal tier), by the hunt unit for the join and block-join module, on main ea57360912ed, on 2026-10-26.

  • While reading the TermsWithScoreCollector accumulators for #16378 / #16428 (double-precision accumulation), the unit noticed that the single-valued collectors test the score, not BytesRefHash.add's result, for a first occurrence.
  • It wrote a public-API JUnit with the multi-valued join over the same documents as an in-test reference: 2 of 2 fail on main with assertions on and off, on 10.5.2 and on 9.12.3; a fix sketch makes it pass.
  • The unit's block-join reference harness does not cover JoinUtil's term join, and TestJoinUtil's random from queries are TermQuery hits that are not built to score 0.

Prior reports, history and checks

Prior reports

No prior report found (217 queries, 2026-10-26: GitHub issues, PRs and commits, review threads, the Lucene and Solr lists, JIRA, downstream repositories and the web; 27 hits read). Closest: #16378, which rewrote the same two lines for precision and kept the test.

Searches4 searches

No issue, PR, review comment, JIRA ticket, list thread or web page describes zero scores being dropped by the single-valued join.

Closest, not this bug: #16378 (float to double on the same two lines, no word on zero scores); LUCENE-6389 / #7449 (its "all scores happened to be 0" remark is about the missing +Infinity fill, fixed there); LUCENE-4043 / #5115 (introduced the code). The outside Go port Gocene copies the test as isUnsetScore; its comment covers only -0.0 and NaN.

Not fixed upstream: main, branch_10x and branch_10_6 still have the test at lines 137 and 190; the open PRs #16805 and #13747 move or wrap the code and keep it.

Gaps: GitHub search does not index review comments (threads were read for the PRs listed), JIRA text search is word-based, list search covers lists.apache.org only, and private security reports and chat channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream repositories; dev@, java-user@, issues@ and commits@ lists; LUCENE JIRA archive2026-10-26a scripted prior-report search, 3 runs: 118 queries (identifiers such as TermsWithScoreCollector, getScoresPerTerm, scoreSums; symptoms such as "zero score", "average score"; history; Solr score join)only word-overlap hits; every high and medium hit read, none about zero scores
GitHub (apache/lucene, apache/solr, Elasticsearch, OpenSearch, apache/lucenenet)2026-10-2635 manual searches, for example "existing, 0.0", "score of 0" join, "join min score zero", "first occurrence", the module:join labelno report
Open apache/lucene PRs and review threads2026-10-26changed-file lists of all 413 open PRs; review threads of #16378, #16428, #16805, #13747, #16858 and #13697#16805 and #13747 touch the file and keep the test; no discussion of zero scores
SOLR and LUCENE JIRA; Lucene and Solr lists; web2026-10-2614 JIRA queries, 38 list queries, 12 web searchesno report

History

Avg has been wrong since the scoring join was added (LUCENE-4043, 36acada7629, 2012, first in 4.0.0-ALPHA), Min since it was added in 5.2.0 (LUCENE-6389, 07beb24d93c). It fails on every release tried from 4.0.0-ALPHA to 10.5.2 and on main.

Versions tested7 rows

Introduced by LUCENE-4043 "Added scoring support via score mode for query time joining" (36acada76293, 2012-05-29, svn r1343966; JIRA era, migrated as #5115; branch_4x 3b51274435e). It used Float.compare(existing, 0.0f) == 0 as the first-occurrence test while the score arrays started zeroed.

Min: LUCENE-6389 (07beb24d93c, 2015-04-19, #7449; branch_5x 87da9f4c64e, first in 5.2.0) pre-filled Min slots with +Infinity and kept the test in front of the switch. Multi-valued path fixed as an apparent side effect: the LUCENE-4765 doc-values rewrite (f2798476cd1, merged as bb1f4d0c61b, first in 4.2.0) made the multi-valued collectors accumulate directly. Kept by #16378 (d96acfdfc09, 2026-07-09), float to double. All 123 release tags from 4.0.0-alpha on in the history clone have the test (8.8.2 to 8.11.3 are not tags there and were not run; 8.11.4 fails); branch_10x and branch_10_6 too.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe repro 2 of 2 and the order test 2 of 4 fail (gradle, assertions on and off)
main ea57360912ed affectedthe hunt's runs: repro 2 of 2 (-ea and -da, three seeds); the verifiers' tests
9.0.0, 9.12.3, 10.0.0, 10.5.2 affectedAvg and Min wrong; 9.12.3 and 10.5.2 also with the JUnit repro, -ea and -da
5.2.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.0.0, 8.11.4 affectedAvg and Min wrong (version-portable ports)
4.0.0-ALPHA, 4.0.0, 4.1.0, 4.2.0, 4.10.4, 5.0.0, 5.1.0 Avg onlyAvg wrong; no ScoreMode.Min yet; up to 4.1.0 the multi-valued Avg is wrong too
3.6.2 API absentno scoring join (compile error)
main + fix A not affectedrepro 2 of 2, order test 4 of 4, the lucene/join tests pass

Independent checks

An independent reproducer, reviews by another model family (refute and works-as-intended lenses), a devil's-advocate check and an adjudication all found it real; their tests fail on main with assertions on and off and pass with the fix. This doc's gradle runs on the newer main reproduced it again.

Checks and verdicts425 words

Every executed check ran on stock jars, with assertions on and off where noted. The hunt ran on main ea57360912ed; this doc's runs are on a9779ef3c193, where nothing under lucene/join changed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateCode reading, then a public-API JUnit: 2 of 2 fail on main (-ea and -da), 10.5.2 and 9.12.3; a fix sketch passes 2 of 2.
cross-model review, refute lens real (high); intended argument weakOpenAI GPT-5.6, executed: its own test with from scores {0, 4} fails (2 of 4, and 2 of 3 with -da); a strictly positive {1, 4} control and a high-before-zero order control pass; with the fix its tests pass 4 of 4 and upstream TestJoinUtil 16 of 16.
Independent reproducer realOwn public-API test with a different trigger, FunctionScoreQuery over a weight that is 0 or missing: 2 of 5 fail with -ea and -da; 40 of 100 over 20 randomized iterations (exactly the two bug tests each time); 10.5.2 and 9.12.3 fail the same; the fix passes 5 of 5.
Cross-model review, works-as-intended lens real (medium); intended argument weakOpenAI GPT-5.6, reading only (its tool could not run Java). Its strongest points, @lucene.experimental and 14 years without change, do not make 0 an illegal score. A separate re-run's re-runs of the repro fail 2 of 2 on main -ea, -da and 10.5.2.
Intended-behaviour devil's advocate real (high)No intended, documented or misuse argument survives. Its test compares the single-valued term join with the multi-valued term, global-ordinals and numeric joins on children [0, 5] and [5, 0]: only the single-valued term join is wrong (Min 5.0, Avg 5.0 for [0, 5]); 2 of 4 fail on main and 10.5.2.
adjudication confirmed (high), silent wrong resultsRe-ran the independent reproduction's test with a fresh seed (2 of 5 fail, -ea and -da) and the finder's repro (2 of 2 fail).
registry and refresh re-runs still reproducesRegistration check: the repro fails 2 of 2 with -ea and -da and passes with the fix; the independent reproduction's test fails 2 of 5. Main refresh: 2 of 2 fail on a9779ef3c193, messages byte-identical.
this doc's runs reproduced; fixes validatedStock a9779ef3c193: the repro 2 of 2 and the order test 2 of 4 fail (gradle, assertions on and off); the reproducer's and the devil's-advocate tests fail again. Fix A: all pass, the lucene/join task 110 tests with 0 failures, :check passes; fix B: the repro tests and TestJoinUtil pass.

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

Finding record

LFV-227, snapshot of 2026-11-06

Registry title
JoinUtil's single-valued term join forgets a from-document that scored exactly 0, so ScoreMode.Min and ScoreMode.Avg give too-high, order-dependent scores
Area
join / JoinUtil query-time term join (TermsWithScoreCollector.SV and SV.Avg, ScoreMode Min/Avg)
Priority
Low, rank 373: Only scores and ranking change, and only in the rarely used single-valued term join with ScoreMode Min or Avg, which the major downstream engines route around.
Impact class
silent wrong results
Impact note
Applications that call JoinUtil.createJoinQuery(fromField, multipleValuesPerDocument=false, toField, fromQuery, fromSearcher, ScoreMode.Min or ScoreMode.Avg) on a SORTED doc-values from field get too-high scores, and therefore wrong ranking, for to-documents whose join value has a from-document scoring exactly 0 that is collected before another from-document with the same value (a FILTER-only match next to SHOULD clauses, a FunctionScoreQuery over a field that is 0 or missing, a zero boost), with the default codec, IndexWriterConfig and IndexSearcher, -ea and -da, at any index size, in every release since 4.0.0-ALPHA (Avg) and 5.2.0 (Min); which documents match is unchanged, Max, Total and None are correct, and the multi-valued, numeric and global-ordinals joins are not affected; Solr's score join, Elasticsearch and OpenSearch has_child/has_parent use unaffected variants (code reading).
Summary
You build JoinUtil.createJoinQuery("join", false, "ref", fromQuery, searcher, ScoreMode.Min) where two from-documents share the join value x and fromQuery scores them 0 and 2 (one matches only a FILTER clause), and expect the to-document with ref x to score min(0, 2) = 0, or (0 + 2) / 2 = 1 with ScoreMode.Avg, because the ScoreMode javadoc says 'the min of all child scores' and 'the average of all child scores' and the multi-valued join returns exactly that on the same documents. Instead both modes return 2: the single-valued collector uses an accumulated score of exactly 0 to mean 'this join value has not been seen yet', so after a from-document that scored 0, the next from-document with the same value overwrites the stored minimum (Min) or restarts the sum and count (Avg). Which documents match does not change; only scores and ranking are wrong, and they depend on collection order (scores [0, 2] go wrong, [2, 0] come out right).
Introduced
36acada76293302d83cab60c086a70f8c17e17e6 (trunk, svn r1343966, 'LUCENE-4043: Added scoring support via score mode for query time joining', JIRA era, migrated as apache/lucene#5115, no pull request; branch_4x backport 3b51274435e, svn r1343982, the same day) for ScoreMode.Avg: it added TermsWithScoreCollector with 'Float.compare(existing, 0.0f) == 0' as the first-occurrence test while the score arrays started zeroed. ScoreMode.Min since 07beb24d93cdc70d6c1f6934faf8387c5cd07ed7 (LUCENE-6389, #7449, 'Added ScoreMode.Min', 2015-04-19; branch_5x backport 87da9f4c64e), which pre-filled the Min slots with +Infinity but kept the 0.0 test in front of the switch [read: git log --follow, git log --all -S and git blame in the full-history clone; exec: the scoring join API is absent in 3.6.2 (compile error) and the bug is present from 4.0.0-ALPHA; Min first fails in 5.2.0]. Kept by d96acfdfc09 (#16378, 2026-07-09, float to double on the same two lines). The multi-valued collectors lost the same test as a side effect of the LUCENE-4765 SortedSetDocValues rewrite (f2798476cd1, merged to branch_4x as bb1f4d0c61b, 2013, first released in 4.2.0), which is why the two paths disagree today (2012-05-29 (ScoreMode.Avg, LUCENE-4043); 2015-04-19 (ScoreMode.Min, LUCENE-6389))
First affected version
4.0.0-ALPHA [exec] for ScoreMode.Avg (first GA release 4.0.0 [exec]); 5.2.0 [exec] for ScoreMode.Min, the first release that has it; the scoring join API does not exist in 3.6.2 or any 3.x release [exec: compile error]
Affected versions
ScoreMode.Avg: every release from 4.0.0-ALPHA to 10.5.2 (the last before 10.6.0) and main ea57360912ed; ScoreMode.Min: every release from 5.2.0 to 10.5.2 and main [exec: version-portable ports fail on 4.0.0-ALPHA, 4.0.0, 4.1.0, 4.2.0, 4.10.4, 5.0.0, 5.1.0 (Avg; Min absent), 5.2.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main with -ea; the canonical JUnit fails 2 of 2 with -ea and -da on 9.12.3, 10.5.2 and main; the independent reproduction's and the intended-behaviour check's own tests fail on 10.5.2, the independent reproduction's also on 9.12.3; read: all 123 release tags in the history clone from 4.0.0-alpha to 8.8.1 and 9.0.0 to 10.5.2 have the single-valued 0.0 test; 8.8.2 to 8.11.4 are not tagged in the clone, and 8.11.4 was executed]; in 4.0.0-ALPHA to 4.1.0 the multi-valued join's Avg was wrong too [exec]; branch_10x and branch_10_6 still have the test at lines 137 and 190 [read through the upstream contents API], so 10.6.0 (released 2026-10-29) is very likely affected (not executed); not fixed in any release or on main
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record: 217 logged read-only queries, 118 through scripted prior-report searches A-C over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the LUCENE JIRA archive, and 99 manual ones: 35 GitHub issue, PR and commit searches over apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and apache/lucenenet, 14 SOLR and LUCENE JIRA queries, 38 on the Lucene and Solr lists and 12 web searches; plus the changed-file lists of all 413 open apache/lucene PRs and the review threads of #16378, #16428, #16805, #13747, #16858 and #13697; 27 hits read, none the same bug); not fixed upstream (main, branch_10x and branch_10_6 still have 'Double.compare(existing, 0.0d) == 0' at TermsWithScoreCollector.java:137 and :190); the only open PRs that change TermsWithScoreCollector.java, #16805 ('Collector manager join', msokolov, under active review) and #13747 (stale), move or wrap this code and keep the test; closest history, not reports: #16378 (float to double on the same two lines, merged for 10.6.0, no mention of zero scores), LUCENE-6389 / #7449 (added Min and the +Infinity fill, kept the test) and LUCENE-4043 / #5115 (introduced it); check (2026-10-26, read-only GitHub contents API): the outside Go port FlavioCFOliveira/Gocene copies the test as isUnsetScore with a comment on its -0.0 and NaN semantics, but does not say that a real 0 score collides with it and has no issue about it, so it is not a report
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree, with assertions on and off, and through the standalone JUnit runner. The hunt and its reviews ran on main ea57360912ed; nothing under lucene/join changed between the two commits, and the finder's repro gives byte-identical failures on both. Release jars 10.5.2 and 9.12.3 (the repro), and version-portable ports on 3.6.2 (API absent) and 18 releases from 4.0.0-ALPHA to 10.5.2 plus main. Fix A was validated in the scratch worktree: both repro tests with assertions on and off, the whole lucene/join test task (110 tests) and :lucene:join:check -x test; fix B with the repro tests and upstream TestJoinUtil; both also through the standalone runner against every verifier test and the security probe.

Lucene and upstream sources

Finding record
  • lucene/join/src/java/org/apache/lucene/search/join/TermsWithScoreCollector.java:31 (INITIAL_ARRAY_SIZE = 0), :106-157 (SV; term added at :120, +Infinity fill for Min at :128, test at :137), :162-205 (SV.Avg; term added at :178, test at :190, count reset at :192), :211-289 (MV and MV.Avg: no such test)
  • lucene/join/src/java/org/apache/lucene/search/join/ScoreMode.java:25-35 (Avg 'the average of all child scores', Min 'the min of all child scores'); JoinUtil.java:54 (@lucene.experimental), :69-86 (multipleValuesPerDocument and 'first encountered join value' text)
The finding in 30 seconds
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/TermsWithScoreCollector.java:120, 126-131, 135-160, 178-196, 203, 236-250, 277-278
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/GenericTermsCollector.java:38-52, 116-130; JoinUtil.java:434, 447-455; package-info.java:94
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/BytesRefHash.java:310-320 (add contract)
Who is affected
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:35, BoostQuery.java:40-42; lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-251; lucene/join/src/java/org/apache/lucene/search/join/package-info.java:94
Why it looks unintended
  • a Lucene main checkout lucene/join/src/java/org/apache/lucene/search/join/ScoreMode.java:25-26, 34-35; JoinUtil.java:54, 68-74, 86
  • a Lucene main checkout lucene/join/src/test/org/apache/lucene/search/join/TestJoinUtil.java:508-585, 1524, 1535-1536, 2046-2081; lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorable.java:36

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

Repro files

About the diagrams

Made in parallel with this doc from the same repro and checked against the code, the registry row and the executed diagram probe; the summary card's "why" box was corrected after that check (Min restarts after any 0, Avg only while every earlier score was 0) and the PNG were re-rendered.

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.