All findings · Lucene finding · LFV-272 · synonym-scorer-float-freq-sum

SynonymQuery adds boosted term frequencies in float in iteration order, so identical documents score differently and explain() differs from the hit score

A SynonymQuery with three or more boosted terms adds the boosted frequencies in float, in an order that depends on how the scorer reached the document: four identical documents under Synonym(a^0.2, b^0.2, c^0.3) score 0.038817033 twice and 0.038817026 twice, and explain() disagrees with two hits. Minor, scores only, since 8.1.0.

Summary

Priority
Low, rank 721Weighted 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
minor
Since
8.1.0 (LUCENE-8652, 2019)introduced 2019-03-11 · about 7 years
Affected
8.1.0 to 10.6.0 and main
Status
documented
Found by
semi-formal: an SMT counterexample (bound) and Lucene's explanation check (order)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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-03.

The finding in 30 seconds

One float sum, added in an order that changes from document to document, gives identical documents different last digits.

Background

  1. SynonymQuery treats 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.
  2. 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.
  3. 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.
Diagram of the bug: four identical documents containing a, b and c are searched with Synonym(a^0.2, b^0.2, c^0.3) under BM25. The scorer adds the boosted frequencies 0.2, 0.2 and 0.3 in float, in an order that depends on the iteration path: (0.2 + 0.2) + 0.3 gives 0.70000005 and the score 0.038817033 (docs 0 and 3), while (0.2 + 0.3) + 0.2 gives 0.7 and the score 0.038817026 (docs 1 and 2); explain() reports 0.038817033 for every document. Summing in double and rounding once gives 0.70000005 and 0.038817033 for all four.
Four identical documents, one query 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.

The finding, step by step

  1. You weight synonyms, e.g. Synonym(a^0.2, b^0.2, c^0.3), built with SynonymQuery.Builder#addTerm(term, boost) or by QueryBuilder from 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).
  2. Index four identical documents a b c and search with the default BM25. You expect four equal scores, hits in doc-id order, and explain() equal to each hit's score.
  3. 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.
  4. 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.
  5. 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.

Who is affected

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.

The bug

  1. SynonymScorer.freq() walks disjunctionDisi.topList() and keeps a float running sum: float freq = w.freq(); ... freq += w.freq(); (SynonymQuery.java:610-617 at 645d4fe14a20), where DisiWrapperFreq.freq() is the float product boost * pe.freq() (:659-661).
  2. topList() returns the lead iterators' list or prepends the matching other iterators (DisjunctionDISIApproximation.java:196-218); DisiPriorityQueueN.topList() walks heap[0], then the heap[1] and heap[2] subtrees (DisiPriorityQueueN.java:71-84). The order is the heap layout, a product of the iteration history.
  3. A search drives the scorer with nextDoc(). SynonymWeight.explain builds a fresh scorer and calls advance(doc) (SynonymQuery.java:246-249), then freq() (:253). The two paths can add the same three products in different orders.
  4. 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.

The numbers

  • Minimal input (four identical documents a b c, BM25, Synonym(a^0.2, b^0.2, c^0.3)): the nextDoc() path scores doc0 = doc3 = 0.038817033 and doc1 = doc2 = 0.038817026 (freq 0.7); a fresh advance(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 QueryBuilder from tokens with one-decimal boosts 0.1..0.9 (all 729 triples), 60 documents, BM25: 568 of 729 fail CheckHits.checkExplanations, the first being Synonym(f:a^0.1 f:b^0.1 f:c^0.1); 0 with the fix.

The second, rare symptom: score above getMaxScore()

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.

The same pattern in CombinedFieldQuery (LFV-277, merged here)

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 SynonymQuery with three or more terms matching the same document and per-term boosts below 1: from SynonymQuery.Builder#addTerm(term, boost), or since 8.5.0 (LUCENE-9171) from QueryBuilder, which copies each stacked token's BoostAttribute into the query (QueryBuilder.java:385-395, :603-609), for example from DelimitedBoostTokenFilter ("foo|0.7"). Default IndexWriterConfig, codec, BM25 and IndexSearcher; 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 compare explain() with the score exactly, as Lucene's CheckHits.checkExplanations does, fail. Nothing throws or logs.
  • Not affected. Which documents match: without scores (FILTER, MUST_NOT, ConstantScoreQuery, the query cache) createWeight builds a weight over TermQuery SHOULD clauses instead (SynonymQuery.java:181-195), which never runs this code. Single-term queries use a TermScorer.
  • 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_SCORES and COMPLETE then return different top hits, so CheckHits.checkTopScores fails. AssertingScorer (score <= getMaxScore(docID()), AssertingScorer.java:130) checks the bound only after advanceShallow, 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).

Minimal repro

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.

TestSynonymFreqSumOrder.java (one test, public API)62 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;

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);
        }
      }
    }
  }
}
Command
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.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Release jars 10.5.2 and 10.6.0: fail
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is plain Java float arithmetic)
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • 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.verifyExplanation asserts assertEquals(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 removed EXPLAIN_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#getMaxScore returns "the maximum score that documents between the last target ... and upTo included" (Scorer.java:75-79); AssertingScorer.java:130 asserts score <= 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 MultiSimilarity and PassageScorer; DisjunctionSumScorer.score sums the same kind of topList in 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. testBoosts uses boosts 0.25, 0.5 and 1 with small freqs, which add exactly (TestSynonymQuery.java:222-280); testScores and testRandomTopDocs use 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.

Suggested fix

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.

Sum in double and round once; same double product in mergeImpacts (validated) (recommended)

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.ceil of 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 mergeImpacts and 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 (TestSynonymQuery 10, TestCombinedFieldQuery 14, TestSimpleExplanations* 3 x 70, TestQueryBuilder 26, and LFV-277's two-test repro TestCombinedFieldFreqSumOrder 2, copied into the scratch tree only for this check; the 2 skipped are in TestSimpleExplanationsWithFillerDocs).
  • :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(), the nextDoc path and a fresh advance(doc) give 0.038817033.
  • Earlier, the study's runs of the same SynonymQuery change: 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.

Sum in a fixed term order, still in float (fixes the order symptom only)

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.

Sum in term order, in double (strictly order-independent)

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.

Document that scores may differ in the last bits

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.

Downstream note

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).

How it was found

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.check reported explanation mismatches on three-term boosted SynonymQuery, and a random search over small indexes minimised it to a six-document test; the four-document test came later, from the independent reproducer.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. migrated JIRA issues), commits2026-10-2626 distinct GitHub queries, 3 commit queries plus the full SynonymQuery.java history (53 commits), 4 JQL queriesno report of this defect; the float sum dates from LUCENE-8652
Open PRs2026-10-26changed files of all 413 open PRs10 touch the files (#418, #11928, #14716, #16851, #16870, ...); none changes the summation
Mailing lists and build reports2026-10-26dev@, java-user@, issues@, commits@ (20 queries); builds@ and the first email of 243 build-failure threadsone 2019 testBoosts failure with a different cause (a norm effect of about 6%)
Downstream trackers, web2026-10-26Elasticsearch, OpenSearch, Solr (8 queries, 14 hits read); 8 web queriesnothing on this defect
Closest items (read)2026-10-26threads 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)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone runner); passes with the fix
10.5.2, 10.6.0 affectedminimal test fails, assertions on and off (release jars)
8.1.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affectedthe 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 affectedthe 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 affectedall probes pass
upstream main 2f424fd428da, branch_10_6 7e5aec4370e0 readsame float sums in SynonymQuery and CombinedFieldQuery (2026-10-31)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (Z3 lemmas, differential test, JUnit) candidateTwo 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 realOwn 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 real568 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 hereRe-ran the finder's tests (4 of 4 fail); file SynonymQuery and CombinedFieldQuery as one issue and one PR.
Current main still reproducesSame failures on a9779ef3c193; the one-test repro verified on 645d4fe14a20.
This doc reproducedMinimal 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.

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

Finding record

LFV-272, snapshot of 2026-11-03

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 with TestSynonymQuery, TestCombinedFieldQuery, the three TestSimpleExplanations* classes, TestQueryBuilder and LFV-277's two-test repro (262 tests in all) and the whole org.apache.lucene.search package (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 main ea57360912ed, and on 8.0.0 the max-score and unboosted order probes pass (the boosted one does not compile there); still reproduced on main a9779ef3c193. Upstream main 2f424fd428da and branch_10_6 carry the same code (read, 2026-10-31).

Lucene and upstream sources

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)

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 code at 645d4fe14a20 and the executed probes (per-hit scores, explain values and frequencies on stock and fixed code).

Built 2026-11-03 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.