All findings · Lucene finding · LFV-073 · basesimilaritytestcase-dead-check

BaseSimilarityTestCase's "scores don't decrease as terms get rarer" check never runs: it scores `term` again instead of `prevTerm`

BaseSimilarityTestCase.testRandomScoring promises to check that a rarer term never scores lower, but it scores the same term twice and compares the score with itself, so for a deterministic Similarity the check can never fail. A Similarity that scores rarer terms lower passes it, in every release since 8.0.0.

Summary

Priority
Low, rank 1222BaseSimilarityTestCase's 'rarer terms do not score lower' check is dead code, test-framework only, though enabling it shows some shipped similarities violate the property.
Impact
minor
Since
8.0.0 (LUCENE-7997, 2017)introduced 2017-10-24 · about 9 years
Affected
main and every release 8.0.0-10.6.0 (test framework only)
Status
documented
Found by
code reading (non-formal), during an SMT study of scoring contracts

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

BaseSimilarityTestCase documents a check that compares a score with itself.

What you need to know first

  1. A Similarity turns term statistics (docFreq: how many documents contain the term; totalTermFreq: how often it occurs overall) and a document's freq and norm into a score. IDF-style models such as BM25 score a rarer term higher.
  2. lucene-test-framework ships BaseSimilarityTestCase: you extend it, return your Similarity from getSimilarity, and its testRandomScoring draws thousands of random statistics and checks documented properties of the scores.
Diagram of the bug. Left: a user's Similarity with weight boost * log1p(docFreq), at freq 2 and norm 4, scores the term with docFreq 5 at 0.39368978 and the rarer prevTerm with docFreq 4 at 0.35362965, but line 481 on main rescores term and gets 0.39368978 again. Expected: 0.35362965 < 0.39368978, an AssertionError; actual: 0.39368978 < 0.39368978 is false, the test passes. Right: BaseSimilarityTestCase.java lines 479-482 and 500 at 645d4fe14a20, where line 481 passes term, not prevTerm. Below the panels: affected are main and all 61 releases from 8.0.0 to 10.6.0 (the minimal repro, in each release's API, fails on each with -ea and -da). Bottom: the one-word fix, scorer(boost, corpus, prevTerm), makes the repro pass but fails TestBasicModelG 8 of 8 seeds, TestIndriDirichletSimilarity 4 of 8 and TestDistributionSPL 1 of 8, and in longer runs TestDistributionLL with normalization H3 by 1 ulp, so an upstream fix also needs exemptions, a narrower check or a javadoc change.
The rarer-term check builds prevTerm but scores term again, so it compares a score with itself: a Similarity that scores the rarer term lower still passes. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You write a Similarity whose term weight grows with docFreq, boost * log1p(docFreq), so a rarer term scores lower: at freq 1 and norm 1, docFreq 5 scores 0.621 and docFreq 4 scores 0.558.
  2. You extend BaseSimilarityTestCase with it and run testRandomScoring(). Its javadoc lists "scores don't decrease as terms get rarer" among its checks, so you expect an AssertionError.
  3. Instead it passes, with assertions on and off, in every run tried (22 of 22). Your Similarity passes every other check, so the rarer-term check is the only one that could have caught it.
  4. Why: doTestScoring builds prevTerm with docFreq - 1 and totalTermFreq - 1, then calls similarity.scorer(boost, corpus, term), the same term again. The two scores are equal, so if (prevTermScore < score) fail(...) never fires.
  5. Scoring prevTerm wakes the check up for Lucene's own models too: TestBasicModelG then fails 8 of 8 seeds and TestIndriDirichletSimilarity 4 of 8, and the IB distribution SPL and the Dirichlet normalization H3 show smaller drops. A fix therefore needs exemptions (or an opt-out), a narrower check, or dropping the promise.

Who is affected

Anyone who validates a Similarity by extending BaseSimilarityTestCase (lucene-test-framework, since 8.0.0), and Lucene's own 23 similarity suites. Every run skips the check, so a Similarity that scores rarer terms lower, such as an inverted IDF, gets a false pass. No search result, score or pruning changes: it is test-only.

Mechanism, terms and numbers533 words

BaseSimilarityTestCase, the ready-made test suite for Similarity implementations in the published lucene-test-framework, documents that testRandomScoring checks "scores don't decrease as terms get rarer, e.g. score(term=N) >= score(term=N+1)". The code builds the rarer term, prevTerm (docFreq - 1, totalTermFreq - 1), but then scores the original term again, so it compares a score with itself and, for a deterministic Similarity, the check can never fail; prevTerm is used only in the failure message. A Similarity whose weight is boost * log1p(docFreq) (docFreq 5 scores 0.621, docFreq 4 scores 0.558) passes in every run tried, on main and on every release from 8.0.0 to 10.6.0. The defect is test-only: no search result, score or pruning decision changes. With the argument corrected, four of Lucene's own models fail the check (DFR basic model G and Indri by their formulas, the IB distribution SPL often, the Dirichlet normalization H3 by an ulp or two), so a fix also needs a decision: exempt them (validated here) or drop the documented property.

The check that never runs

testRandomScoring's javadoc lists, among the checks it does, "scores don't decrease as terms get rarer, e.g. score(term=N) >= score(term=N+1)" (BaseSimilarityTestCase.java:263 at 645d4fe14a20). For each random draw, doTestScoring computes score = similarity.scorer(boost, corpus, term).score(freq, norm) (:380, :385) and later, under the comment "check score(term-1), given the same freq/norm it should be >= score(term) [scores non-decreasing as terms get rarer]" (:476-477), does:

TermStats prevTerm =
    new TermStats(term.term(), term.docFreq() - 1, term.totalTermFreq() - 1);   // :479-480
SimScorer prevTermScorer = similarity.scorer(boost, corpus, term);              // :481, term, not prevTerm
float prevTermScore = prevTermScorer.score(freq, norm);                         // :482
...
if (prevTermScore < score) {                                                    // :500
  fail("score(" + freq + "," + (prevTerm) + ")=" + prevTermScore + " < score(" ...   // :507

Both scorers are built from the same arguments and scored with the same freq and norm, so prevTermScore == score for any deterministic Similarity and the comparison is always false. prevTerm is read only to build the failure string, which is why no unused-variable lint flags it; in the shipped 8.0.0 and 10.6.0 test-framework jars the Similarity.scorer call is likewise passed the term parameter (javap -c).

The numbers

The repro's Similarity has weight boost * log1p(docFreq) and score weight * log1p(freq) / (1 + norm). At boost 1, freq 1, norm 1: docFreq 5 gives 0.621, docFreq 4 gives 0.558, so a rarer term scores 10% lower. It passes every other check of testRandomScoring (finite, non-negative, non-decreasing in freq, non-increasing in norm, explanation equals score). On main it passes testRandomScoring 1/1 at seed DEADBEEF with -ea and -da, and 20 of 20 iterations from seed 5EED0073. With prevTerm scored, every one of 20 iterations throws the rarer-term check's own message.

Not an API contract

SimScorer.score requires monotonicity in freq and in the unsigned norm only (Similarity.java:207-216); no documented Similarity contract requires docFreq monotonicity, and no core code relying on it was found. The property is a promise of the test framework, which also sets its own bar: "if there is some bug in a given Similarity that this test fails to catch then this test needs to be improved!" (BaseSimilarityTestCase.java:48-51).

Exposure and likelihood430 words
  • Who. Authors of Similarity implementations who extend BaseSimilarityTestCase, a public class of the published lucene-test-framework artifact since 8.0.0, and Lucene's own 23 concrete suites in lucene/core. testRandomScoring runs for every subclass, so the check is skipped by default, on every seed. How many outside projects extend the class was not measured.
  • What they lose. A false pass: a Similarity that scores a rarer term lower (for example an IDF with a sign error) passes the check documented to catch it. The check's rarer term has docFreq and totalTermFreq each one lower, with the same freq, norm and field statistics, so models that use totalTermFreq (Indri, G, SPL, H3) react to both.
  • What the dead check hides in Lucene itself. With prevTerm scored, four models score some rarer terms lower:
modelhow oftenexamplesize and cause
DFR basic model G (TestBasicModelG)8 of 8 seeds (one-word fix)docFreq 5: 8.7806E8 vs docFreq 6: 9.070249E8large at the framework's extreme draws (this example has freq 2.8E-45, a float subnormal; about 3.2%), much smaller on ordinary statistics (below); in the formula: G is log2(1+λ) + tfn·log2((1+λ)/λ) with λ = F/(N+F), whose derivative in λ has the sign of λ − tfn, so while tfn is below λ a more common term scores higher
IndriDirichletSimilarity4 of 8 seedsdocFreq 1: -19.201988 vs docFreq 2: -19.10872large; by design: log((tf + mu·P(t|C)) / (len + mu)) with P(t|C) = ttf / tokens, which grows with ttf
IB distribution SPL (TestDistributionSPL)1 of 8 seeds with the one-word fix (a 1-ulp drop); once rounding is tolerated or H3 is exempt, 32-51 of 100 iterations per root seed, across both lambdas and each non-H3 normalizationdocFreq 9: 17.245504 vs docFreq 10: 17.28934up to 0.33%; cause not analysed
Dirichlet normalization H3 (seen in TestDistributionLL)4 of 25 iterations (exact comparison; each 1 ulp, all with H3); 0 of 400 with H3 exempt0.40882927 vs 0.408829331-2 ulps; consistent with rounding: H3 computes (ttf + 1F) / (tokens + 1F) in float
  • On ordinary statistics too (1000 documents of average length 100, term df 500 vs 499, freq 1): DFR G-L-H1 at document length 300 scores 0.848171 for the common term and 0.8481204 for the rarer one; Indri (mu 2000) at length 100 scores -4.6032634 vs -4.6042166. BM25 and ClassicSimilarity score the rarer term higher at every length tried. These are properties of the models, not ranking bugs: no Lucene API documents docFreq monotonicity.
  • Library behaviour. Unchanged and correct; only the test framework's promise is broken.

Minimal repro

One JUnit class, one test, using public classes and the protected getSimilarity hook only. It hands BaseSimilarityTestCase a Similarity that scores rarer terms lower and expects testRandomScoring() to throw. Copy it into the core similarities tests and run it with gradle, or compile it against the built jars.

TestBaseSimilarityRarerTermCheck.java (one test)51 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.similarities;

import java.util.Random;
import org.apache.lucene.search.FieldStats;
import org.apache.lucene.search.TermStats;
import org.apache.lucene.tests.search.similarities.BaseSimilarityTestCase;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestBaseSimilarityRarerTermCheck extends LuceneTestCase {

  public void testRandomScoringRejectsRarerTermScoringLower() {
    Similarity commonTermsScoreHigher =
        new Similarity() {
          @Override
          public SimScorer scorer(float boost, FieldStats fieldStats, TermStats... termStats) {
            double weight = boost * Math.log1p(termStats[0].docFreq());
            return new SimScorer() {
              @Override
              public float score(float freq, long norm) {
                return (float) (weight * Math.log1p(freq) / (1 + norm));
              }
            };
          }
        };
    BaseSimilarityTestCase suite =
        new BaseSimilarityTestCase() {
          @Override
          protected Similarity getSimilarity(Random random) {
            return commonTermsScoreHigher;
          }
        };
    // Expected: AssertionError (a rarer term scores lower); actual: none, prevTerm is never scored.
    expectThrows(AssertionError.class, suite::testRandomScoring);
  }
}
Command
# copy the file into $LUCENE/lucene/core/src/test/org/apache/lucene/search/similarities/, then:
./gradlew :lucene:core:test --tests org.apache.lucene.search.similarities.TestBaseSimilarityRarerTermCheck -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

# or plain javac/java against the built jars; $DEPS = junit 4.13.2, randomizedtesting-runner 2.9.2, hamcrest 3.0
CP=$LUCENE/lucene/core/build/libs/lucene-core-11.0.0-SNAPSHOT.jar:$LUCENE/lucene/codecs/build/libs/lucene-codecs-11.0.0-SNAPSHOT.jar:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-11.0.0-SNAPSHOT.jar:$DEPS
javac -d out -cp "$CP" $REPRO/org/apache/lucene/search/similarities/TestBaseSimilarityRarerTermCheck.java
java -ea -Dtests.seed=DEADBEEF -cp "out:$CP" org.junit.runner.JUnitCore org.apache.lucene.search.similarities.TestBaseSimilarityRarerTermCheck
Expected
testRandomScoring() throws an AssertionError from the rarer-term check, as it does on every seed tried once prevTerm is scored.
Observed
No exception: the test fails with "Expected exception AssertionError but no exception was thrown", with assertions on and off.

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

More files, environment, full output354 words

The test builds an anonymous BaseSimilarityTestCase whose getSimilarity returns a Similarity with weight boost * log1p(docFreq), then calls the public testRandomScoring() inside expectThrows(AssertionError.class, ...). Single thread. It calls testRandomScoring() on an anonymous suite instance, so BaseSimilarityTestCase's @BeforeClass (which builds a one-document index) does not run for it; that is fine, because testRandomScoring and doTestScoring never read that index (READER and DIR appear only in beforeClass and afterClass, BaseSimilarityTestCase.java:55-92). The statistics are drawn by the framework itself; for this Similarity the stock framework cannot fail on any draw, because the only check that could reject it is the dead one, so the outcome does not depend on the seed (22 of 22 runs tried).

Releases need small API variants of the same test (same body and assertion). Before main the statistics classes are CollectionStatistics / TermStatistics (main renamed them to FieldStats / TermStats in #15929). On 9.12.0-10.6.0 only those two names differ. On 9.1.0-9.11.1 the test also overrides Similarity.computeNorm, which is abstract there (concrete from 9.12.0). On 8.0.0-9.0.0 it also uses the pre-9.1 test-framework packages. The version table used two variants: one for 8.0.0-9.0.0 and one for 9.1.0-10.6.0 (with the computeNorm override).

Regression test: this same test. With the check fixed it passes, and it fails again if the check goes dead. Natural homes: next to the suites in lucene/core/src/test/org/apache/lucene/search/similarities/ (as is), or beside TestBaseExplanationTestCase in the test framework's own tests.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); every release 8.0.0-10.6.0 fails the same way (release API variants, see below)
jdkOpenJDK 25.0.3 (2026-04-21 LTS)
osLinux aarch64 (the result does not depend on the platform)

Observed output

# main 645d4fe14a20, stock jars, seed DEADBEEF, -ea (-da: identical)
1) testRandomScoringRejectsRarerTermScoringLower(org.apache.lucene.search.similarities.TestBaseSimilarityRarerTermCheck)
junit.framework.AssertionFailedError: Expected exception AssertionError but no exception was thrown
	at __randomizedtesting.SeedInfo.seed([DEADBEEF:AC09ECDEFB034D93]:0)
	at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
	at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
	at org.apache.lucene.search.similarities.TestBaseSimilarityRarerTermCheck.testRandomScoringRejectsRarerTermScoringLower(TestBaseSimilarityRarerTermCheck.java:49)
Tests run: 1,  Failures: 1

# gradle, the file copied into the core tests, seed DEADBEEF, -Ptests.asserts=true and false
TestBaseSimilarityRarerTermCheck > testRandomScoringRejectsRarerTermScoringLower FAILED
    junit.framework.AssertionFailedError: Expected exception AssertionError but no exception was thrown
<testsuite name="org.apache.lucene.search.similarities.TestBaseSimilarityRarerTermCheck" tests="1" skipped="0" failures="1" errors="0"

# with the recommended fix: 1 test, 0 failures (gradle and standalone, assertions on and off)

Why it looks unintended

  • The javadoc lists the check, the comment says the rarer term's score "should be >= score(term)", and the failure message prints prevTerm.
  • It was added (LUCENE-7997, #9045) to test a "nearby slightly rarer term"; nothing ever disabled it.
  • The class javadoc: a Similarity bug this test misses means "this test needs to be improved!"
The full argument198 words
  • The contract says the check runs. testRandomScoring's javadoc lists "scores don't decrease as terms get rarer, e.g. score(term=N) >= score(term=N+1)" (BaseSimilarityTestCase.java:263); the comment at :476-477 says the rarer term's score "should be >= score(term)"; the failure message prints prevTerm (:507).
  • Intent at creation. LUCENE-7997 (42717d5f4bb, by rmuir, 2017-10-24; migrated as #9045) added the check; the patch note on #9045 says it tests a "nearby slightly rarer term to ensure relevance doesn't go backwards in those cases too". LUCENE-8010 (#9058) repeats the property score(term=N) >= score(term=N+1).
  • Never disabled on purpose. No TODO, @AwaitsFix, flag or comment says the check is off. Seven later commits edited the block and kept term, among them LUCENE-8007 (ca5f9b34576), LUCENE-8116 (8fd7ead940f, by jpountz, which moved it to today's scorer API), LUCENE-9537 (9cc5c9b7983, which added the Indri non-negativity exemption inside this very block at :485) and #15929 (fa0e704d2a0, the TermStats rename).
  • Lucene tests its test helpers. TestBaseExplanationTestCase in the test framework's own tests "Tests that the BaseExplanationTestCase helper code ... are checking what they are supposed to", with the same expectThrows(AssertionError.class, ...) shape as the repro.
The devil's-advocate check179 words

Strongest case for "not a bug": docFreq monotonicity is not part of the Similarity API (Similarity.java:207-216), and Lucene's own BasicModelG and Indri violate it on ordinary statistics, so perhaps nobody wants the property; the defect is test-only and changes no result; and the repro returns one fixed Similarity although getSimilarity's javadoc asks for "a new similarity with all parameters randomized within valid ranges" (BaseSimilarityTestCase.java:94), as the cross-model reviewer noted.

Why it fails: whatever the property's merit, the published javadoc and the code disagree, and the class's own standard calls an uncaught Similarity bug a test defect. The authors clearly meant the check to run (#9045's note, the comment, the message). That Lucene's own models fail the live check is a reason to decide explicitly (exempt, narrow or drop), not to keep a check that silently passes everything. The reviewer rebuilt the test with a fresh randomized Similarity per call and got the same result: it fails on main, every rarity-sensitive case fails with assertions off over 5 iterations, and it passes with the fix.

Suggested fix

Recommended Score prevTerm; skip the comparison for Indri, DFR-G, IB-SPL and H3 (validated)

Score prevTerm, and skip the comparison for the four models that legitimately score some rarer terms lower: Indri, DFR basic model G, IB with SPL, and the Dirichlet normalization H3 (1 file, +27/-2). With it the repro passes and the similarities suites pass every seed and 13,800 iteration runs tried; the one-word fix alone fails.

All options and trade-offs4 options · 836 words

One fix (A) was validated with gradle in a scratch worktree of 645d4fe14a20. Its exemption list was found by sampling: the one-word fix and three earlier variants were run first and failed (see the option "How the exemption list was found"). The other options were not run.

Score prevTerm; skip the comparison for Indri, DFR-G, IB-SPL and H3 (validated) (recommended)

In doTestScoring, pass prevTerm to similarity.scorer, and skip only the final comparison for IndriDirichletSimilarity, a DFRSimilarity with BasicModelG or NormalizationH3, and an IBSimilarity with DistributionSPL or NormalizationH3. The exempted models still run the rest of the block (the rarer term's score must be finite, match its explanation, and be non-negative except for Indri). 1 file, +27/-2, exact comparison. It follows the existing pattern: the same block already exempts Indri from the non-negativity check by instanceof.

-        SimScorer prevTermScorer = similarity.scorer(boost, corpus, term);
+        SimScorer prevTermScorer = similarity.scorer(boost, corpus, prevTerm);
         ...
-        if (prevTermScore < score) {
+        if (prevTermScore < score && rarerTermsNeverScoreLower(similarity)) {

+  private static boolean rarerTermsNeverScoreLower(Similarity similarity) {
+    if (similarity instanceof IndriDirichletSimilarity) {
+      return false;
+    }
+    if (similarity instanceof DFRSimilarity dfr) {
+      return dfr.getBasicModel() instanceof BasicModelG == false
+          && dfr.getNormalization() instanceof NormalizationH3 == false;
+    }
+    if (similarity instanceof IBSimilarity ib) {
+      return ib.getDistribution() instanceof DistributionSPL == false
+          && ib.getNormalization() instanceof NormalizationH3 == false;
+    }
+    return true;
+  }

Trade-offs

  • The test framework names four concrete classes (it already imports IndriDirichletSimilarity), and an outside Similarity that legitimately violates the property has no way to opt out (see the hook option).
  • The list was found by sampling, so a rare violating combination in another model cannot be excluded; in a randomized CI it would show up as an occasional failure.
  • The H3 exemption covers 1-2-ulp drops that look like float rounding rather than an exact-math violation, and it switches the comparison off for every DFR and IB similarity that uses H3. SPL's larger drops were not analysed.
  • The diff uses pattern-matching instanceof (Java 16+): fine on main and 10.x (Java 21); a backport to branch_9x (Java 11) needs explicit casts.
  • Models from three families (DFR, IB, Indri) fail the property, which is also an argument for dropping or narrowing the promise (option C).

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle --no-daemon, OpenJDK 25.0.3: checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain of the test framework pass.
  • :lucene:core:test --tests 'org.apache.lucene.search.similarities.*' (26 classes, 118 tests): 0 failures for seeds DEADBEEF, 12345678, CAFEBABE, 0BADF00D with assertions on and DEADBEEF with assertions off.
  • Every suite's testRandomScoring with -Ptests.iters=100 for 6 root seeds: 13,800 test runs, 0 failures.
  • The minimal repro passes with assertions on and off (gradle, and standalone with the fixed class shadowed in); a diagnostic copy that also checks the message passes 20 of 20 iterations, so the rejection comes from the rarer-term check itself.

How the exemption list was found (one-word fix and three earlier variants, all run and failing)

variantexemptscomparisonresult
one-word fixnoneexactTestBasicModelG fails at DEADBEEF; TestBasicModelG and TestIndriDirichletSimilarity at 12345678; over 8 seeds G fails 8, Indri 4, SPL 1
A1Indri, DFR-G, IB-SPLexactthe package passes 5 seed runs, but 25 iterations of every suite from root seed 00000001 fail TestDistributionLL 4 times, each by 1 ulp, all with H3
A2Indri, DFR-G1-ulp slackTestDistributionSPL fails at 0BADF00D by 0.32% and in 45-51 of 100 iterations per root seed; TestDistributionLL once by 2 ulps
A3Indri, DFR-G, H3exactLL clean (0 of 400 iterations); SPL still fails 32-38 of 100 per root seed, across both lambdas and each non-H3 normalization

Trade-offs

Shows why the one-word fix alone is not enough and why a fixed ulp tolerance does not help: SPL's drops reach 0.33%, and H3's rounding drops reach 2 ulps.

Drop the promise (not run)

Delete the comparison (or the whole rarer-term block) and the javadoc bullet "scores don't decrease as terms get rarer"; the repro test is then dropped too.

Trade-offs

The smallest change, with no risk of new test failures, and what the code has effectively done since 2017 for deterministic similarities; but it gives up a check that the remaining models pass and that would catch an inverted IDF in an outside Similarity. A reasonable choice if maintainers do not want an exemption list found by sampling.

A protected opt-out hook instead of instanceof (not run)

A protected method on BaseSimilarityTestCase, for example "does this Similarity guarantee that rarer terms never score lower?", default true, that the affected suites override.

Trade-offs

Lets outside authors opt out too and keeps model names out of the framework; but doTestScoring is private static, and H3 is drawn at random inside the shared final getSimilarity of BasicModelTestCase and DistributionTestCase, so the hook needs the drawn Similarity as an argument: a larger diff (SPL, G and Indri are fixed per suite and could be plain per-suite overrides).

Downstream note

Projects that test their own Similarity with BaseSimilarityTestCase get no rarer-term check from it today; until a fix ships, they need their own test of that property.

How it was found

Code reading, no formal method. An AI tool studying Lucene's scoring contracts with an SMT solver read BaseSimilarityTestCase to copy its input domains and noticed that prevTerm is never scored. An independent verifier and a reviewer from another model family (OpenAI GPT-5.6) each reproduced it with their own tests.

The method in full149 words
  • Context. The 2026-10-22 study's SMT experiment on similarities and impacts encoded the scoring contracts that dynamic pruning relies on (monotone in freq and norm, finite, non-negative) for SMT solvers, using the statistics domains of BaseSimilarityTestCase#testRandomScoring. Reading that method to derive the domains, the AI tool saw that the rarer-term block scores term, not prevTerm.
  • Confirmation. A meta-test that expects testRandomScoring to reject an anti-IDF Similarity failed on main for 3 seeds; with the one-word fix it passed, and the core suites showed which models violate the property.
  • Independent verification. A separate verifier wrote its own tests (reflection into doTestScoring, a conformance suite, javap of the shipped bytecode, an 8-seed sweep of the 23 core suites with the stock and the fixed framework) and confirmed it as a test-only bug.
  • Tier. Non-formal: the solver work was the context; the bug itself was found by reading code.

Prior reports, history and checks

Prior reports

No prior report: GitHub issues and PRs (with the migrated JIRA archive), commits, the dev@, java-user@, issues@ and commits@ lists, three downstream repositories and the web were searched on 2026-11-01. No open PR touches the file.

Searches5 searches

Closest hits, none this bug: #9045 (LUCENE-7997, created the check), #9058 (LUCENE-8010, states the property), #9100 (LUCENE-8052, open: a norm-check failure in TestBasicModelG), #9063 and #9121 (freq- and norm-check failures), #16137 (open: SPL negative scores). The Jenkins failure mails on the lists come from other assertions; for a deterministic Similarity the dead check cannot produce a failure.

What was searched, when, and what turned up
WhereWhenHowFound
Scripted searches2026-11-0163 queries: 26 GitHub issue/PR, 3 commit, 4 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive, 6 webno report; the one high hit (#9063) is a different assertion
GitHub, manual2026-11-0124 queries: class names, prevTerm / prevTermScore, "terms get rarer", "rarer terms", docFreq - 1, "dead code", "never fail", is:pr variants; BaseSimilarityTestCase in elastic/elasticsearch, opensearch-project/OpenSearch and apache/solrrelated issues only; 0 downstream hits
Mailing lists2026-11-0115 direct lists.apache.org queries on dev@, issues@, java-user@only Jenkins failure mails and the LUCENE-7997 / LUCENE-8010 notifications
Open PRs2026-11-01changed files of all 409 open apache/lucene PRsnone touches BaseSimilarityTestCase.java
History and upstream main2026-11-01full-history clone (commit messages, git log -S); upstream main f815e679bf6cno commit ever scored prevTerm; same file blob upstream

History

Wrong from the start: LUCENE-7997 (42717d5f4bb, 2017-10-24) created the check with the same mistake, and LUCENE-8116 (8fd7ead940f, 2018) carried it into today's API. First release 8.0.0; main and every release through 10.6.0 fail the repro.

Versions tested5 rows
  • Introduced by 42717d5f4bb, "LUCENE-7997: More sanity testing of similarities" (by rmuir, 2017-10-24; a direct commit for the JIRA issue, migrated as #9045). It created BaseSimilarityTestCase.java with similarity.computeWeight(boost, corpus, term), so this is an original defect, not a regression.
  • Carried over by 8fd7ead940f (LUCENE-8116, by jpountz, 2018-01-04), which moved the line to similarity.scorer(boost, corpus, term); git blame on main points there. git log -S finds no commit that ever used prevTerm in that call.
  • First release 8.0.0: CHANGES.txt lists "LUCENE-7997: Add BaseSimilarityTestCase to sanity check similarities." under 8.0.0; no 7.x release has the class.
  • The source line is similarity.scorer(boost, corpus, term) at every release tag read (8.0.0-8.4.1 :414, 8.5.0-8.8.1 :419, 9.0.0 :468, 9.1.0-10.2.2 :470, 10.3.0-10.4.0 :472, 10.5.0-10.6.0 :490, main :481).
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; upstream main f815e679bf6c affectedrepro fails with -ea and -da (standalone, plain java, gradle); upstream read, same file blob
10.0.0 to 10.6.0 (13 releases) affectedrepro (9.1.0-10.6.0 variant) fails 1/1 with -ea and -da
9.0.0 to 9.12.3 (21 releases) affectedfails 1/1 with -ea and -da (9.0.0: the 8.x variant; 9.1.0-9.12.3: the 9.1.0-10.6.0 variant)
8.0.0 to 8.11.4 (27 releases) affectedfails 1/1 with -ea and -da (8.x variant); 8.0.0 with the one-word fix passes
7.2.0, 7.7.3 not affectedno BaseSimilarityTestCase in the test framework (compile error)

Independent checks

Five independent looks: the study's finder and verifier, the repro minimization, a re-verification on the current main, and a reviewer from another model family (OpenAI GPT-5.6) with a refute lens. All agree it is real and test-only; the reviewer also found that the one-word fix alone breaks two suites.

Checks and verdicts215 words

Each check ran separately; the cross-model review wrote and executed its own test.

Each independent check and its verdict
CheckVerdictWhat it found
Study finder bug (test framework)Meta-test fails on main for 3 seeds; with the fix, 3 core suites fail.
Study verifier reproduced, test-onlyReflection into doTestScoring, a conformance suite, javap of the shipped bytecode, an 8-seed sweep of the 23 core suites stock and fixed.
Minimal repro verifiedThe one-test class fails on 645d4fe14a20 with -ea and -da and passes with the one-word fix.
Re-verification realMain -ea, -da and 20 iterations; 10.5.2 and 9.12.3 with the 9.12.0-10.6.0 API variant; diagnostic check that the fixed framework throws the rarer-term message 20 of 20 times; 8-seed sweep stock vs fixed.
Refute lens (OpenAI GPT-5.6) real, high confidenceOwn two-case test with a fresh randomized Similarity per call: fails on main, all 5 rarity cases fail with -da over 5 iterations, passes with the fix; the one-word diff alone breaks BasicModelG and Indri.
This doc's brief real; fix validatedRepro re-run stock and with each fix variant (standalone, plain java, gradle); the similarities package and iteration sweeps of every suite (575 or 2,300 tests each) with the one-word fix, three failing variants and the recommended fix.

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 notes543 words

Finding record

LFV-073, snapshot of 2026-11-06

Registry title
BaseSimilarityTestCase's 'scores don't decrease as terms get rarer' check is dead code
Area
test-framework / similarities
Priority
Low, rank 1222: BaseSimilarityTestCase's 'rarer terms do not score lower' check is dead code, test-framework only, though enabling it shows some shipped similarities violate the property.
Impact class
minor
Impact note
Anyone validating a Similarity with lucene-test-framework; test-only; since 2017.
Summary
You validate a custom Similarity with lucene-test-framework's BaseSimilarityTestCase. You expect it to check that scores do not decrease as terms get rarer, as documented. Instead the check rescores term instead of prevTerm and can never fail; fixing it fails TestBasicModelG 8/8, Indri 4/8 and SPL 1/8 seeds.
Introduced
LUCENE-7997 (42717d5f4bb); the check was wrong when it was written (2017-10-24)
First affected version
8.0.0 [exec]
Affected versions
8.0.0 to 10.6.0 (61 releases) and main [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (study searches 2026-10-22; searched again 2026-11-01)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off (standalone, plain java, and gradle with the file copied into the core tests); the version-history runs ran it on 61 release jars, 8.0.0 to 10.6.0, with -ea and -da (all fail; two API variants of the same test, 8.0.0-9.0.0 and 9.1.0-10.6.0); the fix was validated with gradle in a scratch worktree of 645d4fe14a20. Upstream main f815e679bf6c (2026-11-01) was read, not run: the file blob is unchanged.

Lucene and upstream sources

The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:48-51, :263, :380, :385, :476-482, :500, :507
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:200-222
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/BasicModelG.java:36-50, IndriDirichletSimilarity.java:69-72, :111-112, NormalizationH3.java:47-52
Minimal repro
  • a Lucene clone release tags 9.0.0-9.11.1: Similarity.java:114 `public abstract long computeNorm`; 9.12.0 and later: :152, concrete
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:48-51, :94, :263, :476-477, :485, :507
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/test/org/apache/lucene/tests/search/TestBaseExplanationTestCase.java:30-34
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:376, :485
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/similarities/BasicModelTestCase.java:24-34, :98-115 (random after effect and normalization), DistributionTestCase.java:24-31, :96-114 (random lambda and normalization), TestDistributionSPL.java:21-24 (SPL fixed per suite)
History
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:6611, :6820

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

Repro files

About the diagrams

Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame. Its example uses freq 2 and norm 4 (0.39368978 vs 0.35362965), the text uses freq 1 and norm 1 (0.621 vs 0.558); both follow from the same formula. The count "2560 rarer-term checks" is from one full run of the diagram probe's own test method at seed DEADBEEF; the repro test at the same seed makes 2,522 (randomized-testing seeds depend on the test method).

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.