One float sum, added in an order that changes from document to document, gives identical documents different last digits.
SynonymQuerytreats several terms as one: for each document it adds up the terms' frequencies, each multiplied by its boost (a number in (0, 1]), and asks the similarity (BM25 by default) for one score from that sum.- A Java float keeps about 7 significant digits, so every addition rounds, and the result can depend on the order: in float, (0.2 + 0.2) + 0.3 is 0.70000005, while (0.2 + 0.3) + 0.2 is 0.7.
- To find which terms a document contains, the scorer keeps the terms' postings in a heap. The order in which it lists the matching terms follows the heap's layout at that moment, which depends on the documents it visited before.
Synonym(a^0.2, b^0.2, c^0.3): added as (0.2 + 0.2) + 0.3 the frequency is 0.70000005 and the score 0.038817033, added as (0.2 + 0.3) + 0.2 it is 0.7 and the score 0.038817026. Select the still to open it at full size. Generated with AI, checked against the code.- You weight synonyms, e.g.
Synonym(a^0.2, b^0.2, c^0.3), built withSynonymQuery.Builder#addTerm(term, boost)or byQueryBuilderfrom tokens that carry a boost. The query scores a document from one sum: 0.2 x freq(a) + 0.2 x freq(b) + 0.3 x freq(c). - Index four identical documents
a b cand search with the default BM25. You expect four equal scores, hits in doc-id order, andexplain()equal to each hit's score. - You get docs 0 and 3 at 0.038817033 and docs 1 and 2 at 0.038817026, so doc 3 ranks above docs 1 and 2, and
explain()reports 0.038817033 for all four. - Why: the scorer adds the boosted freqs in float, in the order its internal heap lists the terms, which depends on the documents visited before. In float, (0.2 + 0.2) + 0.3 = 0.70000005 but (0.2 + 0.3) + 0.2 = 0.7.
explain()starts a fresh scorer and can add in another order. - Rarely, with more than 2^24 (16.7 million) synonym occurrences in one field and ClassicSimilarity, the float sum also rounds above the max-score bound: a document scores 1.032795 while
getMaxScore()says 1.0327948, and pruned and exhaustive top-1 differ. Which documents match never changes.
Applications that run a scoring SynonymQuery with three or more terms and boosts below 1, from SynonymQuery.Builder or a QueryBuilder-based parser fed tokens with boosts (since 8.5.0), with the default codec, BM25 and searcher. In the study's probe 568 of 729 one-decimal boost triples hit it (one 60-document corpus), but the effect is 1-2 ulps; matching never changes.
Mechanism, terms and numbers705 words
SynonymQuery scores "the sum of all term frequencies for the document", each frequency multiplied by its per-term boost. SynonymScorer.freq() adds boost * freq in a float running sum, in the order of the disjunction's topList(), and that order follows the heap layout, which depends on how the iterator reached the document. Float addition is not associative, so with three or more terms whose boosted freqs do not add exactly, the same document gets slightly different scores on different paths: identical documents in one search score 1-2 ulps apart and come back out of doc-id order, and explain(), which builds a fresh scorer and calls advance(doc), reports a value the hit did not get. Separately, the max-score bound comes from the merged impacts, an exact integer sum of per-norm impact freqs (boosted ones rounded up); above 2^24 occurrences in one field the float sum can round above it, so score() exceeds getMaxScore() and pruned and exhaustive top-k disagree. It came with per-term boosts in 8.1.0 (LUCENE-8652) and is in every release up to 10.6.0 and main. CombinedFieldQuery has the same float sum (LFV-277, merged here). Summing in double and rounding once removes both symptoms in every executed probe.
SynonymScorer.freq()walksdisjunctionDisi.topList()and keeps a float running sum:float freq = w.freq(); ... freq += w.freq();(SynonymQuery.java:610-617at645d4fe14a20), whereDisiWrapperFreq.freq()is the float productboost * pe.freq()(:659-661).topList()returns the lead iterators' list or prepends the matching other iterators (DisjunctionDISIApproximation.java:196-218);DisiPriorityQueueN.topList()walksheap[0], then theheap[1]andheap[2]subtrees (DisiPriorityQueueN.java:71-84). The order is the heap layout, a product of the iteration history.- A search drives the scorer with
nextDoc().SynonymWeight.explainbuilds a fresh scorer and callsadvance(doc)(SynonymQuery.java:246-249), thenfreq()(:253). The two paths can add the same three products in different orders. - It needs at least three of the query's terms matching the same document (two float operands commute) and boosted freqs that do not add exactly. With all boosts 1 and a summed freq below 2^24 the sums are exact.
- Minimal input (four identical documents
a b c, BM25,Synonym(a^0.2, b^0.2, c^0.3)): thenextDoc()path scores doc0 = doc3 = 0.038817033 and doc1 = doc2 = 0.038817026 (freq 0.7); a freshadvance(doc)scores all four 0.038817033 (freq 0.70000005). The exact sum of the three floats, 0.7000000178813934..., lies exactly halfway between the floats 0.7 and 0.70000005; rounded once (the fix) it is 0.70000005, and all four hits score 0.038817033. - How common in its setup: three-term queries built through
QueryBuilderfrom tokens with one-decimal boosts 0.1..0.9 (all 729 triples), 60 documents, BM25: 568 of 729 failCheckHits.checkExplanations, the first beingSynonym(f:a^0.1 f:b^0.1 f:c^0.1); 0 with the fix.
mergeImpacts bounds each norm's frequency with the exact long sum of the (ceil-boosted) impact freqs (SynonymQuery.java:503, :543-564), and MaxScoreCache converts that int to float once (MaxScoreCache.java:80). The scorer converts each freq to float first and sums in float, which above 2^24 can round above the bound. ClassicSimilarity, boosts 1, 600 documents: doc 300 holds a x (2^24+3) and b x 3; (float) (2^24+3) is 2^24+4, and adding 3 rounds to 2^24+8, while the bound is the exact 2^24+6. Doc 300 scores 1.032795 > getMaxScore(511) = 1.0327948; a pruned top-1 search returns doc 0 (a x (2^24+6), 1.0327948) and an exhaustive one doc 300. Under exact arithmetic the two tie, so the pruned answer is the exact one; the defect is the broken bound and the disagreement. In the tested BM25 settings (k1=1.2/b=0.75 and k1=1e7/b=1) a scan of freqs 2^24..2^24+63 plus 1..7 found no score change, under that probe's statistics.
CombinedFieldScorer.freq() sums freq * weight in float over the same kind of topList() (CombinedFieldQuery.java:424-434), and its explain also uses a fresh advance(doc) (:323-326). With 3 or more matching fields and non-dyadic weights such as 1.1 and 1.2, explain() can differ from the hit by 1 ulp; no ranking change was shown there. Its if (freq < 0) // overflow check, inherited from the old int loop, cannot fire for a float sum.
Exposure and likelihood230 words
- Who is exposed. A scoring
SynonymQuerywith three or more terms matching the same document and per-term boosts below 1: fromSynonymQuery.Builder#addTerm(term, boost), or since 8.5.0 (LUCENE-9171) fromQueryBuilder, which copies each stacked token'sBoostAttributeinto the query (QueryBuilder.java:385-395,:603-609), for example fromDelimitedBoostTokenFilter("foo|0.7"). DefaultIndexWriterConfig, codec, BM25 andIndexSearcher; ordinary small documents; any index size. - What users see. Equally relevant documents score 1-2 ulps apart within one search and come back out of doc-id order;
explain()reports a value the hit did not get. Test suites that compareexplain()with the score exactly, as Lucene'sCheckHits.checkExplanationsdoes, fail. Nothing throws or logs. - Not affected. Which documents match: without scores (FILTER, MUST_NOT,
ConstantScoreQuery, the query cache)createWeightbuilds a weight overTermQuerySHOULD clauses instead (SynonymQuery.java:181-195), which never runs this code. Single-term queries use aTermScorer. - The bound part is rare. More than 2^24 occurrences of the synonym terms in one field of one document and a similarity such as ClassicSimilarity or LMDirichlet;
TOP_SCORESandCOMPLETEthen return different top hits, soCheckHits.checkTopScoresfails.AssertingScorer(score <= getMaxScore(docID()),AssertingScorer.java:130) checks the bound only afteradvanceShallow, so it trips only in tests that shallow-advance the scorer, not in ordinary searches. - How likely. Common in its setup, tiny in effect: priority low (registry: minor, common, low).
One JUnit test, four identical documents, default BM25, public API: search Synonym(a^0.2, b^0.2, c^0.3) and require explain() to equal every hit's score.
/*
* 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;
import java.io.IOException;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestSynonymFreqSumOrder extends LuceneTestCase {
public void testExplainEqualsHitScore() throws IOException {
try (Directory dir = new ByteBuffersDirectory()) {
IndexWriterConfig iwc =
new IndexWriterConfig(new StandardAnalyzer()).setCodec(TestUtil.getDefaultCodec());
try (IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 4; i++) {
Document doc = new Document();
doc.add(new TextField("f", "a b c", Field.Store.NO));
w.addDocument(doc);
}
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
Query q =
new SynonymQuery.Builder("f")
.addTerm(new Term("f", "a"), 0.2f)
.addTerm(new Term("f", "b"), 0.2f)
.addTerm(new Term("f", "c"), 0.3f)
.build();
for (ScoreDoc hit : searcher.search(q, 10).scoreDocs) {
// explain() must report the hit's score; on main they differ by 2 ulps for docs 1 and 2
float explained = searcher.explain(q, hit.doc).getValue().floatValue();
assertEquals("doc " + hit.doc + " hit score vs explain()", hit.score, explained, 0f);
}
}
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestSynonymFreqSumOrder -Ptests.seed=DEADBEEF
- Expected
- Every hit's
explain()value equals its score; the four identical documents score the same. - Observed
AssertionError: doc 1 hit score vs explain() expected:<0.038817026> but was:<0.038817033>, with assertions on and off.
More files, environment, full output379 words
ByteBuffersDirectory, a plain IndexWriterConfig (with TestUtil.getDefaultCodec(), so the test framework's random codec stays out), StandardAnalyzer and a plain IndexSearcher; one segment; nothing random, one thread; the same result for every seed (DEADBEEF and 1234ABCD checked). The test asserts only the exact explain == score rule of CheckHits.verifyExplanation.
The summed freqs are 1 ulp apart (0.7 vs 0.70000005) and the scores 2 float ulps (0.038817026 vs 0.038817033), as the in-code comment says.
It shows the order symptom under default settings. The bound symptom (more than 2^24 occurrences) and the CombinedFieldQuery copy come from the same kind of sum; they were executed in the study and are described in the text, not added as more tests.
Regression test: this same test, as one more method of the existing TestSynonymQuery. It passes with the recommended fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Release jars 10.5.2 and 10.6.0: fail |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is plain Java float arithmetic) |
cp $REPRO/TestSynonymFreqSumOrder.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestSynonymFreqSumOrder -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (the same failure)
# gradle, scratch worktree at 645d4fe14a20, unchanged source, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestSynonymFreqSumOrder > testExplainEqualsHitScore FAILED
java.lang.AssertionError: doc 1 hit score vs explain() expected:<0.038817026> but was:<0.038817033>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:D4A91A568797343C]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:577)
at org.apache.lucene.search.TestSynonymFreqSumOrder.testExplainEqualsHitScore(TestSynonymFreqSumOrder.java:57)
:lucene:core:test (FAILURE): 1 test, 1 failure
BUILD FAILED
# standalone runner: main 645d4fe14a20 jars and release jars 10.5.2 and 10.6.0, -ea and -da: FAIL 1/1, same message
# per-hit probe, same index and query (stock main, assertions off)
float: (0.2f + 0.2f) + 0.3f = 0.70000005 (0.2f + 0.3f) + 0.2f = 0.7
hit doc=0 score=0.038817033 explain=0.038817033 explainFreq=0.70000005
hit doc=3 score=0.038817033 explain=0.038817033 explainFreq=0.70000005
hit doc=1 score=0.038817026 explain=0.038817033 explainFreq=0.70000005
hit doc=2 score=0.038817026 explain=0.038817033 explainFreq=0.70000005
nextDoc path: doc0=0.038817033 doc1=0.038817026 doc2=0.038817026 doc3=0.038817033
fresh advance(doc): doc0=0.038817033 doc1=0.038817033 doc2=0.038817033 doc3=0.038817033
# with the recommended fix, gradle, -Ptests.seed=DEADBEEF
:lucene:core:test (SUCCESS): 1 test # the minimal test (and again with -Ptests.asserts=false)
:lucene:core:test (SUCCESS): 262 tests, 2 skipped # TestSynonymQuery, TestCombinedFieldQuery, TestSimpleExplanations*, TestQueryBuilder, +LFV-277's test
:lucene:core:test (SUCCESS): 2342 tests, 49 skipped # --tests 'org.apache.lucene.search.*' (216 classes)
# per-hit probe with the fix: all four hits, explain() and both paths 0.038817033- The javadoc scores "the sum of all term frequencies for the document": the same for identical documents.
- Lucene's test framework requires
explain()to equal the score exactly; LUCENE-8008 removed the tolerance for "a slightly different order of operations". - Up to 8.0.0 the sum was an exact int; #12682 fixed the same order-dependent float sum elsewhere with a double sum.
The full argument256 words
- A per-document contract. "it will match any of the terms but only invoke the similarity a single time, scoring the sum of all term frequencies for the document" (
SynonymQuery.java:47-49). - explain() == score, exactly, in Lucene's test framework.
CheckHits.verifyExplanationassertsassertEquals(score, value, 0d)(CheckHits.java:393); it is the test framework's rule, not a public API promise. LUCENE-8008 (#9056,d27ddcb409d, 2017) made it exact and removedEXPLAIN_SCORE_TOLERANCE_DELTA, whose javadoc read "Some explains methods calculate their values though a slightly different order of operations from the actual scoring method". - The max score is a bound.
Scorer#getMaxScorereturns "the maximum score that documents between the last target ... and upTo included" (Scorer.java:75-79);AssertingScorer.java:130assertsscore <= getMaxScore(docID())once a caller has shallow-advanced the scorer. LUCENE-8652's second patch wrapped the boosted impacts "to scale the term frequency for max scores". - Precedent. #12675 ("We generally prefer scores to not depend on the order of clauses ... sum up scores into a double") and PR #12682 (merged 2023-10-23) fixed this in
MultiSimilarityandPassageScorer;DisjunctionSumScorer.scoresums the same kind oftopListin double (DisjunctionSumScorer.java:39-47). - It worked before. Up to 8.0.0
SynonymScorer.freq()was an exact int sum; LUCENE-8652 (2019) made it a float sum when it added per-term boosts, and its discussion never mentions rounding, order or explain. - Tests never reach it.
testBoostsuses boosts 0.25, 0.5 and 1 with small freqs, which add exactly (TestSynonymQuery.java:222-280);testScoresandtestRandomTopDocsuse exactly two terms (:178-220,:453-514), where float addition commutes.
The devil's-advocate check265 words
The strongest case, from the refute review, the works-as-designed review and the devil's-advocate verifier: scoring is float arithmetic and no javadoc promises bit-identical scores across iteration paths; QueryUtils.checkSkipTo and checkFirstSkipTo tolerate 1e-5 and 1e-3 between nextDoc and advance (QueryUtils.java:356, :410) CheckHits.checkEqual tolerates 1e-6 between hit lists (CheckHits.java:223), and CheckHits.verifyExplanation allows 2 ulps per detail inside a "sum of" explanation ("TODO: remove this leniency", CheckHits.java:476-483); MIGRATE.md:852-856 says Lucene "does not guarantee identical scoring or document ranking behavior across major version upgrades"; maintainers have declined accuracy-only scoring work ("In general scoring doesn't focus on accuracy", #9547 / LUCENE-8501); and the bound part needs more than 2^24 occurrences.
Why it fails: this is consistency, not accuracy. The same document gets two scores from the same query on the same index, explain() reports a value the hit did not get, which is the exact rule LUCENE-8008 restored, and identical documents tie-break out of doc-id order. The tolerances cover only nextDoc/advance and hit-list comparisons and the inner sums of an explanation tree, not the top-level explain == score or score <= getMaxScore; MIGRATE.md is about upgrades, not one search; and the fix makes the score more accurate, not less (the correctly rounded sum for the repro operands; in general it greatly reduces the order dependence without removing it for every input). The refute review and the works-as-designed review rated the intended-behaviour argument weak; the devil's-advocate verifier called the nextDoc-vs-advance difference on its own tolerated but found the bug real, and the adjudication found no convincing not-real argument. Verdict: not intended, a defect, low priority.
Recommended Sum in double and round once; same double product in mergeImpacts (validated)
Sum the boosted frequencies in double and round to float once, in SynonymScorer.freq() and DisiWrapperFreq.freq(), and use the same double product before Math.ceil in mergeImpacts, so the bound still dominates; the same change in CombinedFieldScorer.freq() fixes the CombinedFieldQuery copy. With it the repro passes, and so do the whole org.apache.lucene.search test package (2,342 tests) and TestQueryBuilder.
// SynonymQuery.SynonymScorer
float freq() throws IOException {
DisiWrapperFreq w = (DisiWrapperFreq) disjunctionDisi.topList();
double freq = w.freq();
for (w = (DisiWrapperFreq) w.next; w != null; w = (DisiWrapperFreq) w.next) {
freq += w.freq();
}
return (float) freq;
}
// SynonymQuery.DisiWrapperFreq
double freq() throws IOException {
return (double) boost * pe.freq();
}All options and trade-offs4 options · 780 words
Option A was built and run through gradle in a scratch worktree of 645d4fe14a20. B (a fixed term order in float) was tried by the independent reproducer and fixes the order symptom only; C (a fixed term order in double) is the strictly order-independent variant the adjudication named, not tried; D only documents the behaviour. Check the stale open PR #418, which moves mergeImpacts into shared code, for conflicts.
In SynonymQuery, DisiWrapperFreq.freq() returns (double) boost * pe.freq(), SynonymScorer.freq() accumulates in a double and returns (float) freq, and mergeImpacts computes (int) Math.ceil(freq * (double) boost) so the bound uses the same product as the score. In CombinedFieldQuery, CombinedFieldScorer.freq() does the same with (double) freq * weight and drops the freq < 0 overflow check, which a float or double sum never triggers. 2 files, +14/-11, formatted with ./gradlew :lucene:core:tidy.
// SynonymQuery.SynonymScorer
float freq() throws IOException {
DisiWrapperFreq w = (DisiWrapperFreq) disjunctionDisi.topList();
double freq = w.freq();
for (w = (DisiWrapperFreq) w.next; w != null; w = (DisiWrapperFreq) w.next) {
freq += w.freq();
}
return (float) freq;
}
// SynonymQuery.DisiWrapperFreq
double freq() throws IOException {
return (double) boost * pe.freq();
}Trade-offs
- Why it works. A float times an int below 2^29 is exact in double, and the sum of such products is exact whenever they fit one 53-bit window, which covers ordinary boosts and frequencies; an exact sum does not depend on the order, and rounding it once gives the correctly rounded float. The bound is an exact integer sum of per-norm impact freqs, each boosted one
Math.ceilof the same double product and so never below it; it is therefore at least the exact sum, and since rounding is monotone it dominates the score's freq again. - Not every possible input. As the adjudication and the intended-behaviour check put it, a double sum is exact for the repro operands but not order-independent for every possible input (for example boosts many binades apart); option C is the strictly order-independent variant.
- Scores change in the last bits, to the correctly rounded sum: in the repro the summed freq of docs 1 and 2 moves up 1 ulp (0.7 to 0.70000005) and their score 2 float ulps (0.038817026 to 0.038817033). Worth a CHANGES.txt line, since exact scores change.
- Cost. Double arithmetic per matching term per scored document; no benchmark was run.
- Review. No reviewer has read the diff line by line yet; #418 (stale, open) rewrites
mergeImpactsand may conflict.
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle, seed DEADBEEF: the minimal test fails before the fix and passes after it, with assertions on and off. :lucene:core:test --tests TestSynonymQuery --tests TestCombinedFieldQuery --tests 'TestSimpleExplanations*' --tests TestQueryBuilder --tests TestCombinedFieldFreqSumOrder: 262 tests, 2 skipped, 0 failures (TestSynonymQuery10,TestCombinedFieldQuery14,TestSimpleExplanations*3 x 70,TestQueryBuilder26, and LFV-277's two-test reproTestCombinedFieldFreqSumOrder2, copied into the scratch tree only for this check; the 2 skipped are inTestSimpleExplanationsWithFillerDocs).:lucene:core:test --tests 'org.apache.lucene.search.*': 216 classes, 2,342 tests, 49 skipped, 0 failures.- The per-hit probe with the fix shadowed: all four hits,
explain(), thenextDocpath and a freshadvance(doc)give 0.038817033. - Earlier, the study's runs of the same
SynonymQuerychange: the finder's, the independent reproduction's and the intended-behaviour check's tests pass (the intended-behaviour check: 0 of 729 explain mismatches), and so does the 8.1.0 history shadow. Not run: the full core suite,:lucene:core:check, a benchmark.
Collect the matching wrappers, sort them by term index and add them in that order, in float.
Trade-offs
The independent reproducer's shadow of exactly this fixes the identical-documents and explain tests but not the max-score tests (2 of 5 still fail), because the float sum can still round above the exact integer bound above 2^24. It also costs a sort per scored document.
Accumulate each term's boost * freq in double in term-index order (for example into a small array indexed by term), then round once.
Trade-offs
The adjudication's strictly order-independent variant: the result never depends on the heap layout, for any input. Costs an array write and an ordered pass per scored document; not built or run.
Say in the javadoc that a SynonymQuery's score can differ by a few ulps depending on how the scorer reached the document.
Trade-offs
Leaves explain() disagreeing with the hit, against the exact rule of CheckHits.verifyExplanation, leaves identical documents out of doc-id order, and leaves the score above getMaxScore() in the 2^24 case. Not recommended.
Only score values move: for filters, exclusions, ConstantScoreQuery and cached clauses createWeight builds a plain term disjunction instead, which never runs this code, so which documents match, and any filter-based access restriction, are unaffected. The one cross-document effect measured is tiny: rearranging deleted documents changed 80 of 5,986 visible scores by at most 2 ulps (0 with the fix).
While writing SMT (Z3) floating-point lemmas for SynonymQuery's max-score bound, the finder dropped a size precondition and asked for a counterexample; Z3 found one in 0.4 s, which became the 2^24 test. Separately, Lucene's own explanation check failed on three-term boosted synonym queries in the unit's differential test, and a search minimised it.
The method in full147 words
Method: semi-formal (the hypothesis came from code reading while modelling; the lemma was posed as a counterexample search).
- A hunt unit (DisjunctionMax, Synonym and CombinedField queries) split
SynonymQuery's merged-impacts bound into Z3 floating-point lemmas with a model-to-Java line map. Lemma S1 (boost 1, no size precondition) came back sat in 0.4 s: freqs 778,681,664 and 1,281,827,656 give a float sum of 1.9189989566802978515625 x 2^30, above the bound 1.91899883747100830078125 x 2^30. With the precondition "sum < 2^24" the same lemma is unsat. The witness became the public-API max-score test. - The merge logic itself was checked bounded-exhaustively on the real
mergeImpacts: 7,743,376 configurations, 0 failures (mutants killed). - The order symptom came from the unit's differential baseline:
QueryUtils.checkreported explanation mismatches on three-term boostedSynonymQuery, and a random search over small indexes minimised it to a six-document test; the four-document test came later, from the independent reproducer.
No prior report: GitHub issues and PRs (with the JIRA archive), commits, five mailing lists, 243 build-failure threads, Elasticsearch, OpenSearch and Solr trackers and the web were searched on 2026-10-26. Closest: #12675 / #12682, the same kind of float sum fixed in other code.
Searches5 searches
Verdict: new. Of the 413 open PRs, 10 touch the relevant files and none changes the summation; #418 (stale) moves mergeImpacts and may conflict with a fix.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (incl. migrated JIRA issues), commits | 2026-10-26 | 26 distinct GitHub queries, 3 commit queries plus the full SynonymQuery.java history (53 commits), 4 JQL queries | no report of this defect; the float sum dates from LUCENE-8652 |
| Open PRs | 2026-10-26 | changed files of all 413 open PRs | 10 touch the files (#418, #11928, #14716, #16851, #16870, ...); none changes the summation |
| Mailing lists and build reports | 2026-10-26 | dev@, java-user@, issues@, commits@ (20 queries); builds@ and the first email of 243 build-failure threads | one 2019 testBoosts failure with a different cause (a norm effect of about 6%) |
| Downstream trackers, web | 2026-10-26 | Elasticsearch, OpenSearch, Solr (8 queries, 14 hits read); 8 web queries | nothing on this defect |
| Closest items (read) | 2026-10-26 | threads and diffs | #9698 / LUCENE-8652 (introduced the float sum, no rounding discussion); #12675 / #12682 (same class, other code; precedent); #418 (moves mergeImpacts); #16870, #16137 / #16139 (other TestSynonymQuery failures) |
Introduced with per-term synonym boosts in 8.1.0 (LUCENE-8652, 2019), which replaced the exact int sum with a float running sum. Every release from 8.1.0 to 10.6.0 and main is affected; 8.0.0 is not.
Versions tested6 rows
Introduced by d8cfeba912ea (main line) / 24427f9f5cd2 (branch_8x), "LUCENE-8652: SynonymQuery can now deboost the document frequency of each term when blending the score of the synonym" by jimczi, 2019-03-11, a JIRA patch with no PR (now #9698). It replaced the exact int running sum in SynonymScorer.freq() with a float running sum, added the float boost * pe.freq() and the Math.ceil boosted impacts; the exact long merged-impacts bound is older (LUCENE-8312, 8.0.0) and not the cause. From 8.5.0 (LUCENE-9171) QueryBuilder passes token boosts to the Builder, the common path today. Later commits (#13957, #14052, #14023, #12978, #15151) touched these lines but not the arithmetic. On the 8.1.0 jars, shadowing only SynonymQuery with its 8.1.0 source plus the double-sum fix makes every probe pass; the commit and its parent were not built.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (gradle and standalone runner); passes with the fix |
| 10.5.2, 10.6.0 | affected | minimal test fails, assertions on and off (release jars) |
| 8.1.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 | affected | the finder's order and max-score probes fail with the same values (ports where needed); all 51 release tags from 8.1.0 to 10.6.0 in the history clone (8.1.0-8.8.1, 9.0.0-10.6.0) contain the commit or its backport |
| 8.0.0 | not affected | the byte-identical max-score probe and an unboosted order probe pass (exact int sum); the boosted test does not compile (no Builder) |
| 8.1.0 + fix shadow | not affected | all probes pass |
upstream main 2f424fd428da, branch_10_6 7e5aec4370e0 | read | same float sums in SynonymQuery and CombinedFieldQuery (2026-10-31) |
Confirmed by an independent reproducer, a works-as-designed review from another model family (OpenAI GPT-5.6), a devil's-advocate verifier, an adjudication and a credibility audit; the only "not real" came from a refute review that could not run Java and was procedural.
Checks and verdicts282 words
Every check that could execute re-ran a repro; none found a flaw in the claim. The adjudication, the devil's-advocate verifier and the audit set the class to minor: 1-2 ulps, and the bound part needs more than 2^24 occurrences.
| Check | Verdict | What it found |
|---|---|---|
| Finder (Z3 lemmas, differential test, JUnit) | candidate | Two public-API tests fail on main (-ea, -da), 10.5.2, 9.12.3, 8.11.4 and 8.1.0 and pass with the fix; bounded-exhaustive merge check 0 failures. |
| Refute review (OpenAI GPT-5.6, read-only) | not real (procedural, low) | Could not run Java; 10 of its 12 evidence items supported real; a separate run re-ran the repro: fails 4 of 4. |
| Independent reproducer | real | Own tests (four identical documents; max score) fail on main, 10.5.2 and 9.12.3; a fixed-order float sum fixes only the order part, a double sum fixes both. |
| Works-as-designed review (OpenAI GPT-5.6) | real (medium) | Intended-behaviour argument weak; a separate run re-ran the repro on main -ea, -da and 10.5.2: fails 4 of 4 each. |
| Devil's advocate | real | 568 of 729 QueryBuilder-built queries fail the explain check (0 with the fix); showed the pruned answer of the bound part is the exact one. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's tests with another seed; class minor. |
| Credibility audit | keep; LFV-277 merged here | Re-ran the finder's tests (4 of 4 fail); file SynonymQuery and CombinedFieldQuery as one issue and one PR. |
| Current main | still reproduces | Same failures on a9779ef3c193; the one-test repro verified on 645d4fe14a20. |
| This doc | reproduced | Minimal test through gradle before and after the fix; module tests; release jars 10.5.2 and 10.6.0; the per-hit probe on stock and fixed code. |
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 482 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,404 words
- Registry title
- SynonymQuery adds up boosted term frequencies in float in heap order, so identical documents can score differently and explain() can differ from the hit score; with more than 2^24 occurrences in one field a score can exceed getMaxScore()
- Area
- search / SynonymQuery scoring (SynonymScorer.freq float sum) and its merged-impacts max-score bound
- Priority
- Low, rank 721: Weighted SynonymQuery scores differ by 1 ulp depending on the path, which reorders tied documents and breaks explain(); it is common in its configuration but tiny in effect.
- Impact class
- minor
- Impact note
- Applications that run a SynonymQuery with three or more terms and per-term boosts below 1 (built with SynonymQuery.Builder#addTerm(term, boost), or since 8.5.0 by QueryBuilder-based query parsers from a synonym token stream that sets BoostAttribute), with the default BM25, codec and IndexSearcher and on ordinary small documents, get scores that differ by about 1 ulp depending on how the scorer reached the document, so identical documents in one segment can score differently and rank out of doc-id order, and explain() differs from the hit score (with -ea and -da, 8.1.0 to 10.5.2 and main); separately and rarely, a field that holds more than 2^24 occurrences of the synonym terms, scored with ClassicSimilarity or LMDirichletSimilarity (BM25 showed no effect in the probes), can score above getMaxScore(), so pruned and exhaustive top-k disagree; which documents match never changes.
- Summary
- You run a SynonymQuery with three or more boosted terms, for example Synonym(a^0.2, b^0.2, c^0.3) over four identical documents 'a b c' with the default BM25, and expect identical documents to score the same and explain() to report the hit's score, because the query scores 'the sum of all term frequencies for the document'. SynonymScorer.freq() adds boost * freq in float, in the order of the disjunction's topList(), and that order depends on how the iterator reached the document, so the rounding differs: docs 0 and 3 score 0.038817033, docs 1 and 2 score 0.038817026, and explain(doc 1) reports 0.038817033. The same float sum can also round above the exact integer sum that the merged impacts use as the max-score bound once a field holds more than 2^24 occurrences of the terms: under ClassicSimilarity a document scores 1.032795 while getMaxScore() is 1.0327948, and a pruned top-1 search returns a different document than an exhaustive one.
- Introduced
- d8cfeba912ea9c474f81f336d1bf403477dc553c (LUCENE-8652 'SynonymQuery can now deboost the document frequency of each term when blending the score of the synonym', by jimczi, 2019-03-11; a JIRA patch with no pull request, migrated to GitHub issue #9698; branch_8x backport 24427f9f5cd2ab9883d1d3e5ca13a205326478a4) [read: git log -L and git blame of SynonymScorer.freq, DisiWrapperFreq.freq and mergeImpacts in the full-history clone: the commit replaced the exact int running sum with a float running sum and added the float boost * pe.freq() term; exec: the byte-identical max-score source and the unboosted order probe pass on 8.0.0 and fail on 8.1.0 with -ea and -da, and on the 8.1.0 jars shadowing only SynonymQuery with its 8.1.0 source plus the double-sum fix makes every probe pass; the commit and its parent were not built]. The exact long merged-impacts bound comes from LUCENE-8312 (0a1de2c4a57, first released in 8.0.0); it is not the cause (2019-03-11)
- First affected version
- 8.1.0 [exec] (released 2019-05-13; 8.0.0 passes [exec]); on the main line the first release is 9.0.0 [exec]
- Affected versions
- 8.1.0 to 10.5.2 and main ea57360912ed [exec: the finder's repros (8.x ports where needed) fail on 8.1.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main with -ea and -da and on 10.0.0 with -ea, with identical values from 8.1.0 to main; the independent reproduction's own tests fail on main, 10.5.2 and 9.12.3; the intended-behaviour check's decimal-boost probe fails 568 of 729 queries on main and 10.5.2] [read: all 50 release tags from 8.1.0 in the history clone contain the commit or its backport; the 8.9.0 to 8.11.3 tags are not in the clone and were not run, 8.11.4 was]; not affected: 8.0.0 [exec] (exact int sum; the boosted repro does not compile there because SynonymQuery.Builder did not exist yet). Exposure: from 8.1.0 boosted synonym queries had to be built with the Builder; from 8.5.0 (LUCENE-9171) QueryBuilder passes token BoostAttribute values to it. Not fixed upstream as of 2026-10-26 (upstream main is ea57360912ed; main and branch_10x still have the float running sum). Not run: 10.6.0 (released 2026-10-29; branch not in the clone), x86
- 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
- semi-formal: Z3, code reading; also reached by fuzzing/PBT
- Upstream and prior reports
- none found (2026-10-26, the prior-report record: 90 recorded queries over apache/lucene issues, PRs and commits, the LUCENE JIRA archive, the dev@, java-user@, issues@, commits@ and builds@ lists (the first email of 243 build-failure threads read), Elasticsearch, OpenSearch and Solr, and the web; the changed files of all 413 open apache/lucene PRs, of which 10 touch the relevant files and none changes the summation; 22 hits read, none the same bug; the finder's earlier prior-art run, the intended-behaviour check and the adjudication found nothing either); not fixed upstream (upstream main is ea57360912ed; main and branch_10x still contain the float running sum); related items, not reports of this bug: #9698 / LUCENE-8652 (introduced the float sum; its discussion does not mention rounding, order or explain), #12675 / #12682 (the same defect class fixed in MultiSimilarity and PassageScorer only; precedent that Lucene prefers order-independent double sums), #418 (stale open PR that moves mergeImpacts and may conflict with a fix), #16870 and #16137 / #16139 (other TestSynonymQuery failures with different causes)
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the recommended fix it passes, together withTestSynonymQuery,TestCombinedFieldQuery, the threeTestSimpleExplanations*classes,TestQueryBuilderand LFV-277's two-test repro (262 tests in all) and the wholeorg.apache.lucene.searchpackage (216 classes, 2,342 tests, 0 failures). Release jars 10.5.2 and 10.6.0 fail the minimal test with assertions on and off. Earlier: the finder's probes fail on 8.1.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and mainea57360912ed, and on 8.0.0 the max-score and unboosted order probes pass (the boosted one does not compile there); still reproduced on maina9779ef3c193. Upstream main2f424fd428daandbranch_10_6carry the same code (read, 2026-10-31).
- Finding record
lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:44-50 (at ea57360912ed) (class javadoc: 'scoring the sum of all term frequencies for the document'), :88-95 (Builder#addTerm accepts boosts in (0, 1]), :181-195 (non-scoring modes rewrite to a TermQuery disjunction, per the security record), :246-266 (explain builds a fresh scorer and advance(doc)s it), :494-506 (boosted impacts: (int) Math.ceil(freq * boost)), :540-555 (mergeImpacts: exact long sumTf, freqUpperBound = (int) min(MAX_INT, sumTf)), :610-617 (SynonymScorer.freq: float running sum over disjunctionDisi.topList()), :659-661 (DisiWrapperFreq.freq: boost * pe.freq() in float)lucene/core/src/java/org/apache/lucene/search/DisjunctionDISIApproximation.java:195-218 (at ea57360912ed) and DisiPriorityQueueN.java:72-83 (topList order follows the heap layout and the lead/other split, which depend on the iteration history)lucene/core/src/java/org/apache/lucene/search/MaxScoreCache.java:74-89 (at ea57360912ed) (computeMaxScore converts the int freq bound to float once); Scorer.java:74-79 (getMaxScore: the maximum score of the documents up to upTo); DisjunctionSumScorer.java:38-46 (sums the same kind of topList in double, the precedent)lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:603-609 (at ea57360912ed) (newSynonymQuery passes each TermAndBoost boost to Builder#addTerm); lucene/analysis/common/src/java/org/apache/lucene/analysis/boost/DelimitedBoostTokenFilter.java:26-31 (token boosts such as 'foo|0.7')lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:393 (at ea57360912ed) (verifyExplanation: assertEquals(score, value, 0d)); AssertingScorer.java:130 (assert score <= getMaxScore(docID()))
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java (:47-49, :246-253, :503, :543-564, :610-617, :659-661)lucene/core/src/java/org/apache/lucene/search/DisjunctionDISIApproximation.java (:196-218); lucene/core/src/java/org/apache/lucene/search/DisiPriorityQueueN.java (:71-84); lucene/core/src/java/org/apache/lucene/search/MaxScoreCache.java (:74-89)lucene/core/src/java/org/apache/lucene/search/CombinedFieldQuery.java (:323-326, :424-434)
- Who is affected
lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java (:385-395, :603-609); lucene/analysis/common/src/java/org/apache/lucene/analysis/boost/DelimitedBoostTokenFilter.java (:26-31, :54)lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java (:90-100, :181-195, :342-352)lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java (:130); lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java (:228-232, :393, :690-697, :711-766)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java (:47-49)lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java (:223, :393, :476-483); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java (:130); lucene/test-framework/src/java/org/apache/lucene/tests/search/QueryUtils.java (:356, :410)lucene/core/src/java/org/apache/lucene/search/Scorer.java (:75-79); lucene/core/src/java/org/apache/lucene/search/DisjunctionSumScorer.java (:39-47); lucene/MIGRATE.md (:852-856)lucene/core/src/test/org/apache/lucene/search/TestSynonymQuery.java (:178-220, :222-280, :453-514)
- History
a Lucene clone: git show origin/main:.../SynonymQuery.java, origin/branch_10_6 (read 2026-10-31)
- TestSynonymFreqSumOrder.java (one test, public API):
repro/LFV-272/src/org/apache/lucene/search/TestSynonymFreqSumOrder.java(62 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed probes (per-hit scores, explain values and frequencies on stock and fixed code).
assets/LFV-272/bug.png(482 KB)
