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.
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.- You use query-time synonyms with the rule
résumé, resume, cv(SolrSynonymParser,expand=true), an analyzer that lower-cases and folds accents, andLMDirichletSimilarity.résuméfolds toresume, so the rule mapsresumeto itself, andQueryBuilderand the classic query parser turn the queryresumeintoSynonym(body:cv body:resume body:resume). - The index holds two documents,
resumeandresume cv: fieldbodyhas 3 tokens,resume2 of them andcv1. SynonymQueryscores its terms as one pseudo-term, so you expect it to occur at most 3 times, the whole field.TermStatslists totalTermFreq <= the field's sumTotalTermFreq as "always true".- 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 throwsAssertionErrorinSimilarityBase.fillBasicStats. With-da, LM Dirichlet computes its scores from a collection probability of 1.5, above 1 (0.75 forTermQuery(resume)). SynonymQuery.Buildertakes the repeat directly too:SynonymQuery(a, a)overaanda a bclaims 6 of 4 tokens (P(a|C) 1.4 with-da; 0.8 forTermQuery(a)). BM25 and Classic never read totalTermFreq, and which documents match never changes.
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.
- 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
TermStatsliststotalTermFreq <= sumTotalTermFreqof 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 oneTermStatsfor 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 builder keeps repeats.
Builder.addTermchecks 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. - The weight sums per entry.
SynonymWeightloops over every entry (:213-222):docFreq = Math.max(termStats.docFreq(), docFreq)(:219) buttotalTermFreq += termStats.totalTermFreq()(:220). k copies of a term add its totalTermFreq k times; the max counts it once. - The similarity gets it.
new TermStats("synonym pseudo-term", docFreq, totalTermFreq)goes tosimilarity.scorer(:225-227).TermStatsdoes not check it against the field; that is a TODO (TermStats.java:62-63: "otherwise many similarity functions will implode"). - Where it breaks.
SimilarityBase.fillBasicStatsassertstotalTermFreq <= 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. - 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 witha1 of 4 tokens). Short or low-cardinality fields, small indexes. - Not matching. Without scores the query becomes a
BooleanQueryof SHOULDTermQuerys (SynonymQuery.java:186-194) and never builds the pseudo-term, so hits,count()and filters are unchanged.
- Synonym rules whose entries coincide after analysis.
SolrSynonymParserwithexpand=trueadds every pairi != jby position in the rule, after analysing each entry (SolrSynonymParser.java:116-127).résumé, resume, cvwith ASCII folding, orDog, dog, puppywith lower-casing, maps a term to itself;SynonymGraphFilteremits it twice, andQueryBuilder.newSynonymQuerycopies every stacked token into the builder (QueryBuilder.java:603-609). The classic and simple query parsers extendQueryBuilder.WordnetSynonymParserdoes the same. - KeywordRepeatFilter plus a stemmer, without
RemoveDuplicatesTokenFilter, for a word already in stem form:Synonym(f:run f:run). Weaker, becauseKeywordRepeatFilterFactoryadvises adding that filter. - Code that adds the same term to
SynonymQuery.Buildertwice.
| query and index | pseudo-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 > 3 | AssertionError at SimilarityBase.java:80 | P(t|C) 1.5; scores 1.67e-4 and 0.0 |
the same with resume counted once (fix) | docFreq 2, totalTermFreq 3 | passes | P(t|C) 1.0; scores 5.00e-4 and 4.99e-4 |
TermQuery(body:resume) (control) | docFreq 2, totalTermFreq 2 | passes | P(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 > 4 | AssertionError | P(a|C) 1.4; scores a 2.14e-4, a a b 0.0 |
the same with a counted once (fix) | docFreq 2, totalTermFreq 3 | passes | P(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) | pass | TermQuery(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
SimilarityBaseor custom similarity withIndexSearcher#setSimilarity. With assertions on, everySimilarityBasesimilarity 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 defaultBM25Similarity(IndexSearcher.java:123),ClassicSimilarityandBooleanSimilaritynever read totalTermFreq, so the statistics part does not change their scores. Codec, writer, merge policy and searcher stay default. - Trigger. A
SynonymQuerythat holds the same term more than once: from code, or fromQueryBuilderand the stock query parsers when the analysis chain stacks a token on itself (synonym rules with case or accent variants underexpand=true, orKeywordRepeatFilterwithoutRemoveDuplicatesTokenFilter). - 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):
AssertionErrorwhile 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 againstTermQuery; 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 threeIndriDirichletSimilarityk=1 baselines without a repeat, and negative scores appeared only underIndriDirichletSimilarity, 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.
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").
/*
* 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]);
}
}
}
}
# 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
TermStatsjavadoc ("always true"): totalTermFreq <= sumTotalTermFreq = 4. Actual: totalTermFreq 6, with -ea and -da.
More files, environment, full output36 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 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)TermStatsdocuments "totalTermFreq <= sumTotalTermFreq of the field" as "always true", andSimilarityBaseasserts 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.addTermasks 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<=sumTotalTermFreqof 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-1escape 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.addTermchecks only the field, the boost range and the clause count, and documents no uniqueness requirement (:70-100). Upstream's ownTestSynonymQuery.testRewritebuilds aSynonymQueryfrom the same term twice and asserts that both entries are kept (LUCENE-9823). - Siblings keep the bound.
CombinedFieldQuery.Builderkeys fields in a map, so a repeated field counts once (CombinedFieldQuery.java:85,:117). On mainBooleanQuerymerges repeated SHOULD clauses (whenminimumNumberShouldMatch <= 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
SynonymQueryskip docFreq-0 terms. LUCENE-3723 (#4797, open): the same out-of-bounds symptom inFieldMaskingSpanQuery. 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.
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.
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
SynonymQuerywith 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. TermStatesis 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'sBytesRefwithout copying it (SynonymQuery.java:95), so a caller that mutates it afterbuild()already breaks the sort order andequals;QueryBuildercopies its bytes. A seen-set, or a deep copy inaddTerm, would not depend on adjacency.
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, seed DEADBEEF,-Ptests.asserts=trueand=false(commands invalidate.sh). - The new tests:
TestSynonymQueryRepeatedTermStats2 of 2 andTestSynonymQueryFromFoldedRule1 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, includingTestSynonymQuerywithtestRewrite),--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). tidyleft 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.
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.
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.
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: 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.
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)againstTermQuery(a)andSynonymQuery(a, b)over 11 similarities with-eaand-da. The-earuns threw theSimilarityBase.fillBasicStatsAssertionError; a reach probe then found that stockQueryBuilderand 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).
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr) | 2026-10-27 | a 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 well | no 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 PRs | 2026-10-27 | the changed files of all 409 open apache/lucene PRs; the contents API on main, branch_10x and branch_10_6 | none changes the statistics loop; #418, #11928, #16851 touch SynonymQuery.java for other reasons, #16870 touches TestSynonymQuery |
| JIRA and mailing lists | 2026-10-27 | 23 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 web | 2026-10-27 | GitHub searches on Elasticsearch and OpenSearch, 6 code searches for deduplication workarounds, 6 web queries | no report; elastic/elasticsearch#41934 (a bound violation in that project's own cross-fields code) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 5.5.5 | no SynonymQuery | QueryBuilder builds a BooleanQuery f:dog f:dog f:puppy; every term's statistics in bounds (pass) |
| 6.0.0 | first affected | API-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 | affected | 3 of 4 fail with -ea, 2 of 4 with -da |
| 9.12.3, 10.5.2 | affected | release variants: the finder's JUnit 3 of 3 (-ea) and 2 of 3 (-da); the reproducer's 3 of 3 both ways |
| main a9779ef3c193 | affected | this 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 affected | new 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 |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | JUnit 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, high | Own 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 | real | Own 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, high | Rated 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 claim | Built 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, high | The 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 | reproduced | Fresh 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 validated | New 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 corrected | Both 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 436 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,436 words
- 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=trueand=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).
- 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-227lucene/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-152lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:603-609lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/SolrSynonymParser.java:116-127lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/KeywordRepeatFilterFactory.java:26-28lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:61; simple/SimpleQueryParser.java:101lucene/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-106lucene/core/src/java/org/apache/lucene/search/CombinedFieldQuery.java:85, 117; BooleanQuery.java:438-447, 706-714lucene/core/src/test/org/apache/lucene/search/TestSynonymQuery.java:476-480, 516-538lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:203github.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)
- TestSynonymQueryRepeatedTermStats.java (JUnit test):
repro/LFV-628/src/org/apache/lucene/search/TestSynonymQueryRepeatedTermStats.java(65 lines)
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).
assets/LFV-628/bug.png(436 KB)
