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.
- You join with
JoinUtil.createJoinQuery("join", false, "ref", fromQuery, searcher, ScoreMode.Min). Two from-documents share the join value x, andfromQueryscores them 0 (it matches only a FILTER clause) and 2, in that order. - You expect the to-document with
ref:xto score min(0, 2) = 0, or (0 + 2) / 2 = 1 withScoreMode.Avg: theScoreModejavadoc 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. - 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.
- 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.
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).
- Query-time join:
JoinUtil.createJoinQuery(fromField, multipleValuesPerDocument, toField, fromQuery, fromSearcher, scoreMode)runsfromQuery, collects the join values of the matching from-documents from the doc values offromField, and returns a query that matches the documents whosetoFieldholds one of those values. With aScoreModeother thanNone, 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=falsereadsSortedDocValuesand usesTermsWithScoreCollector.SV(Min, Max, Total) orSV.Avg;truereadsSortedSetDocValuesand usesMV/MV.Avg(GenericTermsCollector.java:38-52,:116-130). The package example usesfalsefor a single-valued from field (package-info.java:94).
- One slot per join value.
SV.collectadds the from-document's value to aBytesRefHash(TermsWithScoreCollector.java:120);addreturns 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). - The first-document test uses the score.
SV.collectreadsexisting = scoreSums[ord]and, ifDouble.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.collectdoes the same at:189-192and resets the count to 1; the score issum / count(:203). - 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.
- 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.
- 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:
AssertingScorableasserts onlyscore >= 0) leaves -0.0, whereMath.max, addition and the multi-valued join give +0.0 (executed); onlyFloat.compare, whichHitQueueuses to order hits (HitQueue.java:76), tells them apart. The test is a plainif, so-dachanges nothing either. - 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).
| from scores | Min: expected / single-valued | Avg: expected / single-valued |
|---|---|---|
| [0, 2] | 0 / 2 | 1 / 2 |
| [2, 0] | 0 / 0 | 1 / 1 |
| [3, 0, 5] | 0 / 5 | 2.6666667 / 2.6666667 |
| [0, 0, 5] | 0 / 5 | 1.6666666 / 5 |
| [0, 0] | 0 / 0 | 0 / 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 aSORTEDdoc-values from field, which is what the package example uses for a single-valued field (package-info.java:94). Default codec,IndexWriterConfigandIndexSearcher; 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);FunctionScoreQueryreturns 0 for a "missing value, negative value or NaN" (FunctionScoreQuery.java:243-251);BoostQueryaccepts 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 aTermQueryunder 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=truefor term joins; Elasticsearch and OpenSearchhas_child/has_parentuse the global-ordinals variant; Yelp's nrtsearchCrossIndexQuerycan 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.
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.
/*
* 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);
}
}
}
}
# 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-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- The
ScoreModejavadoc: "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
TestJoinUtilchecks 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.
AssertingScorableassertsscore >= 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.
TestJoinUtiluses 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 useTermQueryfrom queries (the random ones onvalue,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 withprice = 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.
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.
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/jointest task, repro classes included, 18 classes, 110 tests, 3 skipped, 0 failures (the 16 existing classes: 104 tests;TestJoinUtil16 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
FunctionScoreQuerytest 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.diffandfix-with-test.diffapply cleanly toa9779ef3c193. Not run: the full Lucene test suite, nightly mode, a benchmark.
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.
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.
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
TermsWithScoreCollectoraccumulators for #16378 / #16428 (double-precision accumulation), the unit noticed that the single-valued collectors test the score, notBytesRefHash.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, andTestJoinUtil's random from queries areTermQueryhits that are not built to score 0.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; downstream repositories; dev@, java-user@, issues@ and commits@ lists; LUCENE JIRA archive | 2026-10-26 | a 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-26 | 35 manual searches, for example "existing, 0.0", "score of 0" join, "join min score zero", "first occurrence", the module:join label | no report |
| Open apache/lucene PRs and review threads | 2026-10-26 | changed-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; web | 2026-10-26 | 14 JIRA queries, 38 list queries, 12 web searches | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the repro 2 of 2 and the order test 2 of 4 fail (gradle, assertions on and off) |
main ea57360912ed | affected | the 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 | affected | Avg 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 | affected | Avg 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 only | Avg wrong; no ScoreMode.Min yet; up to 4.1.0 the multi-valued Avg is wrong too |
| 3.6.2 | API absent | no scoring join (compile error) |
| main + fix A | not affected | repro 2 of 2, order test 4 of 4, the lucene/join tests pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Code 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 weak | OpenAI 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 | real | Own 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 weak | OpenAI 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 results | Re-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 reproduces | Registration 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 validated | Stock 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. |
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 533 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,398 words
- 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 mainea57360912ed; nothing underlucene/joinchanged 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 wholelucene/jointest task (110 tests) and:lucene:join:check -x test; fix B with the repro tests and upstreamTestJoinUtil; both also through the standalone runner against every verifier test and the security probe.
- 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-278a 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:94a 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, 86a 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
- TestJoinMinZeroScore.java (JUnit test):
repro/LFV-227/src/org/apache/lucene/search/join/TestJoinMinZeroScore.java(71 lines)
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.
assets/LFV-227/bug.png(533 KB)
![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.](../assets/LFV-227/bug.png)