All findings · Lucene finding · LFV-261 · stabletfl-pseudo-term-length

StableTflSimilarity scores synonyms and combined fields by the length of a placeholder string

StableTflSimilarity rates a term rarer the longer it is, but SynonymQuery and CombinedFieldQuery hand it the placeholders "synonym pseudo-term" (19 characters) and "pseudo_term" (11) instead of the query term. SynonymQuery{ab, cd} scores 1.405 instead of 0.957, and a "dog" match outranks an "elephant" match; 10.6.0 (released 2026-10-29) and main.

Summary

Priority
Medium, rank 97In 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
silent wrong results
Since
10.6.0, released 2026-10-29 (#16243, 2026-07-01)introduced 2026-07-01
Affected
10.6.0 (released 2026-10-29), main, branch_10_6 and branch_10x; no earlier release
Status
documented
Found by
code reading while modelling StableTfl, then a JUnit

AI-assisted page: this finding was first signalled by code reading or testing 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

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.

Summary card: StableTflSimilarity, two 200-token documents, query "dogs elephant" built by QueryBuilder with a synonym analyzer as Synonym(body:dog body:dogs) body:elephant. The synonym group is scored with the length of the placeholder "synonym pseudo-term" (19) instead of 3 or 4, so the dog document scores 1.4049385 and outranks the elephant document (1.325023), although TermQuery(dog) scores 0.66057557.
The "dogs elephant" example step by step: the synonym group reaches StableTfl as the placeholder "synonym pseudo-term", whose 19 characters put its rarity near the cap, so the "dog" document outranks the "elephant" document. Select the still to open it at full size. Generated with AI, checked against the code.
  1. 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 document ab (2 characters) gets rarity 2.105, a 19-character term 3.091, near the cap log(22).
  2. Search SynonymQuery{ab, cd} on a 40-token document that contains ab once. Both synonyms have 2 characters, so you expect the TermQuery(ab) score, 0.957 (BM25 gives the two equal scores).
  3. You get 1.405 (+47%): SynonymQuery passes new TermStats(new BytesRef("synonym pseudo-term"), ...), and the explanation shows 19.0 = tl, term length. A one-field CombinedFieldQuery passes "pseudo_term" (11.0 = tl): 1.405 instead of the copy-field 1.365.
  4. Rankings flip: with a synonym analyzer, QueryBuilder turns "dogs elephant" into Synonym(dog dogs) elephant. In 200-token documents the "dog" document scores 1.405 and outranks the "elephant" document (1.325), although TermQuery(dog) scores 0.661.
  5. 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.

Who is affected

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.

Terms

  • 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, and tr is capped at log(22) = 3.091.
  • Placeholder: the fixed string that SynonymQuery and CombinedFieldQuery put into the one TermStats they 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):

termlengthdoc lengthtrscore
ab2402.1050.9569799
placeholder synonym pseudo-term19403.0911.4050031
foo343.0021.3647432
placeholder pseudo_term1143.0901.4047582
dog32001.4530.66057557
elephant82002.9151.325023
placeholder synonym pseudo-term192003.0911.4049385

The mechanism

  1. The reader. StableTflSimilarity#scorer takes getTermLength(termStats[i].term()) (StableTflSimilarity.java:195), the code-point count (:181-187), and caches tr(termLength, docLength) for all 256 norm values (:190-201); score takes the cached value for the document's norm and applies the tf saturation, 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).
  2. SynonymQuery. SynonymWeight takes the maximum docFreq and the summed totalTermFreq of the synonyms and calls the similarity once with new 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).
  3. CombinedFieldQuery. CombinedFieldWeight merges the per-field statistics and passes new TermStats(new BytesRef("pseudo_term"), ...) (CombinedFieldQuery.java:274-291): |t| = 11, although the query has exactly one term (:86-96, :135).
  4. Reach. For unquoted text, QueryBuilder builds a SynonymQuery when 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 a MultiPhraseQuery (:347-351), which passes real terms. TermQuery and PhraseQuery pass real term bytes (IndexSearcher.java:1132; a recording-Similarity probe confirmed it), and no other core similarity reads the bytes.
  5. Direction and size. tr falls 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). In CombinedFieldQuery, 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 a SynonymQuery, built directly or by QueryBuilder and 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 a CombinedFieldQuery.
  • Default or opt-in. Opt-in similarity (@lucene.experimental); everything else is ordinary: default codec, plain IndexSearcher, the standard query builders.
  • Releases. 10.6.0 (released 2026-10-29), where StableTfl first shipped; main, branch_10_6 and branch_10x are 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).

Minimal repro

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.

TestStableTflSynonymQuery.java (JUnit test)48 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.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);
      }
    }
  }
}
Command
# 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.

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

More files, environment, full output34 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: 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)

Why it looks unintended

  • StableTfl's javadoc defines rarity from the query term's length; TermStats holds "statistics for a specific term", and Similarity may "freely use any combination of statistics".
  • CombinedFieldQuery has exactly one query term, and TestCombinedFieldQuery asserts 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), and termLengths is "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#testCopyFieldWithSingleField asserts that a one-field CombinedFieldQuery scores like a TermQuery on 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. TestStableTflSimilarity uses only real words (including the constant "term" of the inherited BaseSimilarityTestCase tests); no test mentions the placeholders.
  • History. "synonym pseudo-term" replaced a null term in LUCENE-8020 (2017), whose stated intent is "we shouldn't pass terms that dont exist or bogus statistics" (said about bogus docFreq statistics, 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 CombinedFieldQuery that 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.

Suggested fix

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-docFreq synonym. "First" is BytesRef order: 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 TermStats pairs one synonym's bytes with the group's merged docFreq and totalTermFreq; 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.addTerm at :131-137, BytesRef[] terms at :155, sorted at :169), like SynonymQuery; a terms[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, TestCombinedFieldQuery with StableTfl 336 of 336; TestCombinedFieldQuery, TestSynonymQuery, similarities.* and TestQueryBuilder (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 test passes; tidy leaves the patch unchanged.
  • Standalone harness, fixed classes shadowed: the repro passes 5 of 5.

Choose the synonym by another corpus-independent rule (not run)

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.

An explicit API for merged statistics (not run)

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.

Document the limitation (not run)

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.

Detect the placeholder inside StableTfl (rejected)

Special-case the two strings in the similarity.

Trade-offs

Fragile, and there is still no real length to use.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-2657 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 #16322only the introducing PR and its backport are about StableTfl; no report
Open apache/lucene PRs2026-10-26the changed-file lists of all 413 open PRs, and the diffs of the 12 that touch related files12 touch related files; none changes the placeholders or the term() read
Downstream repositories and all of GitHub2026-10-2641 GraphQL searchesno report
JIRA archive and mailing lists (dev@, java-user@, issues@, commits@)2026-10-26scripted prior-report search queries; LUCENE-8020, -8216, -8943, -9725 and -10039 and two SynonymQuery list threads readorigins of the placeholders and related history; no report
Web2026-10-2610 searchesthe StableTfl authors' blog post and product docs; neither mentions synonyms or multi-field queries

History

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

Versions and branches executed, with results
Version or commitVerdictResult
9.12.3, 10.5.2 and every older release class absentthe repro does not compile: StableTflSimilarity does not exist
e3a7a733c8a9 (#16243) vs parent d87b4f388151 introducedfails 3 of 3; the parent does not compile (class absent)
branch_10_6 2763db11b0a7, branch_10x 1309534296e3 snapshotsfail 3 of 3 with identical values (ASF nightly snapshot jars, run with main's third-party dependencies; SHA-1s recorded)
main ea57360912ed, main a9779ef3c193 affectedthis doc's test fails 3 of 5 (on ea57360912ed with assertions on and off); the BM25 controls pass
fix (private clone of ea57360912ed) not affected5 of 5 pass; the core module and queryparser tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidatePublic-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 realOwn 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 keepRe-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 validatedGradle in a private clone: the repro fails on stock and passes with the fix, as do the core module and the queryparser module.

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

Finding record

LFV-261, snapshot of 2026-11-06

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; main a9779ef3c193; the introducing commit e3a7a733c8a9 and its parent; branch_10_6 and branch_10x nightly snapshot jars; 10.5.2 and 9.12.3 (class absent); the fix in a private clone of ea57360912ed.

Lucene and upstream sources

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-245
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:47-49, 103-106, 160-168, 209-227
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/CombinedFieldQuery.java:46-64, 86-96, 135, 274-291
  • Lucene 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-220
  • Lucene 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-77
  • Lucene 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-64
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestCombinedFieldQuery.java:306-342, 392-400
  • Lucene 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

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

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.