BaseSimilarityTestCase documents a check that compares a score with itself.
- 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.
- lucene-test-framework ships
BaseSimilarityTestCase: you extend it, return your Similarity fromgetSimilarity, and itstestRandomScoringdraws thousands of random statistics and checks documented properties of the scores.
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.- 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. - You extend
BaseSimilarityTestCasewith it and runtestRandomScoring(). Its javadoc lists "scores don't decrease as terms get rarer" among its checks, so you expect anAssertionError. - 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.
- Why:
doTestScoringbuildsprevTermwith docFreq - 1 and totalTermFreq - 1, then callssimilarity.scorer(boost, corpus, term), the same term again. The two scores are equal, soif (prevTermScore < score) fail(...)never fires. - Scoring
prevTermwakes the check up for Lucene's own models too:TestBasicModelGthen fails 8 of 8 seeds andTestIndriDirichletSimilarity4 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.
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.
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(" ... // :507Both 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 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.
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 inlucene/core.testRandomScoringruns 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
prevTermscored, four models score some rarer terms lower:
| model | how often | example | size and cause |
|---|---|---|---|
DFR basic model G (TestBasicModelG) | 8 of 8 seeds (one-word fix) | docFreq 5: 8.7806E8 vs docFreq 6: 9.070249E8 | large 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 |
IndriDirichletSimilarity | 4 of 8 seeds | docFreq 1: -19.201988 vs docFreq 2: -19.10872 | large; 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 normalization | docFreq 9: 17.245504 vs docFreq 10: 17.28934 | up 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 exempt | 0.40882927 vs 0.40882933 | 1-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.
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.
/*
* 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);
}
}
# 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 anAssertionErrorfrom the rarer-term check, as it does on every seed tried onceprevTermis scored.- Observed
- No exception: the test fails with "Expected exception AssertionError but no exception was thrown", with assertions on and off.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); every release 8.0.0-10.6.0 fails the same way (release API variants, see below) |
|---|---|
| jdk | OpenJDK 25.0.3 (2026-04-21 LTS) |
| os | Linux aarch64 (the result does not depend on the platform) |
# 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)- 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-477says the rarer term's score "should be >= score(term)"; the failure message printsprevTerm(: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 propertyscore(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 keptterm, among them LUCENE-8007 (ca5f9b34576), LUCENE-8116 (8fd7ead940f, by jpountz, which moved it to today'sscorerAPI), LUCENE-9537 (9cc5c9b7983, which added the Indri non-negativity exemption inside this very block at:485) and #15929 (fa0e704d2a0, theTermStatsrename). - Lucene tests its test helpers.
TestBaseExplanationTestCasein the test framework's own tests "Tests that the BaseExplanationTestCase helper code ... are checking what they are supposed to", with the sameexpectThrows(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.
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.
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,ecjLintMainandforbiddenApisMainof the test framework pass. :lucene:core:test --tests 'org.apache.lucene.search.similarities.*'(26 classes, 118 tests): 0 failures for seedsDEADBEEF,12345678,CAFEBABE,0BADF00Dwith assertions on andDEADBEEFwith assertions off.- Every suite's
testRandomScoringwith-Ptests.iters=100for 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.
| variant | exempts | comparison | result |
|---|---|---|---|
| one-word fix | none | exact | TestBasicModelG fails at DEADBEEF; TestBasicModelG and TestIndriDirichletSimilarity at 12345678; over 8 seeds G fails 8, Indri 4, SPL 1 |
| A1 | Indri, DFR-G, IB-SPL | exact | the 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 |
| A2 | Indri, DFR-G | 1-ulp slack | TestDistributionSPL fails at 0BADF00D by 0.32% and in 45-51 of 100 iterations per root seed; TestDistributionLL once by 2 ulps |
| A3 | Indri, DFR-G, H3 | exact | LL 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.
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 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).
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.
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 scoresterm, notprevTerm. - Confirmation. A meta-test that expects
testRandomScoringto 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,javapof 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| Scripted searches | 2026-11-01 | 63 queries: 26 GitHub issue/PR, 3 commit, 4 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive, 6 web | no report; the one high hit (#9063) is a different assertion |
| GitHub, manual | 2026-11-01 | 24 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/solr | related issues only; 0 downstream hits |
| Mailing lists | 2026-11-01 | 15 direct lists.apache.org queries on dev@, issues@, java-user@ | only Jenkins failure mails and the LUCENE-7997 / LUCENE-8010 notifications |
| Open PRs | 2026-11-01 | changed files of all 409 open apache/lucene PRs | none touches BaseSimilarityTestCase.java |
| History and upstream main | 2026-11-01 | full-history clone (commit messages, git log -S); upstream main f815e679bf6c | no commit ever scored prevTerm; same file blob upstream |
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 createdBaseSimilarityTestCase.javawithsimilarity.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 tosimilarity.scorer(boost, corpus, term);git blameon main points there.git log -Sfinds no commit that ever usedprevTermin 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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; upstream main f815e679bf6c | affected | repro fails with -ea and -da (standalone, plain java, gradle); upstream read, same file blob |
| 10.0.0 to 10.6.0 (13 releases) | affected | repro (9.1.0-10.6.0 variant) fails 1/1 with -ea and -da |
| 9.0.0 to 9.12.3 (21 releases) | affected | fails 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) | affected | fails 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 affected | no BaseSimilarityTestCase in the test framework (compile error) |
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.
| Check | Verdict | What 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-only | Reflection into doTestScoring, a conformance suite, javap of the shipped bytecode, an 8-seed sweep of the 23 core suites stock and fixed. |
| Minimal repro | verified | The one-test class fails on 645d4fe14a20 with -ea and -da and passes with the one-word fix. |
| Re-verification | real | Main -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 confidence | Own 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 validated | Repro 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. |
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 471 KB
Finding record, Lucene and upstream sources, repro files and diagram notes543 words
- 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, plainjava, 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-eaand-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 of645d4fe14a20. Upstream mainf815e679bf6c(2026-11-01) was read, not run: the file blob is unchanged.
- 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, :507a 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, :507a 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, :485a 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
- TestBaseSimilarityRarerTermCheck.java (one test):
repro/LFV-073/src/org/apache/lucene/search/similarities/TestBaseSimilarityRarerTermCheck.java(51 lines)
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).
assets/LFV-073/bug.png(471 KB)
