StableTflSimilarity scores a term as rarer the longer it is, and reads the length from the term bytes the query passes in. SynonymQuery and CombinedFieldQuery pass a fixed placeholder string there, so the similarity scores the placeholder's length, not the query term's.
- Set
new StableTflSimilarity()on the searcher. Instead of IDF it uses a rarity that grows with the query term's length: in a 40-token documentab(2 characters) gets rarity 2.105, a 19-character term 3.091, near the cap log(22). - Search
SynonymQuery{ab, cd}on a 40-token document that containsabonce. Both synonyms have 2 characters, so you expect theTermQuery(ab)score, 0.957 (BM25 gives the two equal scores). - You get 1.405 (+47%):
SynonymQuerypassesnew TermStats(new BytesRef("synonym pseudo-term"), ...), and the explanation shows19.0 = tl, term length. A one-fieldCombinedFieldQuerypasses "pseudo_term" (11.0 = tl): 1.405 instead of the copy-field 1.365. - Rankings flip: with a synonym analyzer,
QueryBuilderturns "dogs elephant" intoSynonym(dog dogs) elephant. In 200-token documents the "dog" document scores 1.405 and outranks the "elephant" document (1.325), althoughTermQuery(dog)scores 0.661. - No error, with assertions on or off, and the set of matching documents does not change. StableTfl is opt-in and new in 10.6.0 (released 2026-10-29; main,
branch_10_6); a two-line fix passes the core tests.
Applications that opt in to StableTflSimilarity and search with synonyms (a SynonymQuery built directly or by QueryBuilder and the classic and simple parsers when the analyzer stacks tokens in unquoted text) or with CombinedFieldQuery. Any index size; the error is largest for short terms in long documents. The class first shipped in 10.6.0 (2026-10-29).
Mechanism, terms and numbers712 words
StableTflSimilarity, new in 10.6.0 (released 2026-10-29), replaces IDF with a rarity that grows with the length of the query term, and reads that length from TermStats#term; it is the only similarity in core that does. SynonymQuery and CombinedFieldQuery merge statistics over several terms or fields and pass a fixed placeholder there, "synonym pseudo-term" (19 code points) and "pseudo_term" (11). So every synonym group that is scored as a SynonymQuery (an empty group and a single unboosted term are rewritten to other queries first) gets the rarity of an almost maximally rare 19-character term, and every combined-field term that of an 11-character one, whatever was searched. SynonymQuery{ab, cd} on a 40-token document containing ab once scores 1.4050031 instead of TermQuery(ab)'s 0.9569799 (BM25 gives equal scores); a one-field CombinedFieldQuery scores 1.4047582 instead of the copy-field TermQuery's 1.3647432; and through QueryBuilder with a synonym analyzer, a 200-token "dog" document outranks an "elephant" document. The error is negligible for long terms in short documents and large for short terms in long documents. Only scores and ranking (and so the top hits) change; the set of matching documents does not. The placeholders date from 2017 and 2018 and were harmless until #16243 added a similarity that reads the term bytes; 10.6.0, main, branch_10_6 and branch_10x are affected, no earlier release is.
- StableTfl rarity
tr:p = 1 - (1 - m * 2^(-c * |t|))^|d|,tr = log(1 + (1 - p + 0.05) / (p + 0.05)),score = boost * tr * tf,tf = freq / (freq + k1)(StableTflSimilarity.java:36-43,:170-175).|t|is the term length in code points,|d|the document length;m = 0.00781,c = 0.917,k1 = 1.2. Longer terms are rarer, andtris capped atlog(22)= 3.091. - Placeholder: the fixed string that
SynonymQueryandCombinedFieldQueryput into the oneTermStatsthey hand the similarity, because their statistics are merged over several terms or fields.
Rarity at the repro's document lengths (freq 1, tf = 0.4545; hand-computed from the formula, matching the executed scores):
| term | length | doc length | tr | score |
|---|---|---|---|---|
ab | 2 | 40 | 2.105 | 0.9569799 |
placeholder synonym pseudo-term | 19 | 40 | 3.091 | 1.4050031 |
foo | 3 | 4 | 3.002 | 1.3647432 |
placeholder pseudo_term | 11 | 4 | 3.090 | 1.4047582 |
dog | 3 | 200 | 1.453 | 0.66057557 |
elephant | 8 | 200 | 2.915 | 1.325023 |
placeholder synonym pseudo-term | 19 | 200 | 3.091 | 1.4049385 |
- The reader.
StableTflSimilarity#scorertakesgetTermLength(termStats[i].term())(StableTflSimilarity.java:195), the code-point count (:181-187), and cachestr(termLength, docLength)for all 256 norm values (:190-201);scoretakes the cached value for the document's norm and applies thetfsaturation,weight - weight / (1 + freq / k1)(:244-245). Its javadoc: rarity "based on term length and document length, rather than relying on corpus-level statistics" (:30-31). - SynonymQuery.
SynonymWeighttakes the maximumdocFreqand the summedtotalTermFreqof the synonyms and calls the similarity once withnew TermStats(new BytesRef("synonym pseudo-term"), docFreq, totalTermFreq)(SynonymQuery.java:209-227):|t|= 19 for every group that reaches this weight, even{a, b}; an empty group and a single unboosted term are rewritten to other queries before (:160-168). - CombinedFieldQuery.
CombinedFieldWeightmerges the per-field statistics and passesnew TermStats(new BytesRef("pseudo_term"), ...)(CombinedFieldQuery.java:274-291):|t|= 11, although the query has exactly one term (:86-96,:135). - Reach. For unquoted text,
QueryBuilderbuilds aSynonymQuerywhen the analyzer stacks tokens at one position (QueryBuilder.java:395,:409,:555,:603), and the classic and simple query parsers extend it; quoted text with stacked tokens becomes aMultiPhraseQuery(:347-351), which passes real terms.TermQueryandPhraseQuerypass real term bytes (IndexSearcher.java:1132; a recording-Similarity probe confirmed it), and no other core similarity reads the bytes. - Direction and size.
trfalls towards 0.047 for short terms in long documents while a 19-character term stays near 3.09, so synonym groups of ordinary words are over-weighted, more so in longer documents:Synonym{dog, dogs}/TermQuery(dog)is 1.07x at about 10 tokens, 2.13x at 200, 7.56x and 66x at decoded document lengths of about 984 and 9,240 (executed). InCombinedFieldQuery, terms shorter than 11 characters gain and longer ones lose; a 14-character term on a 4-token document changes by 0.016%.
Exposure and likelihood167 words
- Who is exposed. Applications that set
StableTflSimilarity(default constructor is enough) and run aSynonymQuery, built directly or byQueryBuilderand the classic and simple query parsers when the analyzer puts several tokens at one position in unquoted text (a synonym filter, or keyword-repeat stemming where the stem differs from the word), or aCombinedFieldQuery. - Default or opt-in. Opt-in similarity (
@lucene.experimental); everything else is ordinary: default codec, plainIndexSearcher, the standard query builders. - Releases. 10.6.0 (released 2026-10-29), where StableTfl first shipped; main,
branch_10_6andbranch_10xare affected too. - Scale. Any index size, ordinary text. Negligible for long terms in short documents (0.016%), large for short terms in longer documents (+47% at 40 tokens, 2.13x at 200).
- Symptom. Silent: wrong scores and ranking, no exception with assertions on or off; the explanation prints the placeholder's length as
tl. The set of matching documents is unchanged; which documents make the top N can change. - Impact class: silent wrong results (priority Medium, likelihood plausible).
The test shows that with StableTflSimilarity, SynonymQuery passes the placeholder term "synonym pseudo-term" (19 code points) to the similarity instead of its real terms, so a synonym group gets the rarity of a 19-character term.
/*
* 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.StableTflSimilarity;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestStableTflSynonymQuery extends LuceneTestCase {
public void testSynonymGroupScoresLikeTermQuery() throws Exception {
StableTflSimilarity sim = new StableTflSimilarity();
try (Directory dir = newDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setSimilarity(sim))) {
w.addDocument(List.of(new TextField("f", "ab", Field.Store.NO)));
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setSimilarity(sim);
Query term = new TermQuery(new Term("f", "ab"));
Query synonyms = new SynonymQuery.Builder("f").addTerm(new Term("f", "ab")).addTerm(new Term("f", "cd")).build();
// expected: the TermQuery(ab) score (both synonyms are 2 code points); actual: the score of the 19-code-point "synonym pseudo-term"
assertEquals(searcher.search(term, 1).scoreDocs[0].score, searcher.search(synonyms, 1).scoreDocs[0].score, 1e-6f);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-261/, 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.TestStableTflSynonymQuery
- Expected vs actual
- Expected: SynonymQuery{ab, cd} on a document with the single token "ab" scores like TermQuery(ab), 1.3855287. Actual (-ea and -da): 1.4050188.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected:<1.3855287> but was:<1.4050188>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:577)
org.junit.Assert.assertEquals(Assert.java:701)
org.apache.lucene.search.TestStableTflSynonymQuery.testSynonymGroupScoresLikeTermQuery(TestStableTflSynonymQuery.java:44)- StableTfl's javadoc defines rarity from the query term's length;
TermStatsholds "statistics for a specific term", andSimilaritymay "freely use any combination of statistics". CombinedFieldQueryhas exactly one query term, andTestCombinedFieldQueryasserts the one-field copy-field equality (its similarity list predates StableTfl).- Equal-length synonyms
{ab, cd}have length 2 but are scored as 19; nothing documents an exclusion.
The full argument248 words
- Contracts. StableTfl: rarity from
|t|(StableTflSimilarity.java:29-43), andtermLengthsis "the decoded UTF-8 term length (in code points) of the i-th query term" (:217-220).TermStats: "Contains statistics for a specific term",term= "Term bytes" (TermStats.java:22-23,:46).Similarity: the statistics passed in "already contain all of the raw statistics involved, so a Similarity can freely use any combination of statistics" (Similarity.java:72-77).SynonymQuery"tries to score the terms as if you had indexed them as one term" (SynonymQuery.java:47-49).CombinedFieldQuery's requirements "hold for all similarities that don't customize computeNorm" (CombinedFieldQuery.java:60-64); StableTfl does not customize it. - Tests.
TestCombinedFieldQuery#testCopyFieldWithSingleFieldasserts that a one-fieldCombinedFieldQueryscores like aTermQueryon a copy field; its similarity list (:392-400) dates from LUCENE-9725 (2021). Adding StableTfl to it fails 6 of 336 iterations on stock and passes with the fix.TestStableTflSimilarityuses only real words (including the constant "term" of the inheritedBaseSimilarityTestCasetests); no test mentions the placeholders. - History.
"synonym pseudo-term"replaced anullterm in LUCENE-8020 (2017), whose stated intent is "we shouldn't pass terms that dont exist or bogus statistics" (said about bogusdocFreqstatistics, not about the term bytes);"pseudo_term"came from the sandbox BM25FQuery (LUCENE-8216, 2018), which forced BM25. No similarity read the bytes until #16243, whose reviews never mention synonyms, combined fields or placeholders. - Precedent. LUCENE-10039 (2021): a one-field
CombinedFieldQuerythat scored unlike the copy field was fixed as a bug (different cause).
The devil's-advocate check172 words
The strongest case for "intended" (built by the devil's advocate, an OpenAI GPT-5.6 reviewer and the adjudication): the placeholders predate StableTfl and only satisfied a non-null check, so the term bytes of merged statistics never had a meaning; a synonym group of different lengths has no natural length, and a constant is at least deterministic; StableTfl, Similarity, TermStats and CombinedFieldQuery are @lucene.experimental; and TestCombinedFieldQuery does not list StableTfl.
Why it fails: CombinedFieldQuery has one query term, so there is nothing to choose; {ab, cd} has length 2 and {a, b} length 1, yet both get 19; the Similarity and TermStats javadocs allow reading the bytes and call them the term's; the first term in sorted order is just as deterministic; experimental status allows API changes, not wrong scores; and the test's list predates StableTfl. The devil's advocate rated the case weak to moderate, the adjudication confirmed with high confidence. Conceded: the length for a mixed-length synonym group is a design choice, and documenting a limitation is a possible maintainer answer.
Recommended Pass a real query term instead of the placeholder (SynonymQuery's length rule left to the maintainers)
Pass a real query term instead of the placeholder: CombinedFieldQuery its query term, SynonymQuery its first term in sorted order (two statements, no API change); the repro, the core module (8,873 tests), the queryparser module and :lucene:core:check pass. Recommended for CombinedFieldQuery; for SynonymQuery the length rule is a maintainer decision.
// CombinedFieldQuery.CombinedFieldWeight
TermStats pseudoTermStats = new TermStats(term, docFreq, Math.max(1, totalTermFreq));
// SynonymQuery.SynonymWeight
TermStats pseudoStats = new TermStats(terms[0].term, docFreq, totalTermFreq);All options and trade-offs5 options · 640 words
Option A was built and validated with gradle in a private clone of ea57360912ed; A2, B, C and D were not run. An optional test change adds StableTflSimilarity to TestCombinedFieldQuery#randomCompatibleSimilarity.
Pass a real query term instead of the placeholder (SynonymQuery's length rule left to the maintainers) (validated by the checks) (recommended)
CombinedFieldQuery passes its query term term; SynonymQuery passes terms[0].term, the first term in sorted order (the builder sorts, SynonymQuery.java:104). +5/-4 in two files, no API change; on branch_10_6 the class is still TermStatistics. Recommended for CombinedFieldQuery, which has exactly one real term; for SynonymQuery, "first sorted term" is the validated minimal choice that removes the placeholder, not a recommendation of that rule (the adjudication's caveat): the rule is a maintainer decision.
// CombinedFieldQuery.CombinedFieldWeight
TermStats pseudoTermStats = new TermStats(term, docFreq, Math.max(1, totalTermFreq));
// SynonymQuery.SynonymWeight
TermStats pseudoStats = new TermStats(terms[0].term, docFreq, totalTermFreq);Trade-offs
- Mixed-length synonyms. "First sorted" is arbitrary with respect to length:
{dog, dogs}gets "dog" (3),{colour, color}gets "color" (5). It is deterministic and corpus-independent, which keeps StableTfl's promise that rarity does not come from corpus statistics; do not pick, for example, the highest-docFreqsynonym. "First" isBytesReforder: it ignores length and boosts, and it can be a synonym that is absent from the index while another one matches. - One term's bytes with merged statistics. The
TermStatspairs one synonym's bytes with the group's mergeddocFreqandtotalTermFreq; no other core similarity reads the bytes, so BM25 and the rest are unchanged. Under StableTfl the score, the explanation and possibly the ranking change; matching does not. An outside similarity that reads the bytes would see the change (experimental APIs). - Sandbox copy. The deprecated sandbox
CombinedFieldQuery(lucene/sandbox/.../CombinedFieldQuery.java:325) has the same placeholder, but it holds several terms (Builder.addTermat:131-137,BytesRef[] termsat:155, sorted at:169), likeSynonymQuery; aterms[0]-style change would apply, with the same mixed-length question (not tested).
What the checks validated
- Stock: the repro fails 3 of 5 (assertions on and off), the finder's test 3 of 3; with StableTfl added to
TestCombinedFieldQuery's similarity list, 24 iterations fail 6 of 336 (testCopyFieldWithSingleField,testCopyFieldWithMissingFields,testOverrideFieldStats), plus a suite-level file-handle-leak error that follows from those failures. - With the fix: the repro passes 5 of 5 (assertions on and off), the finder's 3 of 3,
TestCombinedFieldQuerywith StableTfl 336 of 336;TestCombinedFieldQuery,TestSynonymQuery,similarities.*andTestQueryBuilder(29 classes, 168 tests), the whole core module (749 classes, 8,873 tests, 322 skipped) and:lucene:queryparser:test(533 tests, 1 skipped) have 0 failures;:lucene:core:check -x testpasses; tidy leaves the patch unchanged. - Standalone harness, fixed classes shadowed: the repro passes 5 of 5.
Pass the shortest synonym (never rates the group rarer than its least rare member), the longest, or the highest-boost one with ties broken by sort order (follows the application's synonym weights).
Trade-offs
As cheap as A, but couples SynonymQuery to a notion of length only StableTfl uses. Any of them gives {dog, dogs} length 3 or 4, so the repro's "elephant" document ranks first either way.
Let a Similarity receive every synonym's statistics through a separate method.
Trade-offs
Most principled, but an API change touching every Similarity; the built-in similarities, BM25 included, add up several TermStats passed to scorer (as for phrases), so passing every synonym's statistics there would change BM25's synonym scores.
Javadoc: StableTfl's term length is undefined for SynonymQuery and CombinedFieldQuery.
Trade-offs
Cheap, but leaves the unambiguous cases (one-field CombinedFieldQuery, equal-length synonyms) wrong.
Special-case the two strings in the similarity.
Trade-offs
Fragile, and there is still no real length to use.
Code reading: while modelling StableTfl's scorer, the finder noticed it is the only similarity in core that reads the term bytes, and a search of every place that builds term statistics showed the two placeholders. A public-API JUnit on the stock jar then showed the wrong scores; no solver output was involved.
The method in full125 words
Method: non-formal. A hunt unit (new similarities, 2026-10-26). The finder, an AI tool, was building an SMT model of StableTflSimilarity's norm monotonicity. Reading the scorer's inputs, it noticed that StableTfl is the only Similarity in core that reads TermStats#term; a grep of every new TermStats( call site then showed the placeholders in SynonymQuery and CombinedFieldQuery. It wrote a public-API JUnit on the stock jar. The model's output played no part.
Then: cross-model reviews (OpenAI GPT-5.6, reading only; one of them is the root-cause adversary, whose executed checks added a recording-Similarity probe), an independent reproducer with BM25 controls, an intended-behaviour devil's advocate, an adjudication, a history run on 7 builds, a prior-report search, a security probe, a credibility audit and this doc's gradle validation.
No prior report found (2026-10-26): three scripted prior-report search runs over GitHub, JIRA, the mailing lists, downstream repositories and the web (389 hits), 98 GitHub searches, the changed-file lists of all 413 open PRs and the diffs of the 12 that touch related files; 15 hits read and classified, none this bug (dated search results). Not fixed upstream.
Searches5 searches
Verdict new. Closest items, context not reports: #16243 and #16322 (the introducing change; the reviews never mention synonyms, combined fields or placeholders), LUCENE-8020 and LUCENE-8216 (where the placeholders came from), LUCENE-10039 (the 2021 one-field CombinedFieldQuery precedent), LUCENE-8943 (open: IDF over several term statistics) and #14262 (open, stale: would widen the trigger). Gaps: shared GitHub search throttling (one run completed on a rerun), inline review comments covered only for the PRs read directly, private channels not searchable.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | 57 GraphQL searches (StableTfl, "synonym pseudo-term", pseudo_term, getTermLength, "term length" with similarity or synonym, placeholder/bogus term, SynonymQuery or CombinedFieldQuery with StableTfl) plus the scripted prior-report search's issue, PR and commit queries; full reads of #16243 and #16322 | only the introducing PR and its backport are about StableTfl; no report |
| Open apache/lucene PRs | 2026-10-26 | the changed-file lists of all 413 open PRs, and the diffs of the 12 that touch related files | 12 touch related files; none changes the placeholders or the term() read |
| Downstream repositories and all of GitHub | 2026-10-26 | 41 GraphQL searches | no report |
| JIRA archive and mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | scripted prior-report search queries; LUCENE-8020, -8216, -8943, -9725 and -10039 and two SynonymQuery list threads read | origins of the placeholders and related history; no report |
| Web | 2026-10-26 | 10 searches | the StableTfl authors' blog post and product docs; neither mentions synonyms or multi-field queries |
Introduced on main by #16243 (e3a7a733c8a9, 2026-07-01), which added StableTfl and its term-length read; backported to branch_10x and in branch_10_6. The placeholders date from 2017 and 2018. First released in 10.6.0 (2026-10-29); not fixed upstream.
Versions tested5 rows
Introduced by "StableTfl Similarity" (#16243, e3a7a733c8a9, author @txwei, merged by @mccullocht on 2026-07-01); backport #16322 (8e40c912555b) to branch_10x, contained in branch_10_6. An interaction bug: "synonym pseudo-term" dates from LUCENE-8020 (e0bde579815, 2017), "pseudo_term" from LUCENE-8216 (fd96bc5ca6b, 2018, sandbox) and moved to core with #14236 (2025; 239059e69cf on main, its branch_10x backport cddee429c70 in 10.2.0); both were harmless while no similarity read the term bytes. First affected version: 10.6.0, released 2026-10-29 (CHANGES.txt:488).
| Version or commit | Verdict | Result |
|---|---|---|
| 9.12.3, 10.5.2 and every older release | class absent | the repro does not compile: StableTflSimilarity does not exist |
e3a7a733c8a9 (#16243) vs parent d87b4f388151 | introduced | fails 3 of 3; the parent does not compile (class absent) |
branch_10_6 2763db11b0a7, branch_10x 1309534296e3 | snapshots | fail 3 of 3 with identical values (ASF nightly snapshot jars, run with main's third-party dependencies; SHA-1s recorded) |
main ea57360912ed, main a9779ef3c193 | affected | this doc's test fails 3 of 5 (on ea57360912ed with assertions on and off); the BM25 controls pass |
fix (private clone of ea57360912ed) | not affected | 5 of 5 pass; the core module and queryparser tests pass |
An independent reproducer, a root-cause adversary with its own probe, a devil's advocate, OpenAI GPT-5.6 reviews (reading only) and an adjudication checked it: every executed check reproduced it, and the adjudication confirmed it with high confidence. A credibility audit kept it.
Checks and verdicts283 words
The hunt's checks: the finder's candidate, cross-model reviews, verifiers, an adjudication, the follow-up stages (history, prior art, security), registry re-runs and a credibility audit, and this doc's gradle validation.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Public-API JUnit: 3 of 3 fail with assertions on and off, 30 of 30 over 10 iterations; the fix sketch passes. |
| Cross-model reviews (OpenAI GPT-5.6, reading only) | real (medium); not real (low, procedural) | The intended lens rated the intended-behaviour case moderate and rejected it; the refute lens said "not real" only because it could not run code, and its reading agreed with the root cause. |
| Independent reproducer | real | Own 5-test JUnit (this doc's test): 3 of 5 fail, the BM25 controls pass; shadow classes passing real terms make all 5 pass; 10.5.2 and 9.12.3 do not compile. |
| Root-cause adversary (OpenAI GPT-5.6, reading only, plus executed checks) | real (medium) | Root cause agrees; a recording-Similarity probe shows SynonymQuery, the QueryBuilder path and CombinedFieldQuery pass the placeholder bytes, TermQuery and PhraseQuery real terms; 64 upstream tests pass with the fix. |
| Devil's advocate | real (high) | Built the strongest intended case and rated it weak to moderate; its probe shows tl=19.0 even for {a, b}. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's and the finder's tests with a new seed. |
| Registry re-runs and credibility audit | keep | Re-ran the finder's and the independent reproduction's tests with fresh seeds and the fix; score and class checked. |
| This doc's fix validation | fix validated | Gradle in a private clone: the repro fails on stock and passes with the fix, as do the core module and the queryparser module. |
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 488 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,340 words
- Registry title
- StableTflSimilarity scores SynonymQuery and CombinedFieldQuery with the length of a placeholder term string ('synonym pseudo-term', 19 code points, or 'pseudo_term', 11), not the length of the query terms
- Area
- search / similarities: StableTflSimilarity with SynonymQuery and CombinedFieldQuery
- Priority
- Medium, rank 97: In the opt-in StableTflSimilarity new in 10.6.0 every synonym and CombinedFieldQuery search is mis-scored while the matching documents stay the same.
- Impact class
- silent wrong results
- Impact note
- Only applications that opt in to StableTflSimilarity (new in 10.6.0, released 2026-10-29; on main and branch_10_6) and search with a SynonymQuery (built directly, or by QueryBuilder and the classic and simple query parsers whenever the analyzer stacks synonyms at one position) or with a CombinedFieldQuery; default constructor, any index size; only scores and ranking change, never the set of matching documents; the error is negligible for long terms in short documents (0.016% measured) and large for short terms in long documents (+47% for {ab, cd} in a 40-token document, 2.13x for {dog, dogs} in 200-token documents, enough to rank a 'dog' document above an 'elephant' document although TermQuery(dog) scores half of TermQuery(elephant)).
- Summary
- You set StableTflSimilarity on the searcher, whose javadoc derives a term's rarity from the length |t| of the query term, and you search with a SynonymQuery (for example the one QueryBuilder builds when a synonym filter stacks 'dog' and 'dogs' at one position) or with a CombinedFieldQuery. You expect the real query terms' length to be used, so that SynonymQuery{ab, cd} on a document that contains 'ab' once scores like TermQuery(ab), and a one-field CombinedFieldQuery scores like a TermQuery on a copy field. Instead both queries hand the similarity a fixed placeholder term ('synonym pseudo-term', 19 code points; 'pseudo_term', 11 code points), so every synonym group is scored as an almost maximally rare 19-character term and every combined-field term as an 11-character one: SynonymQuery{ab, cd} scores 1.4050181 instead of 1.3490043, and in 200-token documents the group {dog, dogs} scores 2.13x TermQuery(dog) and outranks a document that matches 'elephant'.
- Introduced
- e3a7a733c8a9 (#16243 'StableTfl Similarity', author txwei, merged by mccullocht), which added StableTflSimilarity and its read getTermLength(termStats[i].term()) [read: git blame of StableTflSimilarity.java:190-200; exec: the repro fails 3/3 at e3a7a733c8a9 and does not compile at its parent d87b4f388151 (class absent)]; backport #16322 (8e40c912555b) to branch_10x, which branch_10_6 contains; an interaction bug: the placeholders it exposes are older and were harmless while no Similarity read TermStats.term(): SynonymQuery's 'synonym pseudo-term' from e0bde579815 (LUCENE-8020, 2017, replacing a null term) and CombinedFieldQuery's 'pseudo_term' from fd96bc5ca6b (LUCENE-8216, 2018, sandbox BM25FQuery), copied into core by 239059e69cf (#14236, 2025) (2026-07-01 (#16243 merged on main; backport #16322 merged the same day); the placeholders date from 2017-10-30 (LUCENE-8020) and 2018-11-21 (LUCENE-8216))
- First affected version
- 10.6.0 (released 2026-10-29; CHANGES.txt lists GITHUB#16243 under Lucene 10.6.0 New Features, on main and on branch_10_6)
- Affected versions
- main 11.0.0-SNAPSHOT from e3a7a733c8a9 through ea57360912ed [exec: e3a7a733c8a9 and ea57360912ed with -ea, ea57360912ed also with -da]; branch_10_6 (10.6.0-SNAPSHOT, head 2763db11b0a7) and branch_10x (10.7.0-SNAPSHOT, head 1309534296e3) [exec on ASF nightly snapshot jars with recorded SHA-1s, -ea]; identical failing values on all four builds; not affected: 10.5.2, 9.12.3 and every older release, and the parent commit d87b4f388151, because StableTflSimilarity does not exist there (the repro does not compile) [exec at 10.5.2, 9.12.3 and d87b4f388151]; not fixed upstream as of 2026-10-26 (upstream main HEAD ea57360912ed, no later commits to the three files, no open PR changes the code) [read]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, the prior-report record: three scripted prior-report searches (finder; the dev@, java-user@, issues@ and commits@ lists plus the LUCENE JIRA archive; GitHub issues, PRs, commits and downstream repos) merged into 389 distinct hits with 0 high-overlap hits; 57 GitHub GraphQL searches on apache/lucene and 41 on downstream repos and all of GitHub; 10 web searches; the changed-file lists of all 413 open apache/lucene PRs and the diffs of the 12 that touch related files; full reads of #16243 and #16322 (body, comments, reviews, inline comments) and the issues@ mirror thread of #16243, LUCENE-8020, LUCENE-8216, LUCENE-8943, LUCENE-9725, LUCENE-10039 and two SynonymQuery list threads; 15 hits read and classified, none the same bug; the finder, the independent reproduction, the intended-behaviour check and the adjudication found nothing either); not fixed upstream (upstream main HEAD was ea57360912ed at search time, the three files have no later commits, and no open PR changes the placeholder TermStats or StableTfl's term-length read); related items, not reports: #16243 / #16322 (the introducing change; no review comment mentions SynonymQuery, CombinedFieldQuery or placeholder terms), LUCENE-8020 / #9068 (origin of 'synonym pseudo-term'), LUCENE-8216 / #9263 (origin of 'pseudo_term'), LUCENE-10039 / #11077 (precedent: a one-field CombinedFieldQuery that scored unlike the copy field was fixed as a bug in 2021, different cause), LUCENE-8943 / #9986 (open: IDF combination over several term statistics), #14262 (open PR that would widen the trigger); search gaps: GitHub's search rate limit throttled one run (completed on a rerun, with GraphQL as a cross-check), inline review comments are covered only for the PRs read directly, and private reports are not visible
- Lucene commit tested
- main
ea57360912ed(commit of 2026-10-24; tested 2026-10-26), seed DEADBEEF, assertions on and off, through gradle and through a standalone JUnit harness; maina9779ef3c193; the introducing commite3a7a733c8a9and its parent;branch_10_6andbranch_10xnightly snapshot jars; 10.5.2 and 9.12.3 (class absent); the fix in a private clone ofea57360912ed.
- Finding record
lucene/core/src/java/org/apache/lucene/search/similarities/StableTflSimilarity.java:28-55 (at ea57360912ed) (class javadoc: rarity from term length |t| and document length, p = 1 - (1 - m * 2 ^ (-c * |t|)) ^ |d|; @lucene.experimental at :55), :181-187 (getTermLength = UnicodeUtil.codePointCount, non-UTF-8 fallback 5 at :75), :190-200 (scorer; getTermLength(termStats[i].term()) at :195)lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:47-49 (at ea57360912ed) ('tries to score the terms as if you had indexed them as one term'), :225-227 (new TermStats(new BytesRef("synonym pseudo-term"), docFreq, totalTermFreq))lucene/core/src/java/org/apache/lucene/search/CombinedFieldQuery.java:54-64 (at ea57360912ed) (synthetic combined field; compatibility 'hold[s] for all similarities that don't customize computeNorm' at :62), :77 (@lucene.experimental), :289-291 (new TermStats(new BytesRef("pseudo_term"), docFreq, ...))lucene/core/src/java/org/apache/lucene/search/TermStats.java:23 (at ea57360912ed) ('Contains statistics for a specific term'), :46 ('@param term Term bytes.')lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:75-76 (at ea57360912ed) ('a Similarity can freely use any combination of statistics')lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:395, (at ea57360912ed) 409, 555, 603 (tokens stacked at one position become newSynonymQuery); lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:61 and .../simple/SimpleQueryParser.java:101 (extend QueryBuilder)lucene/core/src/test/org/apache/lucene/search/TestCombinedFieldQuery.java:306 (at ea57360912ed) (testCopyFieldWithSingleField), :392-400 (randomCompatibleSimilarity: BM25, Boolean, Classic, LMDirichlet, LMJelinekMercer; no StableTfl)lucene/CHANGES.txt:488 (at ea57360912ed) (GITHUB#16243 under Lucene 10.6.0)lucene/sandbox/src/java/org/apache/lucene/sandbox/search/CombinedFieldQuery.java:325 (at ea57360912ed) (the deprecated sandbox copy passes the same 'pseudo_term'; sandbox is outside the hunt scope)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/StableTflSimilarity.java:29-55, 75, 170-175, 181-201, 217-220, 237-245Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:47-49, 103-106, 160-168, 209-227Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/CombinedFieldQuery.java:46-64, 86-96, 135, 274-291Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/QueryBuilder.java:347-351, 395, 409, 555, 603; Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/MultiPhraseQuery.java:228; Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:1132
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/StableTflSimilarity.java:55 (@lucene.experimental)Lucene main ea57360912ed: lucene/CHANGES.txt:488 (GITHUB#16243 under 10.6.0)
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/StableTflSimilarity.java:29-43, 217-220Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TermStats.java:22-23, 46; Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:72-77Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:47-49; Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/CombinedFieldQuery.java:47-49, 60-64Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestCombinedFieldQuery.java:306-342, 392-400Lucene main ea57360912ed: lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:201, 248 (the inherited tests' constant term "term")
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:104; Lucene main ea57360912ed: lucene/sandbox/src/java/org/apache/lucene/sandbox/search/CombinedFieldQuery.java:131-137, 155, 169, 325
- TestStableTflSynonymQuery.java (JUnit test):
repro/LFV-261/src/org/apache/lucene/search/TestStableTflSynonymQuery.java(48 lines)
A step-by-step picture of the finder's dog/elephant test (two 200-token documents), made in parallel with this doc; its values come from the diagram's executed probe on main ea57360912ed or from the code at that commit. The PNG is the summary card.
assets/LFV-261/bug.png(488 KB)
