All findings · Lucene finding · LFV-628 · synonymquery-repeated-term-total-term-freq-exceeds-field

SynonymQuery with a repeated term counts it once per copy, so its statistics can exceed the field's totals

A SynonymQuery holding the same term twice, like Synonym(body:cv body:resume body:resume) from the rule résumé, resume, cv with accent folding, counts that term's occurrences per copy: 5 in a field of 3 tokens. SimilarityBase similarities throw AssertionError (-ea); LM Dirichlet scores from a probability of 1.5 (-da).

Summary

Priority
Medium, rank 98Released since 6.0, it silently mis-scores SynonymQuery with a repeated term only under opt-in SimilarityBase similarities, which is narrower than the StableTfl synonym-scoring row it follows.
Impact
silent wrong results
Since
6.0.0 (2016), when SynonymQuery was addedintroduced 2015-09-14 · over 11 years
Affected
6.0.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
differential testing of a javadoc claim: SynonymQuery with a repeated term vs TermQuery

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

The finding in 30 seconds

SynonymQuery scores several terms as one pseudo-term: the similarity gets one docFreq (the largest of the terms) and one totalTermFreq (their sum). When the same term is in the query twice, the sum counts its occurrences twice.

Summary card: field body holds the documents "resume" and "resume cv" (3 tokens); the rule "résumé, resume, cv" with ASCII folding makes QueryBuilder build Synonym(body:cv body:resume body:resume). SynonymWeight takes docFreq 2 but totalTermFreq 1 + 2 + 2 = 5. Expected, each term counted once: docFreq 2, totalTermFreq 3, at most 3, P(t|C) 1.0. Actual on main: totalTermFreq 5 > 3; with -ea an AssertionError at SimilarityBase.fillBasicStats:80 when the Weight is built; with -da LM Dirichlet P(t|C) = (5 + 1) / (3 + 1) = 1.5, against 0.75 for TermQuery(body:resume). Subtitle: this happens once the copies outnumber the field's tokens. Notes: with -ea every SimilarityBase similarity throws; with -da the formulas that read totalTermFreq (LM, Indri, DFI, some DFR and IB models; not Axiomatic) score from it; BM25 and Classic are not affected; the direct SynonymQuery(a, a) case (6 > 4); and the fix.
The rule maps resume to itself, so the query holds resume twice. The weight takes docFreq 2 but adds 1 + 2 + 2 = 5 occurrences in a field of 3 tokens; with -ea SimilarityBase throws, with -da LM Dirichlet uses P(t|C) = 1.5. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You use query-time synonyms with the rule résumé, resume, cv (SolrSynonymParser, expand=true), an analyzer that lower-cases and folds accents, and LMDirichletSimilarity. résumé folds to resume, so the rule maps resume to itself, and QueryBuilder and the classic query parser turn the query resume into Synonym(body:cv body:resume body:resume).
  2. The index holds two documents, resume and resume cv: field body has 3 tokens, resume 2 of them and cv 1.
  3. SynonymQuery scores its terms as one pseudo-term, so you expect it to occur at most 3 times, the whole field. TermStats lists totalTermFreq <= the field's sumTotalTermFreq as "always true".
  4. Instead it takes the largest docFreq (2) but adds totalTermFreq per entry: 1 + 2 + 2 = 5 of 3 tokens. With -ea, building the query's Weight throws AssertionError in SimilarityBase.fillBasicStats. With -da, LM Dirichlet computes its scores from a collection probability of 1.5, above 1 (0.75 for TermQuery(resume)).
  5. SynonymQuery.Builder takes the repeat directly too: SynonymQuery(a, a) over a and a a b claims 6 of 4 tokens (P(a|C) 1.4 with -da; 0.8 for TermQuery(a)). BM25 and Classic never read totalTermFreq, and which documents match never changes.

Who is affected

Applications that set a SimilarityBase similarity (every one throws with -ea; with -da the LM family, DFI and some DFR and IB models score from the bad value) or a custom one, not the default BM25, and run a SynonymQuery with a repeated term, typically from a synonym rule whose entries coincide after analysis. Mostly short fields and small indexes.

Mechanism, terms and numbers1,088 words

SynonymQuery "tries to score the terms as if you had indexed them as one term": its weight passes the similarity one pseudo-term whose docFreq is the largest docFreq of the terms and whose totalTermFreq is the sum of theirs. SynonymQuery.Builder keeps a term that is added twice, and the sum then counts that term's occurrences once per copy while the docFreq counts it once. When the copies add up to more than the field holds, the pseudo-term breaks the bound TermStats documents as "always true" (totalTermFreq <= sumTotalTermFreq of the field). With -ea, every SimilarityBase similarity (LM Dirichlet, LM Jelinek-Mercer, Indri, DFR, IB, DFI, Axiomatic) trips the assertion in the shared SimilarityBase.fillBasicStats; with -da, the formulas that use totalTermFreq (the LM family, DFI and some DFR and IB configurations, not Axiomatic) score from impossible statistics (LM Dirichlet's collection probability goes above 1). Stock components produce such queries: the synonym rule résumé, resume, cv with ASCII folding makes QueryBuilder and the classic query parser build Synonym(body:cv body:resume body:resume) for the query resume. BM25 and Classic never read totalTermFreq, and which documents match never changes. Present since SynonymQuery was added in 6.0.0; not fixed on main.

Terms

  • totalTermFreq, sumTotalTermFreq: how often a term occurs in a field over all documents, and how many tokens the field holds in total. A real term can never occur more often than the field has tokens, and TermStats lists totalTermFreq <= sumTotalTermFreq of the field among the conditions that are "always true" (TermStats.java:32-38).
  • Pseudo-term: SynonymQuery "tries to score the terms as if you had indexed them as one term: 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). It passes one TermStats for all its terms.
  • SimilarityBase family: LM Dirichlet, LM Jelinek-Mercer, Indri Dirichlet, DFR, IB, DFI and Axiomatic. All of them assert the bound in the shared fillBasicStats. Their formulas differ: the LM family, DFI, the DFR parts G, I(F), I(ne), after-effect B and normalization H3, and IB's LambdaTTF read totalTermFreq, for example the LM collection probability P(t|C) = (totalTermFreq + 1) / (sumTotalTermFreq + 1) (LMSimilarity.java:150-152). Axiomatic, DFR I(n) with L and H1/H2/Z, and IB with LambdaDF and H1/H2/Z do not. The default BM25 and Classic do not read it at all.

The bug

  1. The builder keeps repeats. Builder.addTerm checks only the field, the boost range and the clause count (SynonymQuery.java:79-100); build() sorts and copies (:103-106). A repeated term stays, next to its first copy. No javadoc asks for unique terms.
  2. The weight sums per entry. SynonymWeight loops over every entry (:213-222): docFreq = Math.max(termStats.docFreq(), docFreq) (:219) but totalTermFreq += termStats.totalTermFreq() (:220). k copies of a term add its totalTermFreq k times; the max counts it once.
  3. The similarity gets it. new TermStats("synonym pseudo-term", docFreq, totalTermFreq) goes to similarity.scorer (:225-227). TermStats does not check it against the field; that is a TODO (TermStats.java:62-63: "otherwise many similarity functions will implode").
  4. Where it breaks. SimilarityBase.fillBasicStats asserts totalTermFreq <= sumTotalTermFreq (SimilarityBase.java:80); without assertions it copies the value into its statistics (:88), LM's collection probability goes above 1, and the other formulas that read totalTermFreq use the inflated value. Executed with -da: SynonymQuery(a, a) scores the same with and without the fix under the 6 Axiomatic variants, DFR I(n)L1/I(n)L2 and IB LL-D2, and differently under LM Dirichlet, LM Jelinek-Mercer, Indri, DFI, DFR GB2, DFR I(F)L2 and IB LL-L2.
  5. When. The distinct terms' occurrences are disjoint, so they stay within the field on their own; the bound breaks when the extra copies, (k - 1) × ttf for a term present k times, outnumber the field's tokens that are not occurrences of any of the query's distinct terms. With no other term in the query, the term must be more than 1/k of the field; with other synonyms a smaller share is enough (docs a b, b, b: Synonym(f:a f:a f:b) gives 5 > 4 with a 1 of 4 tokens). Short or low-cardinality fields, small indexes.
  6. Not matching. Without scores the query becomes a BooleanQuery of SHOULD TermQuerys (SynonymQuery.java:186-194) and never builds the pseudo-term, so hits, count() and filters are unchanged.

How a repeat gets into the query

  • Synonym rules whose entries coincide after analysis. SolrSynonymParser with expand=true adds every pair i != j by position in the rule, after analysing each entry (SolrSynonymParser.java:116-127). résumé, resume, cv with ASCII folding, or Dog, dog, puppy with lower-casing, maps a term to itself; SynonymGraphFilter emits it twice, and QueryBuilder.newSynonymQuery copies every stacked token into the builder (QueryBuilder.java:603-609). The classic and simple query parsers extend QueryBuilder. WordnetSynonymParser does the same.
  • KeywordRepeatFilter plus a stemmer, without RemoveDuplicatesTokenFilter, for a word already in stem form: Synonym(f:run f:run). Weaker, because KeywordRepeatFilterFactory advises adding that filter.
  • Code that adds the same term to SynonymQuery.Builder twice.

The numbers (main, executed)

query and indexpseudo-term statistics-ea-da, LM Dirichlet
Synonym(body:cv body:resume body:resume) from the rule; docs resume, resume cv (3 tokens)docFreq 2, totalTermFreq 5 > 3AssertionError at SimilarityBase.java:80P(t|C) 1.5; scores 1.67e-4 and 0.0
the same with resume counted once (fix)docFreq 2, totalTermFreq 3passesP(t|C) 1.0; scores 5.00e-4 and 4.99e-4
TermQuery(body:resume) (control)docFreq 2, totalTermFreq 2passesP(t|C) 0.75; scores 1.67e-4 and 0.0
SynonymQuery(a, a); docs a, a a b (4 tokens)docFreq 2, totalTermFreq 6 > 4AssertionErrorP(a|C) 1.4; scores a 2.14e-4, a a b 0.0
the same with a counted once (fix)docFreq 2, totalTermFreq 3passesP(a|C) 0.8; scores a 7.49e-4, a a b 9.98e-4
controls TermQuery(a), SynonymQuery(a, b)totalTermFreq 3, 4 (in bounds)passTermQuery(a): P(a|C) 0.8; scores a 1.25e-4, a a b 0.0

The fix rows keep the query and its doubled per-document frequency (freq 2 for resume, 4 for a a b); only the statistics change. LM Dirichlet with mu 2000.

The claimed -da effect is the statistic: a collection probability above 1, from which the scores are computed. The toy scores are not a harm on their own: TermQuery gives the same 0.0 values (LM Dirichlet clamps negative scores to 0, LMDirichletSimilarity.java:81) and the same order, and the fix row's order in the second example comes from the doubled per-document frequency the fix keeps, which is not claimed.

Exposure and likelihood402 words
  • Who. Applications that set a SimilarityBase or custom similarity with IndexSearcher#setSimilarity. With assertions on, every SimilarityBase similarity fails; with assertions off, only the formulas that use totalTermFreq change: the LM family including Indri, DFI and some DFR and IB configurations, not Axiomatic. The default BM25Similarity (IndexSearcher.java:123), ClassicSimilarity and BooleanSimilarity never read totalTermFreq, so the statistics part does not change their scores. Codec, writer, merge policy and searcher stay default.
  • Trigger. A SynonymQuery that holds the same term more than once: from code, or from QueryBuilder and the stock query parsers when the analysis chain stacks a token on itself (synonym rules with case or accent variants under expand=true, or KeywordRepeatFilter without RemoveDuplicatesTokenFilter).
  • Scale. The bound breaks only when the extra copies outnumber the field's tokens outside the query's distinct terms: a repeated term that is more than 1/k of the field for k copies, or a smaller share when the other synonyms' occurrences make up the difference. Ordinary text fields of large indexes do not get there; short fields (titles, tags, categories), small indexes and test indexes can. Nobody has reported it since 6.0.0, so the registry rates it rare.
  • With assertions on (tests, assertion-enabled deployments): AssertionError while the Weight is built, so the query fails. A probe of 2,340 cases saw 1,488 such errors, all above the bound and none at or below it.
  • With assertions off (production): scores from impossible statistics, silently, under the formulas that use totalTermFreq: LM Dirichlet's P(t|C) is 1.5 and 1.4 in the examples, above 1 (0.75 and 0.8 for the equivalent TermQuery). The two-document examples show no ranking harm against TermQuery; how much rankings move in real indexes was not measured.
  • Not affected. Which documents match, count() and filters. The probe (65 similarity configurations, 3 corpora, k in {1, 2, 3, 8, 64, 1023} copies with and without a second term, assertions off) found no NaN or infinite score, no hit dropped because of the repeat, and the same top-10 score values pruned and exhaustive. The only dropped hits were three IndriDirichletSimilarity k=1 baselines without a repeat, and negative scores appeared only under IndriDirichletSimilarity, which scores negative without repeats too (both a separate registry bug).
  • Below the bound the totalTermFreq is still counted per copy, but no documented contract breaks, so that is not claimed; the doubled per-document frequency (which changes BM25 too) is not claimed either.

Minimal repro

The test shows that SynonymQuery(f:a, f:a) counts the repeated term's totalTermFreq once per copy, so the pseudo-term statistics handed to the Similarity exceed the field's sumTotalTermFreq (documents "a" and "a a b").

TestSynonymQueryRepeatedTermStats.java (JUnit test)65 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.util.List;
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.search.similarities.Similarity;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSynonymQueryRepeatedTermStats extends LuceneTestCase {

  public void testRepeatedTermStatsStayWithinFieldTotals() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        w.addDocument(List.of(new TextField("f", "a", Field.Store.NO)));
        w.addDocument(List.of(new TextField("f", "a a b", Field.Store.NO)));
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        long[] seen = new long[2];
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setSimilarity(
            new Similarity() {
              @Override
              public SimScorer scorer(float boost, FieldStats fieldStats, TermStats... termStats) {
                seen[0] = termStats[0].totalTermFreq();
                seen[1] = fieldStats.sumTotalTermFreq();
                return new SimScorer() {
                  @Override
                  public float score(float freq, long norm) {
                    return freq;
                  }
                };
              }
            });
        Term a = new Term("f", "a");
        searcher.search(new SynonymQuery.Builder("f").addTerm(a).addTerm(a).build(), 10);
        // Expected (TermStats javadoc): totalTermFreq <= the field's sumTotalTermFreq (4); actual: 6.
        assertTrue(
            "pseudo-term totalTermFreq " + seen[0] + " > field sumTotalTermFreq " + seen[1],
            seen[0] <= seen[1]);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-628/, 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: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.TestSynonymQueryRepeatedTermStats
Expected vs actual
Expected, per the TermStats javadoc ("always true"): totalTermFreq <= sumTotalTermFreq = 4. Actual: totalTermFreq 6, with -ea and -da.

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

More files, environment, full output36 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: pseudo-term totalTermFreq 6 > field sumTotalTermFreq 4
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.search.TestSynonymQueryRepeatedTermStats.testRepeatedTermStatsStayWithinFieldTotals(TestSynonymQueryRepeatedTermStats.java:59)

Why it looks unintended

  • TermStats documents "totalTermFreq <= sumTotalTermFreq of the field" as "always true", and SimilarityBase asserts it.
  • SynonymQuery "tries to score the terms as if you had indexed them as one term"; a term added twice is still one term, and docFreq already counts it once.
  • Builder.addTerm asks for no unique terms, and stock synonym parsing produces repeats.
The full argument278 words
  • The bound (TermStats.java:32-38): "The following conditions are always true: ... totalTermFreq <= sumTotalTermFreq of the field"; @param totalTermFreq (:54-59): "never exceeds FieldStats#sumTotalTermFreq()"; the TODO (:62-63): "actually add missing cross-checks to guarantee TermStats is in bounds of FieldStats, otherwise many similarity functions will implode."
  • The assertion (SimilarityBase.java:80): added by LUCENE-3723 (2012, 4.0, then with a -1 escape for missing statistics) and rewritten by LUCENE-8007 (2017, 8.0.0), which also wrote the "always true" javadoc.
  • One term (SynonymQuery.java:47-48). Distinct terms cannot break the bound, because their occurrences are disjoint; only a repeat can.
  • Legal input. Builder.addTerm checks only the field, the boost range and the clause count, and documents no uniqueness requirement (:70-100). Upstream's own TestSynonymQuery.testRewrite builds a SynonymQuery from the same term twice and asserts that both entries are kept (LUCENE-9823).
  • Siblings keep the bound. CombinedFieldQuery.Builder keys fields in a map, so a repeated field counts once (CombinedFieldQuery.java:85, :117). On main BooleanQuery merges repeated SHOULD clauses (when minimumNumberShouldMatch <= 1) and repeated MUST clauses into one clause with a combined boost (BooleanQuery.java:438-455, :706-714), never into inflated statistics. The test framework draws a similarity's random term statistics so that the term "fits within the bounds of the corpus" (BaseSimilarityTestCase.java:203).
  • Precedents. LUCENE-8020 (#9068, fixed in 8.0): "we shouldn't pass terms that dont exist or bogus statistics", a general rule (the issue is about span queries and every similarity formula) whose fix also made SynonymQuery skip docFreq-0 terms. LUCENE-3723 (#4797, open): the same out-of-bounds symptom in FieldMaskingSpanQuery. LUCENE-7468 (#8520): an analysis filter emitting duplicate stacked tokens was fixed as a bug.
The devil's-advocate check216 words

Strongest case (rated "moderate" by the cross-model reviews, built in full by the intended-behaviour verifier): SynonymQuery treats its terms as a multiset. The javadoc says "scoring the sum of all term frequencies", build() never deduplicates, testRewrite keeps two identical terms, testRandomTopDocs forces term2 != term1 without saying why (TestSynonymQuery.java:476-480), and in LUCENE-8644 (#9690) a reviewer asked for an array rather than a map because duplicate terms "should be rare but still possible". Repeats may also be called a configuration issue: KeywordRepeatFilterFactory advises RemoveDuplicatesTokenFilter. The statistics API is @lucene.experimental (TermStats.java:60, SimilarityBase.java:38), and the pseudo-statistics are approximate by design (the max docFreq is not the docFreq of the union).

Why it fails: all of that is about whether repeats may exist and how much a repeat may weigh per document, which is not claimed. None of it allows corpus statistics above the field's totals, which describe the index, not the query; the max-docFreq approximation stays within the bounds, BooleanQuery expresses multiplicity as a boost, and no document, test or thread says out-of-range statistics are acceptable. The configuration argument does not cover direct Builder input or an ordinary-looking rule with an accent variant, about which nothing warns. A reviewer may still weigh that the recommended fix also changes multiset scoring below the bound.

Suggested fix

Recommended Count a repeated term's statistics once in SynonymWeight (recommended)

Recommended: in SynonymWeight, count a repeated term's statistics once (terms are sorted, so a repeat follows its first copy). The query, its terms, boosts and per-document frequencies stay as they are. Validated: the 3 new tests pass, and 3,013 core search, QueryBuilder, synonym and query-parser tests (the 3 new ones included) pass with assertions on and off.

        termStates[i] = ts;
        // terms are sorted, so a repeated term follows its first copy: count its statistics once,
        // otherwise totalTermFreq can exceed the field's sumTotalTermFreq
        boolean repeat = i > 0 && terms[i].term.equals(terms[i - 1].term);
        if (ts.docFreq() > 0 && repeat == false) {
          TermStats termStats = searcher.termStats(term, ts.docFreq(), ts.totalTermFreq());
All options and trade-offs4 options · 609 words

Option A ran through gradle in a scratch worktree at a9779ef3c193 (commands in the fix folder's validate.sh); option B was run and rejected; options C and D are untested alternatives. If the separate statistics-override fix (the ts.docFreq() > 0 guard on the same line) lands first, keep both conditions.

Count a repeated term's statistics once in SynonymWeight (recommended) (validated by the checks)

The change is +4/-1 lines in 1 file: skip the statistics of an entry whose term equals the previous sorted entry's. The docFreq max and the totalTermFreq sum then see each distinct term once, as for one indexed term; TermStates, the scorer and the per-document frequency sum are unchanged.

        termStates[i] = ts;
        // terms are sorted, so a repeated term follows its first copy: count its statistics once,
        // otherwise totalTermFreq can exceed the field's sumTotalTermFreq
        boolean repeat = i > 0 && terms[i].term.equals(terms[i - 1].term);
        if (ts.docFreq() > 0 && repeat == false) {
          TermStats termStats = searcher.termStats(term, ts.docFreq(), ts.totalTermFreq());

Trade-offs

  • Scores under the similarity formulas that use totalTermFreq (the LM family, DFI, some DFR and IB configurations) change for every SynonymQuery with a repeated term, also below the bound (its totalTermFreq is then counted once). Axiomatic, the DFR and IB configurations that ignore totalTermFreq, BM25 and Classic scores do not change.
  • TermStates is still built once per entry (unchanged cost).
  • What a term repeated with different boosts should mean stays open; the fix does not touch boosts.
  • It relies on sorted adjacency. Builder.addTerm(BytesRef, float) keeps the caller's BytesRef without copying it (SynonymQuery.java:95), so a caller that mutates it after build() already breaks the sort order and equals; QueryBuilder copies its bytes. A seen-set, or a deep copy in addTerm, would not depend on adjacency.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF, -Ptests.asserts=true and =false (commands in validate.sh).
  • The new tests: TestSynonymQueryRepeatedTermStats 2 of 2 and TestSynonymQueryFromFoldedRule 1 of 1 pass, assertions on and off (they fail 3 of 3 on stock main both ways).
  • Surrounding suites, 258 classes and 3,013 tests (including the 2 new classes and their 3 tests), 0 failures with assertions on (50 skipped) and off (52 skipped): :lucene:core:test --tests 'org.apache.lucene.search.*' (215 classes, 2,333 tests, including TestSynonymQuery with testRewrite), --tests org.apache.lucene.util.TestQueryBuilder (26), :lucene:analysis:common:test --tests 'org.apache.lucene.analysis.synonym.*' (12 classes, 121) and the whole :lucene:queryparser:test (30 classes, 533).
  • tidy left the patch unchanged; ecjLint (core main and test, analysis-common test), forbiddenApis (core, analysis-common) and checkGoogleJavaFormat (core, analysis-common) pass.
  • The same logic passed the finder's, the reproducer's and a reviewer's JUnits and upstream TestSynonymQuery (10 of 10) through the hunt's shadow-class runner.

Deduplicate in Builder.build() (rejected) (validated by the checks)

Merge repeated entries into one in build().

Trade-offs

Changes the query structure (getTerms(), equals, the per-document frequency, so BM25 scores too) and needs a rule for the boost; breaks upstream TestSynonymQuery.testRewrite, which asserts that two identical terms are kept.

What the checks validated

Executed with the first fix sketch: testRewrite fails, 1 of 10 TestSynonymQuery tests.

Stop producing repeats in the synonym parsers or QueryBuilder (follow-up, untested)

Skip a self-mapping in SolrSynonymParser/WordnetSynonymParser when two entries are equal after analysis, or deduplicate stacked tokens in QueryBuilder.newSynonymQuery.

Trade-offs

Does not cover direct Builder input or custom analysis chains (KeywordRepeatFilter), and changes the parsers' token output, a separate behaviour change.

Add the TODO's cross-check, or clamp (untested)

Validate TermStats against FieldStats, or clamp the pseudo-term's totalTermFreq to the field's sumTotalTermFreq.

Trade-offs

A check turns the silent -da case into an exception; a clamp hides the symptom while the statistics stay per copy below the bound.

Downstream note

Downstream: three widely used Lucene-based servers do not enable Java assertions in their default JVM options (read only), so there the effect is score and ranking only; which documents match never changes.

How it was found

A hunt unit that tests equivalences stated in javadoc took SynonymQuery's "score the terms as if you had indexed them as one term", decided in advance that only a violation of an invariant Lucene itself asserts would count, and compared SynonymQuery(a, a) with TermQuery(a) under 11 similarities. With assertions on, the SimilarityBase statistics assertion fired.

The method in full198 words

Method: differential testing of a javadoc claim (a hunt unit, 2026-10-26, main a9779ef3c193; the hunt's "semi-formal" tier: a hypothesis formed while modelling the claim, then witnessed by a tool). No model checker, SMT solver or prover was involved.

  • Before running, the unit wrote down a reading and an input domain for the sentence: for distinct terms the two halves of the javadoc agree; for a repeated term "as if indexed as one term" and "the sum of all term frequencies" disagree, so a doubled score alone would count as ambiguous, and only an invariant Lucene itself asserts, or a score neither reading allows, would count.
  • A differential probe on the stock main jar ran SynonymQuery(a, a), (a, a^0.5) and (a, a, b) against TermQuery(a) and SynonymQuery(a, b) over 11 similarities with -ea and -da. The -ea runs threw the SimilarityBase.fillBasicStats AssertionError; a reach probe then found that stock QueryBuilder and synonym parsers produce such queries.
  • An earlier unit (distributed-statistics scoring) had seen the same assertion for Synonym(f:aa f:aa^0.5) and noted it without pursuing it.
  • Then a cross-model review, independent verifiers, an adjudication, a release sweep, a prior-report search and a security screen (see Independent checks).

Prior reports, history and checks

Prior reports

No prior report: 166 recorded queries and reads over Lucene issues, PRs, commits, JIRA, the mailing lists, downstream trackers and the web, plus the changed files of all 409 open PRs (2026-10-27). Closest: a LUCENE-8644 review remark that duplicate synonym terms are "rare but still possible", with nothing on statistics.

Searches4 searches

None of the searched items reports that a repeated term makes SynonymQuery's pseudo-statistics exceed the field's totals. The nearest items show that repeats were known to reach SynonymQuery (LUCENE-8644), that out-of-range or bogus similarity statistics were treated as defects elsewhere (LUCENE-3723, LUCENE-8020), and where the bound and the repeated-term test came from (LUCENE-8007, LUCENE-9823). Word-based searches show what was found, not that no report exists anywhere; some searches were rate-limited.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr)2026-10-27a scripted prior-report search (75 queries: 30 GitHub issue/PR, 4 commit, 5 downstream, 24 mailing-list, 6 JIRA-archive, 6 web) and 49 manual issue/PR searches; a verifier's earlier 43 queries read as wellno report; #9690 (LUCENE-8644, duplicates "rare but still possible"), #4797 (LUCENE-3723, same symptom in FieldMaskingSpanQuery), #9068 (LUCENE-8020), #9055 (LUCENE-8007), #10862 / PR #160 (LUCENE-9823), #9698 (LUCENE-8652), #8520 (LUCENE-7468)
Open PRs2026-10-27the changed files of all 409 open apache/lucene PRs; the contents API on main, branch_10x and branch_10_6none changes the statistics loop; #418, #11928, #16851 touch SynonymQuery.java for other reasons, #16870 touches TestSynonymQuery
JIRA and mailing lists2026-10-2723 JQL searches over LUCENE and SOLR; 10 lists.apache.org searches (dev@, java-user@, users@solr)no report; SOLR-1670 (2009, duplicate same-position tokens from the old SynonymFilter, before SynonymQuery)
Downstream and web2026-10-27GitHub searches on Elasticsearch and OpenSearch, 6 code searches for deduplication workarounds, 6 web queriesno report; elastic/elasticsearch#41934 (a bound violation in that project's own cross-fields code)

History

Introduced with SynonymQuery itself in LUCENE-6789 (2015-09-14), first released in 6.0.0. Executed: 6.0.0, 7.7.3, 8.0.0, 8.11.4, 9.12.3, 10.5.2 and main fail with assertions on and off; 5.5.5, which has no SynonymQuery, passes. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-6789 "change IndexSearcher default similarity to BM25" (a944ab17a712, @rmuir, 2015-09-14; issue #7847; a patch on the JIRA issue, before Lucene used pull requests). It added SynonymQuery and switched QueryBuilder's stacked-token path to it; from the first version the weight took the max docFreq and summed totalTermFreq over every entry, and the terms were only sorted. No later commit deduplicated them.

The bound. The SimilarityBase assertion dates from LUCENE-3723 (1b9e732beda, 2012, 4.0; then with a -1 escape), so the -ea failure exists from 6.0.0. LUCENE-8007 (ca5f9b34576, 2017, 8.0.0) made statistics mandatory, wrote the "always true" javadoc and the TODO, and dropped the escape. Before 8.0, fields without frequencies reported totalTermFreq -1 and skipped the check, so in 6.x-7.x only fields with frequencies can break the bound (code reading). Later: LUCENE-8020 (8.0.0) skips docFreq-0 terms; LUCENE-8652 (8.1.0) added the Builder, which keeps repeats; LUCENE-9823 (9.0.0) added testRewrite; a 2026 rename on main changed no logic. Releases between the executed ones are inferred. Not fixed: upstream main a95c754ee003, branch_10x and branch_10_6 still sum per entry.

Versions and branches executed, with results
Version or commitVerdictResult
5.5.5 no SynonymQueryQueryBuilder builds a BooleanQuery f:dog f:dog f:puppy; every term's statistics in bounds (pass)
6.0.0 first affectedAPI-adapted test: 3 of 4 fail with -ea (6 > 4 twice; AssertionError in SimilarityBase), 2 of 4 with -da; the distinct-terms control passes
7.7.3, 8.0.0, 8.11.4 affected3 of 4 fail with -ea, 2 of 4 with -da
9.12.3, 10.5.2 affectedrelease variants: the finder's JUnit 3 of 3 (-ea) and 2 of 3 (-da); the reproducer's 3 of 3 both ways
main a9779ef3c193 affectedthis doc's tests 3 of 3 fail through gradle with assertions on and off; earlier JUnits fail with -ea and -da
main with the recommended fix not affectednew tests 3 of 3 pass; 3,013 core search, TestQueryBuilder, synonym and query-parser tests (the 3 new ones included), 0 failures, assertions on and off

Independent checks

An independent reproducer, cross-model reviews (OpenAI GPT-5.6, executed), a devil's advocate, an adjudication, the registration check and an audit all said real and re-ran it with their own tests or fresh seeds; one review caught that the first fix idea broke an upstream test. This doc's tests and fix ran through gradle.

Checks and verdicts442 words

All checks ran on main a9779ef3c193 unless noted; the cross-model reviews ran on the previous main ea57360912ed, where none of the involved files differ.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateJUnit fails 3 of 3 with -ea and 2 of 3 with -da on main, 3 of 3 on 10.5.2 and 9.12.3; passes with the fix; upstream TestSynonymQuery 10 of 10 with the fix.
Cross-model review, refute lens (OpenAI GPT-5.6, executed) real, highOwn JUnit fails 3 of 4 (-ea) and 2 of 4 (-da); KeywordRepeatFilter + PorterStemFilter gives Synonym(f:run f:run), 6 > 4. Found that deduplicating in Builder.build() breaks testRewrite, so the fix moved into SynonymWeight.
Independent reproducer realOwn JUnit (docs x y, x z, w; the résumé rule) fails 3 of 3 on main, 10.5.2 and 9.12.3 with -ea and -da: totalTermFreq 6 > 5, P(t|C) 1.1666666, 5 > 3; passes with the fix.
Intended-behaviour review (OpenAI GPT-5.6, executed) real, highRated the intended argument moderate (it covers repeat multiplicity, not statistics above the field totals); its fixed-expectation test and upstream TestSynonymQuery (10 of 10) pass with the fix.
Devil's advocate real, high for the narrow claimBuilt the multiset case (javadoc, testRewrite, BooleanQuery repeats, testRandomTopDocs) and found none of it permits statistics above the field totals; re-ran the finder's JUnit.
Adjudication confirmed, highThe independent reproduction's JUnit 3 of 3 fail with -ea and -da on a clean main (seed 5EEDFACE); passes with the fix.
Registration check and audit reproducedFresh seeds 5EED7201-06 and A0D17401-02: finder 3 of 3 (-ea), 2 of 3 (-da); the independent reproduction 3 of 3 both ways; both pass with the fix. History corrected: the assertion predates LUCENE-8007.
This doc (gradle) reproduced; fix validatedNew tests 3 of 3 fail on stock main with assertions on and off; with the fix they pass 3 of 3, and 3,013 surrounding tests (core search, TestQueryBuilder, synonym, query parser; the 3 new ones included) pass with assertions on and off; ecjLint, forbiddenApis and the format check pass.
Fact checks of this doc (an independent checker; OpenAI GPT-5.6) bug holds; wording correctedBoth say the bug holds; the independent checker re-ran the tests (3 of 3 fail on main with -ea and -da, pass with the fix) and its own probes. Between them they found six overstated points, now corrected: which similarities change with -da (not Axiomatic), the 1/k condition, the toy scores as harms, 'no dropped hit', the clause cap and BooleanQuery's dedup condition. The diagram check passed; the diagram was re-rendered with the same corrections.

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

Finding record

LFV-628, snapshot of 2026-11-06

Registry title
SynonymQuery with a repeated term adds that term's totalTermFreq once per copy, so the pseudo-term statistics it gives the Similarity can exceed the field's sumTotalTermFreq (AssertionError in SimilarityBase with -ea, scores from impossible statistics with -da)
Area
search / SynonymQuery pseudo-term statistics (SynonymWeight; reached through QueryBuilder with stock synonym parsers)
Priority
Medium, rank 98: Released since 6.0, it silently mis-scores SynonymQuery with a repeated term only under opt-in SimilarityBase similarities, which is narrower than the StableTfl synonym-scoring row it follows.
Impact class
silent wrong results
Impact note
Hits only applications that score with a similarity that reads totalTermFreq (the SimilarityBase family: LM Dirichlet, LM Jelinek-Mercer, Indri, DFR, IB, DFI, Axiomatic, or a custom one; the default BM25 and ClassicSimilarity never read it) and that run a SynonymQuery holding the same term more than once, built directly or by QueryBuilder (which the classic and simple query parsers extend) from an analyzer that stacks a token on itself, for example a SolrSynonymParser or WordnetSynonymParser rule with expand=true whose entries coincide after the rule analyzer ('résumé, resume, cv' with ASCII folding, 'Dog, dog, puppy' with lower-casing) or KeywordRepeatFilter plus a stemmer without RemoveDuplicatesTokenFilter; the documented bound breaks only when k copies of the term's totalTermFreq plus the other terms' exceed the field's sumTotalTermFreq, so mostly on short or low-cardinality fields and small indexes. Then, with -ea, building the query's Weight throws AssertionError; with -da, documents are scored from impossible statistics (score and ranking changes only: which documents match, count() and filters are unchanged, and a 2,340-case probe saw no NaN or infinite score and no dropped hit apart from the separate IndriDirichlet negative-score bug LFV-190). Default codec, writer and searcher; any index size.
Summary
You search with a SynonymQuery that contains the same term more than once, built with SynonymQuery.Builder or produced by QueryBuilder when a synonym rule's entries coincide after analysis (the rule 'résumé, resume, cv' with ASCII folding gives Synonym(f:cv f:resume f:resume) for the query 'resume'), and expect the pseudo-term statistics it hands to the Similarity to stay within the field's totals, which TermStats lists as 'always true' (totalTermFreq <= sumTotalTermFreq of the field). Instead SynonymWeight takes the largest docFreq of the terms but adds the repeated term's totalTermFreq once per copy: on a field with the documents 'a' and 'a a b' (sumTotalTermFreq 4), SynonymQuery(a, a) passes totalTermFreq 6. With assertions enabled every SimilarityBase similarity throws AssertionError from fillBasicStats when the Weight is built; with assertions disabled they score from impossible statistics, for example LM Dirichlet uses a collection probability P(t|C) of 1.17 for SynonymQuery(x, x, x) where TermQuery(x) uses 0.5.
Introduced
a944ab17a712f58e8417639763a0c7267fa11e61 (svn r1703070; LUCENE-6789 'change IndexSearcher default similarity to BM25', migrated to GitHub as apache/lucene#7847, by rmuir; a patch attached to the JIRA issue, no pull request). It added SynonymQuery: SynonymWeight took the max docFreq but summed totalTermFreq over every terms[] entry, and the constructor only sorted the terms [read: git log -S and blame in the history clone; exec: 5.5.5 has no SynonymQuery and passes, 6.0.0 fails]. The bound it breaks: the SimilarityBase.fillBasicStats assertion dates from LUCENE-3723 (1b9e732beda, 2012-01-25, 4.0; then with a -1 escape for missing statistics), and LUCENE-8007 (ca5f9b34576, 2017-11-02, 8.0.0) added the TermStatistics 'always true' javadoc and the cross-check TODO and dropped the -1 escape. No commit ever deduplicated SynonymQuery terms (git log -G on SynonymQuery.java). (2015-09-14)
First affected version
6.0.0 [exec] (released 2016-04-07), the first release with SynonymQuery; 5.5.5, the last 5.x release, has no SynonymQuery (its QueryBuilder builds a BooleanQuery whose per-term statistics stay in bounds) and is not affected [exec]
Affected versions
Every release from 6.0.0 to 10.6.0 (10.6.0 by reading) and main a9779ef3c193; never fixed. [exec] with -ea and -da: 6.0.0, 7.7.3, 8.0.0 and 8.11.4 (an API-adapted copy of the finder's test plus a distinct-terms control that passes everywhere), 9.12.3 and 10.5.2 (the finder's release variant and the independent reproduction's own test), main a9779ef3c193 (finder, the independent reproduction, the intended-behaviour check, the adjudication, the registration check); the cross-model reviews ran on the previous main ea57360912ed, where none of the involved files differ. [read]: upstream main a95c754ee003 (2026-10-26), branch_10x and branch_10_6 still sum per entry. Code reading, not executed: before 8.0, fields without frequencies reported totalTermFreq -1 and the assertion was skipped, so in 6.x-7.x only fields with frequencies can break the bound; since 8.0, DOCS-only fields report totalTermFreq = docFreq and can break it too.
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
semi-formal; also reached by fuzzing/PBT
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record verdict new): 166 queries and reads. a scripted prior-report search 75 (30 GitHub issue and PR queries on apache/lucene, 4 commit, 5 downstream, 24 mailing-list over dev@, java-user@, issues@ and commits@, 6 JIRA-archive, 6 web); manual 91 (49 GitHub issue and PR searches over apache/lucene, apache/solr, apache/lucene-solr, elastic/elasticsearch and opensearch-project/OpenSearch, 6 GitHub code searches for downstream dedupe workarounds, 23 JIRA JQL searches over LUCENE and SOLR, 10 lists.apache.org searches including users@solr, the changed-file lists of all 409 open apache/lucene PRs, the contents API on main, branch_10x and branch_10_6, and the commits API); plus the intended-behaviour check's 43 earlier queries. Not fixed upstream: SynonymQuery.java on upstream main a95c754ee003 (2026-10-26), branch_10x and branch_10_6 still sums per entry, and no open PR changes the statistics loop (#418, #11928 and #16851 touch SynonymQuery.java for impacts, the disjunction iterator and the PriorityQueue API; #16870 touches TestSynonymQuery for a flaky assertion). Related, none a report of this bug: LUCENE-8644 (#9690, open), where a reviewer (jimczi) said duplicate terms 'should be rare but still possible' in newSynonymQuery input, which shows repeats were known to reach SynonymQuery but discusses no statistics or scores; LUCENE-3723 (#4797, open), the same symptom class (a totalTermFreq above sumTotalTermFreq) in FieldMaskingSpanQuery; LUCENE-8020 (#9068, fixed), which treated bogus SynonymQuery pseudo-statistics (docFreq 0) as a defect; LUCENE-8007 (#9055), which added the bounds javadoc; LUCENE-9823 (#10862, PR #160), which added testRewrite (a repeated term kept twice, structure only); LUCENE-7468 (#8520), duplicate stacked tokens from an analysis filter fixed as a bug; SOLR-1670 (2009), duplicate same-position tokens from the old SynonymFilter called 'not really a serious bug' (analysis-side, before SynonymQuery existed); elastic/elasticsearch#41934, a downstream pseudo-statistics bound violation in that project's own cross_fields code. Limits: GitHub search reads only the first 50-100 results, and some manual searches hit the shared rate limit and were retried after a back-off.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-26 and 2026-10-27), JDK OpenJDK 25.0.3, Linux aarch64: this doc's two JUnit files through gradle with -Ptests.asserts=true and =false, on stock main and with the recommended fix; the finder's, the independent reproducer's and the cross-model reviewers' JUnits; the diagram probe (both examples, main and fix, assertions on and off); release runs on 5.5.5 (pass, no SynonymQuery) and 6.0.0, 7.7.3, 8.0.0, 8.11.4, 9.12.3, 10.5.2 (fail).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:44-50 (class javadoc), :103-106 (build() only sorts), :209-227 (max docFreq, totalTermFreq += per entry, pseudo TermStats)
  • lucene/core/src/java/org/apache/lucene/search/TermStats.java:32-38, 54-63 ('always true' bounds, @param totalTermFreq, TODO 'otherwise many similarity functions will implode'); similarities/SimilarityBase.java:78-81 (the assertion)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/SolrSynonymParser.java:116-127 (expand: all pairs i != j by index); analysis/miscellaneous/KeywordRepeatFilterFactory.java:26-28 ('consider adding RemoveDuplicatesTokenFilterFactory'); queryparser classic/QueryParserBase.java:61 and simple/SimpleQueryParser.java:101 (extend QueryBuilder)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:47-49, 79-106, 184-194, 209-227
  • lucene/core/src/java/org/apache/lucene/search/TermStats.java:32-38, 54-59, 62-63; similarities/SimilarityBase.java:78-89; similarities/LMSimilarity.java:72, 150-152
  • lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:603-609
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/SolrSynonymParser.java:116-127
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/KeywordRepeatFilterFactory.java:26-28
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:61; simple/SimpleQueryParser.java:101
  • lucene/core/src/java/org/apache/lucene/search/similarities/: getTotalTermFreq() in LMSimilarity, IndriDirichletSimilarity, DFISimilarity, BasicModelG, BasicModelIF, BasicModelIne, AfterEffectB, NormalizationH3, LambdaTTF; none in Axiomatic*, BasicModelIn, AfterEffectL, LambdaDF; LMDirichletSimilarity.java:81 (clamp)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:123 (default BM25Similarity)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/TermStats.java:32-38, 54-59, 62-63; similarities/SimilarityBase.java:79-80; SynonymQuery.java:47-49, 70-106
  • lucene/core/src/java/org/apache/lucene/search/CombinedFieldQuery.java:85, 117; BooleanQuery.java:438-447, 706-714
  • lucene/core/src/test/org/apache/lucene/search/TestSynonymQuery.java:476-480, 516-538
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:203
  • github.com/apache/lucene/issues/9068 (LUCENE-8020 body, read-only)
  • lucene/core/src/java/org/apache/lucene/search/TermStats.java:60, similarities/SimilarityBase.java:38 (@lucene.experimental)

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

Repro files

About the diagrams

A step-by-step picture of the résumé, resume, cv example on main, from the rule analysis to the pseudo-term statistics and what LM Dirichlet does with them; every value on screen is parsed from the diagram probe's executed runs on main and with the fix. The PNG is the final summary card. Made in parallel with this doc and re-rendered after the fact check (which similarities change with -da, the bound's condition, the step 4 scores shown next to TermQuery's).

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