All findings · Lucene finding · LFV-413 · sweetspot-length-plateau-breaks-top-scores-pruning

SweetSpotSimilarity's length plateau makes top-k search skip the best hits

With SweetSpotSimilarity.setLengthNormFactors(10, 20, 0.5f), a field shorter than 10 terms has a smaller norm but a lower score, which breaks the rule Lucene's top-k pruning relies on. search(q, 10) then skips the block that holds the best hit (1.414) and returns 1.0 hits instead; every release since 8.0.0.

Summary

Priority
Medium, rank 62SweetSpotSimilarity with a length plateau has made default top-k search silently lose its best hits since 8.0, which is the same similarity-breaks-pruning profile as LFV-190 and more plausible than LFV-253's Axiomatic plus CombinedFieldQuery pairing.
Impact
silent wrong results
Since
8.0.0 (LUCENE-4198, 2018)introduced 2018-01-31 · about 8 years
Affected
8.0.0 to 10.5.2 and main
Status
documented
Found by
code reading and a bounded-exhaustive check of the norm rule

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

SweetSpotSimilarity lets you mark a range of field lengths as ideal; shorter and longer fields score lower. Lucene's top-k search assumes that, at the same term frequency, a shorter field (a smaller norm) never scores lower. Below the plateau that is false.

  1. Score field f with SweetSpotSimilarity and setLengthNormFactors(10, 20, 0.5f): 10 to 20 terms is ideal (length factor 1.0), a 2-term field gets 1/3. Index 3,000 docs: docs 0-1999 "t" + 9 words (score 1.0), docs 2000-2999 "t t" (0.471), except doc 2150, "t t" + 8 words (1.414, the best hit).
  2. Run searcher.search(new TermQuery(new Term("f", "t")), 10). You expect doc 2150 first, as a search that counts every hit returns.
  3. You get docs 0-9 (1.0 each); doc 2150 is missing. Lucene stores the field length as the norm and assumes a greater norm never scores higher. Below the plateau it does: norm 2 scores 0.333, norm 10 scores 1.0.
  4. Trusting that rule, the block of 256 postings that holds doc 2150 keeps only the pair (freq 2, norm 2) and claims a maximum of 0.471. From hit 1,001 on only scores above 1.0 can compete, so the search skips the block.
  5. No error, with assertions on or off, and the returned scores are right. The default plateau (min = max = 1) is safe. Every release since 8.0.0 is affected.

Who is affected

Applications that score a field with SweetSpotSimilarity and a plateau minimum of 2 or more, and run the default IndexSearcher#search(q, n) on terms with more than 1,000 matches. Solr's factory is exposed only with minExactCount. The default plateau is safe. How often real corpora lose hits was not measured.

Mechanism, terms and numbers767 words

SweetSpotSimilarity (lucene/misc) lets an application declare a plateau of ideal field lengths with setLengthNormFactors(min, max, steepness): lengths from min to max get a length factor of 1.0, shorter and longer fields less. It keeps the inherited computeNorm, which stores the field length as the norm, so below the plateau a longer field has a greater norm and a higher score. Similarity#computeNorm forbids exactly that ("greater unsigned norms must produce scores that are lower or equal"), because the postings writer keeps, for each block of 256 postings, only the (freq, norm) pairs that can produce the best score under that rule. A short field then hides a better-scoring longer field of the same or lower term frequency: the block claims a too-low maximum, and the default IndexSearcher#search(q, n) skips it once it has counted more than 1,000 hits. In the repro search(q, 10) returns docs 0-9 (1.0) and misses doc 2150 (1.414); Lucene's CheckHits.checkTopScores fails. Scores of returned hits are correct and nothing throws. Every plateau with min >= 2 (and a positive steepness) is affected, the default min = max = 1 is not; every release from 8.0.0 (block impacts, LUCENE-4198) to 10.5.2 and main.

Terms

  • Norm: one byte per document and field, written at index time by Similarity#computeNorm; by default the field length (SmallFloat.intToByte4, equal to the length up to 40 terms; Similarity.java:151-161).
  • Plateau: lengthNorm(x) = 1/sqrt(steepness * (|x-min| + |x-max| - (max-min)) + 1), 1.0 for min <= x <= max and lower on both sides (SweetSpotSimilarity.java:97-120). The default min = max = 1, steepness 0.5, is 1/sqrt(x), like ClassicSimilarity.
  • Block impacts: for each block of 256 postings the writer stores the (freq, norm) pairs that can produce the block's best score; top-k search computes the block's maximum score from those pairs and skips the block when it cannot beat the current k-th score.

With setLengthNormFactors(10, 20, 0.5f) (freq 1, idf 1):

field length (= norm byte)125910 to 202128
lengthNorm0.3160.3330.4080.7071.00.7070.333

Below 10 terms the score rises with the norm; above 20 it falls, as the rule requires.

The mechanism

  1. Score. SweetSpotSimilarity overrides lengthNorm (SweetSpotSimilarity.java:110-120) and tf (:127-130, sqrt(freq) with the default tf factors, as in ClassicSimilarity), but not computeNorm, so the norm is the length (Similarity.java:151-161). TFIDFSimilarity#scorer (final) fills normTable[i] = lengthNorm(byte4ToInt(i)) for the norm bytes 1-255 (TFIDFSimilarity.java:426-448) and scores tf(freq) * idf * boost * normTable[norm] (:467-472): freq 1 scores 1.0 at norm 10 and 0.333 at norm 2.
  2. Rule. Similarity#computeNorm: "for a given term-document frequency, greater unsigned norms must produce scores that are lower or equal" (Similarity.java:140-145); SimScorer#score: "Score must not increase when the unsigned norm increases" (:211-213).
  3. Index. Lucene104PostingsWriter adds each posting's (freq, norm) to a CompetitiveImpactAccumulator (Lucene104PostingsWriter.java:280-296). getCompetitiveFreqNormPairs() walks the norms upward and keeps a pair only if its freq beats every freq at a smaller norm (CompetitiveImpactAccumulator.java:101-111). In block 2048-2303 norm 2 has freq 2 (short docs) and norm 10 has freq 2 (doc 2150): the norm-10 pair is dropped, the block's maximum becomes sqrt(2)/3 = 0.4714, and Scorer#getMaxScore(2150) returns 0.47140452 while doc 2150 scores 1.4142135 (executed). The level-1 skip data of every 32 blocks merges these pairs through the same accumulator (Lucene104PostingsWriter.java:482-490), so its bound can be too low the same way.
  4. Search. IndexSearcher#search(q, n) counts 1,000 hits exactly (IndexSearcher.java:100, :597-599), then sets the minimum competitive score to Math.nextUp(1.0) (TopScoreDocCollector.java:154-165), and ImpactsDISI#advanceTarget skips every block whose maximum score is below it (ImpactsDISI.java:67-98). The block of doc 2150 claims 0.471 and is skipped; the top 10 is docs 0-9.
  5. Reach. A document is hidden when a shorter, sub-plateau field in its block has an equal or greater term frequency and a lower score. Plateau fields qualify only with a term frequency below min (without overlapping tokens, freq <= length); fields above the plateau qualify too: for 10..20 a 21-term field with t once (0.707) was hidden by 2-term fields with t once (0.333) in an executed variant, and found with the fix. One short field in the same block is enough (the independent reproducer's layout: doc 2000 "t t" hides doc 2001). A bounded-exhaustive check of every plateau 1 <= min <= max <= 64 with the steepness values 0.1, 0.5, 1 and 2 found 8,064 of 8,320 settings breaking the rule: exactly those with min >= 2 (steepness 0 would flatten the curve).
Exposure and likelihood237 words
  • Who is exposed. Users of org.apache.lucene.misc.SweetSpotSimilarity who call setLengthNormFactors with min >= 2, the plateau the class exists for. Solr ships SweetSpotSimilarityFactory (javadoc example 3/5/0.5) but counts hits exactly unless a request sets minExactCount; Elasticsearch and OpenSearch do not ship the similarity (source reading only).
  • Default or opt-in. Opt-in similarity with a non-default plateau; everything else is default: IndexWriterConfig, codec, plain IndexSearcher, IndexSearcher#search(q, n).
  • Scale. More than 1,000 matches for the default API (3,000 documents in the repro), and the hidden document's block must be reached after the collector has raised its bar (in the one-segment repro: after the first 1,001 hits); callers with a lower total-hits threshold prune sooner. Only postings with stored impacts are skipped (a postings format without impacts serves SlowImpactsEnum, which never skips).
  • Queries. TermQuery top-k executed wrong on main and every release run from 8.0.0 to 10.5.2; 2-clause disjunctions also on main and several releases. Phrase, synonym and other impact-based queries were not run.
  • Symptom. Silent: wrong best hits, correct scores, a correctly labelled hit count, a clean index, no exception with assertions on or off. A hit is lost only when a shorter, lower-scoring field with an equal or higher term frequency shares its 256-posting block and the hidden document beats the current k-th score. In a synthetic probe one short document per 200 postings removed the whole true top 10; real-corpus effect not measured.

Minimal repro

The test shows that with SweetSpotSimilarity.setLengthNormFactors(10, 20, 0.5f), the default IndexSearcher.search(q, 1) misses the best hit, because a shorter-than-plateau field makes the postings block's impacts underestimate its max score and TOP_SCORES skips the block.

TestSweetSpotTopScores.java (JUnit test)66 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.misc;

import org.apache.lucene.document.Document;
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.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.TopScoreDocCollectorManager;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestSweetSpotTopScores extends LuceneTestCase {

  public void testTopHitMatchesExhaustiveSearch() throws Exception {
    SweetSpotSimilarity sim = new SweetSpotSimilarity();
    sim.setLengthNormFactors(10, 20, 0.5f);
    IndexWriterConfig iwc =
        new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()).setSimilarity(sim);
    try (Directory dir = newDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (int i = 0; i < 1280; i++) {
        String text =
            switch (i) {
              case 1024 -> "t t";
              case 1025 -> "t t x x x x x x x x";
              default -> "t x x x x x x x x x";
            };
        Document doc = new Document();
        doc.add(new TextField("f", text, Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setSimilarity(sim);
        Query q = new TermQuery(new Term("f", "t"));
        TopDocs exhaustive =
            searcher.search(q, new TopScoreDocCollectorManager(1, Integer.MAX_VALUE));
        // expected: search(q, 1) finds the exhaustive top hit, doc 1025 (1.41); actual: doc 0 (1.0)
        assertEquals(exhaustive.scoreDocs[0].doc, searcher.search(q, 1).scoreDocs[0].doc);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-413/, 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:$LUCENE/lucene/misc/build/libs/lucene-misc-$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.misc.TestSweetSpotTopScores
Expected vs actual
Expected: search(q, 1) returns the exhaustive top hit, doc 1025 (score 1.41). Actual: doc 0 (score 1.0) (expected:<1025> but was:<0>), with -ea and -da.

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

More files, environment, full output6 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)

Why it looks unintended

  • Similarity#computeNorm: greater norms "must produce scores that are lower or equal"; the rule was added in 8.0 for exactly this pruning.
  • IndexSearcher says scoreDocs "is always accurate"; Lucene's own CheckHits.checkTopScores fails on the repro index.
  • The user only calls the public setLengthNormFactors, which documents no limit; BaseSimilarityTestCase checks this rule, but SweetSpot's tests never run it.
The full argument264 words
  • Contracts. Similarity#computeNorm (Similarity.java:140-145): "greater unsigned norms must produce scores that are lower or equal ... for any legal freq", and "1 is the norm that produces the highest scores". SimScorer#score (:211-216): "Score must not increase when the unsigned norm increases". IndexSearcher (IndexSearcher.java:59-67): only the hit count may be a lower bound; "The TopDocs#scoreDocs array is always accurate however." Scorer#getMaxScore must bound the scores it covers; it returns 0.4714 for a document scoring 1.4142.
  • No documented limit. setLengthNormFactors (SweetSpotSimilarity.java:85-95) names no restriction; SweetSpotSimilarity's own javadoc has no @lucene.experimental tag (its base class Similarity is experimental as a whole, Similarity.java:89); no CHANGES or MIGRATE entry warns about plateaus and top-k search.
  • Tests. BaseSimilarityTestCase asserts score(freq, norm-1) >= score(freq, norm) for every norm ("scores non-decreasing as docs get shorter", BaseSimilarityTestCase.java:437-470), and TestClassicSimilarity extends it. TestSweetSpotSimilarity extends only LuceneTestCase and never checks lengths below the plateau or a top-k search. A BaseSimilarityTestCase subclass on random plateaus fails on stock ("score(1.0,1)=0.19876102 < score(1.0,2)=0.20500971") and passes with the fix.
  • History. LUCENE-8119 (#9167) wrote the rule for impacts: "scores must not increase when the unsigned norm increases. The latter is something I need for #5270"; LUCENE-4198 (#5270): "This patch adds the assumption than (unsigned) greater norms produce equal or lower scores". Neither touched lucene/misc. The project fixed or dropped other non-monotone formulas (LUCENE-8010's sweep of core similarities; LUCENE-8015 for DFR; LUCENE-7997, #9045, changed BM25 to guarantee monotonicity, and LUCENE-9071 later sped it up and kept that guarantee; DFR's BE, P and D models were left out for this reason).
The devil's-advocate check179 words

The strongest case for "intended" (built by the devil's advocate, the GPT-5.6 intended-lens reviewer and the adjudication): the plateau and its drop-off below min are the class's documented purpose since 2006 (LUCENE-577); Similarity is a low-level API whose class javadoc says "Lucene makes no assumption about what is in this norm" (Similarity.java:47-48); the Similarity class (:89), ClassicSimilarity#lengthNorm and Similarity#computeNorm are @lucene.experimental; and a user can count all hits instead. So a maintainer could call it "garbage in".

Why it fails: the plateau's scores are intended, wrong top hits are not, and IndexSearcher promises accurate scoreDocs. The user implements nothing; a shipped, non-sandbox class breaks a "must" that core relies on. The later, specific rule (8.0, written for impacts) governs the older general sentence, and experimental status allows API changes, not wrong results. Every verifier and the adjudication rejected the argument. What is fairly conceded: any code fix changes the stored norm and needs re-indexing, so documenting "use min = 1 or count all hits" is a legitimate maintainer choice for a misc class.

Suggested fix

Recommended Store a sub-plateau length as the equally distant length above the plateau (validated)

In SweetSpotSimilarity, store a field of min - d terms as max + d (the same length factor) and score every length up to min as on the plateau, so norm order matches score order (+39/-6, one file). The repro, the upstream nightly test and the misc module pass. Short fields need re-indexing; wide plateaus change some scores.

@Override
public long computeNorm(FieldInvertState state) {
  final int numTerms = ...; // as in Similarity#computeNorm
  if (numTerms > 0 && numTerms < ln_min) { // below the plateau: store max + (min - numTerms)
    final long mirrored = (long) ln_max + (ln_min - numTerms);
    return SmallFloat.intToByte4((int) Math.min(Integer.MAX_VALUE, mirrored));
  }
  return SmallFloat.intToByte4(numTerms);
}

@Override
public float lengthNorm(int numTerms) {
  final long x = Math.max(numTerms, ln_min); // then the unchanged formula, in long arithmetic
  ...
All options and trade-offs5 options · 952 words

Option A was built and validated with gradle in a scratch clone of ea57360912ed; A2 was run in the standalone harness; B, C and D were not run. The finder's earlier sketch (store the distance from the plateau) failed the upstream nightly test and is not offered.

Store a sub-plateau length as the equally distant length above the plateau (validated) (recommended)

computeNorm writes a field of min - d terms (d >= 1) as max + d, which the plateau formula scores the same (both give 1/sqrt(2 s d + 1)), and lengthNorm uses x = max(numTerms, min), so over all 255 norm bytes the factor is 1.0 up to max and falls after it. Lengths >= min and empty fields are encoded as today; new indexes score exactly as before whenever the mirrored length is at most 40 (for example 29 for the plateau 10..20; identical scores for every length 1 to 2,000 on the plateaus 10..20 and 3..5), while wider plateaus change some scores (Precision below). Patch: SweetSpotSimilarity.java +39/-6; the computeNorm body copies Similarity#computeNorm.

@Override
public long computeNorm(FieldInvertState state) {
  final int numTerms = ...; // as in Similarity#computeNorm
  if (numTerms > 0 && numTerms < ln_min) { // below the plateau: store max + (min - numTerms)
    final long mirrored = (long) ln_max + (ln_min - numTerms);
    return SmallFloat.intToByte4((int) Math.min(Integer.MAX_VALUE, mirrored));
  }
  return SmallFloat.intToByte4(numTerms);
}

@Override
public float lengthNorm(int numTerms) {
  final long x = Math.max(numTerms, ln_min); // then the unchanged formula, in long arithmetic
  ...

Trade-offs

  • Re-indexing. Sub-plateau fields get a new norm (the computeNorm javadoc warns that overriding it needs re-indexing). Until an index is rebuilt, its old sub-plateau fields score like plateau fields, while newly indexed ones keep their penalty; merging copies norms and does not recompute them, so mixed segments score inconsistently until the documents are re-indexed (read, not executed).
  • lengthNorm(int) changes for direct callers: below min it returns 1.0; the drop-off moves into computeNorm. The class javadoc needs a sentence.
  • CombinedFieldQuery requires an additive computeNorm encoded with SmallFloat#intToByte4 (CombinedFieldQuery.java:60-64; the default computeNorm satisfies it); the mirrored encoding is not additive. Today the combination is correct, because its bound score(+Infinity, 1) is +Infinity and no block is skipped; with the fix, a document with two 2-term fields is scored as one 56-term field (28 + 28) instead of 4 terms: 0.5345225 becomes 0.23249526 (executed).
  • Precision (wide plateaus). A mirrored length above 40 keeps 4 significant bits and is rounded down, possibly into the plateau. For the plateau 50..100, 43 of the lengths 1 to 110 score higher than today, none lower; a 47-term field with t once goes from 0.4472136 to the plateau score 1.0 (executed). The norm order stays correct. Not tried: rounding the mirrored length up instead.
  • Not covered by the new tests: IndexOptions.DOCS fields and overlapping tokens (the override copies those branches from Similarity#computeNorm).
  • Backports. The design needs 9.12.0 or later (overridable Similarity#computeNorm; tested on main only). Through 9.11.1 TFIDFSimilarity#computeNorm is final and setLengthNormFactors takes a fourth argument, so 8.x and early 9.x need another design.
  • The long arithmetic also removes a NaN length factor for fields of more than about 1.07 billion terms (an int overflow noticed during the hunt, a separate edge). The BaseSimilarityTestCase subclass needs it because it probes norm byte 255; drop both for the smallest change.

What the checks validated

  • Gradle, scratch clone of ea57360912ed, OpenJDK 25.0.3: stock: the repro fails 5 of 6 (assertions on and off), the BaseSimilarityTestCase subclass fails, upstream TestSweetSpotSimilarity with -Ptests.nightly=true passes (its @Nightly testSweetSpotComputeNorm ran).
  • With the fix: the repro passes 6 of 6 (assertions on and off); the subclass passes 2 of 2 and 40 of 40 over 20 iterations; upstream TestSweetSpotSimilarity passes with -Ptests.nightly=true (two seeds, assertions on and off), including the 0.0-tolerance comparison with ClassicSimilarity that the finder's sketch failed; the whole misc module (25 classes, 323 tests, 0 failures, 9 skipped) and :lucene:misc:check -x test pass.
  • Standalone harness, fixed class shadowed: the finder's JUnit passes 2 of 2; the devil's advocate's main() agrees on all four configurations; the independent reproducer's JUnit passes 3 of 4: its testNormContract asserts as a precondition that a 2-term field's norm byte is 2, which the fix changes on purpose (28).

The computeNorm override alone, without the lengthNorm clamp (run, not recommended alone) (validated by the checks)

Old segments keep their old scores (and their wrong pruning) until re-indexed; new segments prune correctly.

Trade-offs

The score table still inverts for norm bytes below min, which new segments never write: the repro's two score-table checks fail (norm 2 scores 0.33333334 > norm 1 scores 0.31622776), the search tests pass, and score(Float.MAX_VALUE, 1) is then not the documented maximum.

What the checks validated

Standalone harness: the repro fails 1 of 6 (the score-table check); the subclass fails.

Flatten the curve below min (not run)

Only the lengthNorm clamp, no computeNorm change.

Trade-offs

No re-indexing and CombinedFieldQuery stays compatible, but short fields are no longer penalized at all, which drops half of what the class documents ("Below the min, and above the max the lengthNorm drops off").

Document the limitation (not run)

Javadoc on the class and on setLengthNormFactors: a plateau with min >= 2 breaks the computeNorm order, so top-k search with pruning can miss hits; use min = 1 or count all hits (totalHitsThreshold = Integer.MAX_VALUE; in Solr, no minExactCount).

Trade-offs

Cheap and honest, but a shipped class keeps silently breaking a core contract.

Make core tolerate non-monotone norms (rejected)

For example a per-similarity flag that disables impact-based skipping.

Trade-offs

Touches the postings format or every pruning scorer to support one misc class.

Downstream note

Why: whoever can get documents indexed into such a field can hide other documents from top-k results without touching queries. In a probe one short document hid the true top hit, one per 200 postings emptied the true top 10, and deleting them helped only after a merge. No denied or non-matching document is returned.

How it was found

Code reading of SweetSpotSimilarity against the Similarity norm rule, then a bounded-exhaustive check on the stock jar: 8,064 of 8,320 plateau settings break the rule, exactly those with min >= 2. A public-API test with search(q, 10) then showed the missed hit.

The method in full131 words

Method: semi-formal. A hunt unit (misc module, 2026-10-26). The finder, an AI tool, read SweetSpotSimilarity against the Similarity#computeNorm rule (the plan's secondary method, "SweetSpotSimilarity monotonicity"), then checked the rule bounded-exhaustively on the stock jar: every plateau 1 <= min <= max <= 64, 4 steepness values, every consecutive pair of norm bytes. 8,064 of 8,320 configurations violate it, exactly those with min >= 2; none with min = 1. It then wrote an end-to-end public-API JUnit with IndexSearcher#search(q, 10) and CheckHits.checkTopScores.

Then: an independent reproducer with its own layout, cross-model reviews (OpenAI GPT-5.6, reading only), a devil's advocate with a plain-IndexSearcher main(), an adjudication, a version sweep with a commit-level bisect, a prior-report search, a document-only security probe, and this doc's fix with gradle validation.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 55 tool queries and 23 manual GitHub searches across Lucene, Solr, Elasticsearch and OpenSearch, JIRA, the mailing lists and the web; 24 hits read, none this bug. The general rule is documented, but nobody tied it to SweetSpotSimilarity.

Searches5 searches

Verdict new. The rule itself is the oracle, not a report: LUCENE-8119 (#9167) and LUCENE-4198 (#5270) state it, LUCENE-7997 and LUCENE-8010 test and fix it for core similarities only, a committer restates it on SOLR-13289, and the Elasticsearch scripted-similarity docs warn that breaking it can "silently return wrong top hits". None names SweetSpotSimilarity or a plateau. Not fixed upstream: the last change to SweetSpotSimilarity.java is 7c056ab88c7 (#13757, 2024, a refactor), and no open PR touches the fix sites. Gaps: shared GitHub search throttling (3 queries retried), keyword-based list search, private channels (including the security list) not searchable.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26scripted prior-report search (16 issue/PR and 3 commit queries) plus manual searches: SweetSpot, TestSweetSpotSimilarity, BaseSimilarityTestCase, monotonic/non-monotonic similarity, computeNorm impacts, "unsigned norms", lengthNorm impacts, setLengthNormFactors, CheckHits checkTopScores similarity, "custom similarity" WAND/impactsno report; context items #9167, #5270, #9183, #9045; old SweetSpot issues from before impacts
Open apache/lucene PRs2026-10-26changed files of all 411 open PRsnone touches SweetSpotSimilarity, its test, TFIDFSimilarity or CompetitiveImpactAccumulator
Elasticsearch, OpenSearch, Solr, Lucene.NET, all of GitHub2026-10-26SweetSpotSimilarity with WAND, monotonic, block-max or impacts0 hits
JIRA (LUCENE, SOLR)2026-10-26SweetSpot*, monotonic + norm, lengthNorm since 2017, impacts + similarity + norm, plateau, TOP_SCORES + similarity, minExactCount + similarity; full comments of LUCENE-4198, -7730, -7997, -8010, -8015, -8116, -8119, -8135 and otherscontext only (the rule); no report
Mailing lists (dev@, java-user@, issues@, commits@, solr-user@, Solr lists) and the web2026-10-26SweetSpot, SweetSpotSimilarity, setLengthNormFactors; 10 web queriesa 2011 usage question and a generic migration article; no report

History

Introduced in 8.0.0 by LUCENE-4198 (f410df81136, 2018, block impacts); the default search(q, n) started pruning with LUCENE-8060. Every release run from 8.0.0 to 10.5.2, and main, is affected; 7.x is clean. Not fixed upstream.

Versions tested7 rows

Introduced by LUCENE-4198 "Give codecs the opportunity to index impacts" (f410df81136, jpountz, 2018-01-31; a JIRA patch, now #5270, no pull request): it added the impact accumulator that drops a (freq, greater norm) pair and TermScorer block skipping. Companion commits on the same 8.0 line: LUCENE-8119 (838c604b765, #9167) wrote the rule into Similarity; LUCENE-8060 (99dbe936818, #9108) made search(q, n) prune after 1,000 hits. Latent since 7.0.0: LUCENE-7730 (06a6034d9bc) made the norm the field length; the plateau formula dates from LUCENE-577 (2006).

Not executed: every 8.x release from 8.1.0 to 8.11.3; 9.4.1, 9.4.2, 9.5.0, 9.9.1, 9.9.2 and 9.11.0; 10.1.0 to 10.5.1 (all between affected releases, same code path); the 10.6 branch (10.6.0, released 2026-10-29); x86. For 2-clause disjunctions, "right" in some releases only means their disjunction scorer did not prune that layout.

Versions and branches executed, with results
Version or commitVerdictResult
7.0.0, 7.7.3 no pruningright top hit: the plateau already inverts the norm order, but nothing prunes on norms
f410df81136 (LUCENE-4198) vs parent ebb346d5820 bisectthe TermQuery top-scores case flips from right to wrong; controls agree on both
99dbe936818 (LUCENE-8060) vs parent 0dc124aa78e bisectthe default search(q, 10) flips from right to wrong
8.0.0, 8.11.4 first affectedTermQuery top-k misses the best hit (version probe; the finder's portable main() fails)
9.0.0 to 9.12.3 (15 releases run) affectedTermQuery top-k wrong in every one; 2-clause disjunctions also wrong on 9.3.0-9.7.0 and 9.12.x
10.0.0, 10.5.2 affectedTermQuery and 2-clause disjunctions wrong; the independent reproducer's JUnit fails 3 of 4 on 10.5.2
main ea57360912ed affectedthis doc's test fails 5 of 6 with assertions on and off; disjunctions wrong too

Independent checks

An independent reproducer, a devil's advocate, OpenAI GPT-5.6 reviewers (reading only) and an adjudication checked it: every executed check reproduced it, and the adjudication confirmed it with high confidence. The finder's first fix sketch was found broken and replaced.

Checks and verdicts319 words

The hunt's checks: the finder's candidate, a cross-model refute review, verifiers (an independent reproducer, an intended-lens adversary from another model family with its own runs, and a devil's advocate), an adjudication, the follow-up stages (history, prior art, security), a re-run and credibility audit, and this doc's fix validation.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateBounded-exhaustive check of the norm rule (8,064 of 8,320 plateaus violate it) and a JUnit that fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3; its fix sketch later proved broken.
Refute review (OpenAI GPT-5.6, reading only) not real (low; procedural)It could not run code, and it voted not real on procedure only; its reading agreed with the root cause through the impact accumulator, MaxScoreCache and ImpactsDISI; a separate re-run failed 2 of 2.
Independent reproducer realOwn JUnit with one short document next to the best one: fails 3 of 4 on main (assertions on and off), 10.5.2 and 9.12.3; the control passes.
Intended-lens adversary (OpenAI GPT-5.6, reading only, plus a verifier's runs) real (medium); fix sketch brokenRated the intended-behaviour case moderate and rejected it; its verifier found that the finder's fix sketch fails the upstream @Nightly testSweetSpotComputeNorm.
Devil's advocate real (high)Deterministic plain-IndexSearcher main() misses the top hit for plateaus 10..20 and 3..5 with assertions on and off; found the finder's JUnit seed-sensitive.
Adjudication confirmed (high)Re-ran the independent reproduction's test and the intended-behaviour check's main(); no convincing not-real argument.
Follow-up re-run and credibility audit keepRe-ran the independent reproduction, the intended-behaviour check and the finder's test on main (all fail as before, controls pass).
This doc's fix validation fix validatedGradle in a scratch clone: the repro fails 5 of 6 on stock and passes with the fix, as do the upstream nightly test and the misc module.

Sources and provenance

Finding record, Lucene and upstream sources and repro files1,286 words

Finding record

LFV-413, snapshot of 2026-11-06

Registry title
SweetSpotSimilarity with a length plateau (setLengthNormFactors with min >= 2) breaks Similarity's norm-monotonicity contract, so default top-k search silently misses the best hits
Area
misc / similarities (SweetSpotSimilarity) with core impacts-based TOP_SCORES pruning
Priority
Medium, rank 62: SweetSpotSimilarity with a length plateau has made default top-k search silently lose its best hits since 8.0, which is the same similarity-breaks-pruning profile as LFV-190 and more plausible than LFV-253's Axiomatic plus CombinedFieldQuery pairing.
Impact class
silent wrong results
Impact note
Applications that score a field with lucene/misc SweetSpotSimilarity and call setLengthNormFactors with min >= 2 (the plateau the class exists for; the default min = max = 1 is monotone) can get wrong top hits, with no error, from the default IndexSearcher.search(query, n) once more than 1,000 documents match, whenever a postings block holds a field shorter than the plateau whose term frequency is at least that of a better-scoring plateau-length field; default IndexWriterConfig, codec and searcher, -ea and -da, no scale beyond about 1,000 matches, every release since 8.0.0; returned scores are correct and the index is not damaged; callers that count hits exactly are not affected, and Solr is exposed only when minExactCount is set.
Summary
You score a field with SweetSpotSimilarity and setLengthNormFactors(10, 20, 0.5f), index mostly 10-term fields plus some shorter ones, and run searcher.search(new TermQuery(new Term("f", "t")), 10) on a term with more than 1,000 matches, expecting the same top hits as an exhaustive search. Instead a 10-term document that contains the term twice (score 1.414) is missing and a document that contains it once (score 1.0) comes first. Below the plateau a 2-term field scores lower than a 10-term field although its norm is smaller, which breaks the rule that a greater norm never scores higher; the postings writer relies on that rule, keeps only the short field's (freq, norm) pair for the block, advertises a block maximum of 0.471, and top-k search skips the block.
Introduced
f410df81136291f61b8deaace22bf4bb650de1ad (LUCENE-4198 'Give codecs the opportunity to index impacts', a JIRA patch by jpountz, migrated as apache/lucene#5270; no pull request): it added CompetitiveFreqNormAccumulator (today CompetitiveImpactAccumulator), which drops a (freq, larger norm) pair as dominated, and TermScorer block skipping under TOP_SCORES [exec: commit-level A/B, parent ebb346d5820 TermQuery top-scores SAME, f410df81136 DIFFERENT, controls SAME on both]. Companion commits, same author, same 8.0 development line: 838c604b765 (LUCENE-8119, #9167) wrote the norm-monotonicity rule into Similarity and is the earliest commit with any plateau-specific wrong hit (explicit TOP_SCORES disjunctions; that exposure was removed by LUCENE-8135 before any release); 99dbe936818 (LUCENE-8060, #9108) made the default search(q, n) use TOP_SCORES after 1,000 hits [exec: parent 0dc124aa78e default SAME, 99dbe936818 DIFFERENT]. Latent precondition, harmless before 8.0: 06a6034d9bc (LUCENE-7730, 7.0.0) encodes the field length as the norm; the plateau formula dates from LUCENE-577 (2006) (2018-01-31 (f410df81136; the contract commit 838c604b765 is 2018-01-09 and the default-search commit 99dbe936818 is 2018-08-01))
First affected version
8.0.0 [exec: the history probe and the finder's portable repro fail]; 7.0.0 and 7.7.3 are not affected [exec: no TOP_SCORES pruning exists there; the inverted norm property is already present but nothing relies on it]; none of the four 2018 commits was backported to 7.x [read]
Affected versions
Every release from 8.0.0 to 10.5.2 and main ea57360912ed; not fixed upstream (the last upstream commit to SweetSpotSimilarity.java is 7c056ab88c7, #13757, 2024-09-13, a refactor; no open PR touches the fix sites; read 2026-10-26). [exec] The history probe HistSweetSpotProbe (one public-API source for every version) misses the top hit of a TermQuery top-k search on 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.3.0, 9.4.0, 9.6.0, 9.7.0, 9.8.0, 9.9.0, 9.10.0, 9.11.1, 9.12.0, 9.12.1, 9.12.2, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da on main), with its controls agreeing everywhere; on its data 2-clause disjunctions also miss the top hit on 9.3.0-9.7.0, 9.12.0-9.12.3, 10.0.0, 10.5.2 and main (the other releases' disjunction scorers did not prune those layouts, which does not show them safe); the finder's JUnit fails on 9.12.3, 10.5.2 and main (-ea and -da) and its portable main() on 8.0.0, 8.11.4, 9.0.0 and 9.1.0; the independent reproduction's JUnit fails on 9.12.3, 10.5.2 and main (-ea and -da). [read] Releases not executed (8.1.0-8.10.1, 9.5.0, 10.1.0-10.5.1) lie between affected ones with the same mechanism; unreleased branch_10x and 10.6.0 (released 2026-10-29) not checked separately
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
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new: 55 scripted prior-report search queries over apache/lucene GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web; 23 manual GitHub searches (apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET and all of GitHub); manual LUCENE and SOLR JIRA queries including full-text greps of LUCENE-4198, -7730, -7997, -8010, -8015, -8116, -8119, -8135 and others; the solr-user@ and solr.apache.org lists; the changed files of all 411 open apache/lucene PRs; 24 hits read, none the same bug; the finder's, the independent reproduction's and the intended-behaviour check's searches found nothing either). Not fixed upstream: the last upstream commit to SweetSpotSimilarity.java is 7c056ab88c7 (#13757, 2024-09-13, refactor only), and no open PR touches SweetSpotSimilarity.java, TestSweetSpotSimilarity.java, TFIDFSimilarity.java or CompetitiveImpactAccumulator.java. Context, not reports: LUCENE-8119 (#9167) and LUCENE-4198 (#5270) state the assumption that greater norms never score higher; LUCENE-7997 and LUCENE-8010 test and fix it for core similarities only; a committer comment on SOLR-13289 and the Elasticsearch scripted-similarity documentation state the general rule; none names SweetSpotSimilarity or a length plateau. Gaps: shared GitHub search throttling (3 queries retried and succeeded), keyword-based list search, private channels 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; the version probe on 21 releases from 7.0.0 to 10.5.2 and on 10 commit builds of the 8.0 development line; the fix in a scratch clone of the same commit.

Lucene and upstream sources

Finding record
  • lucene/misc/src/java/org/apache/lucene/misc/SweetSpotSimilarity.java:21-26 (at ea57360912ed) (class javadoc: plateau; 'Below the min, and above the max the lengthNorm drops off'), :85-95 (setLengthNormFactors, no restriction documented), :110-120 (lengthNorm = 1/sqrt(s*(|x-min|+|x-max|-(max-min))+1), int arithmetic); the class inherits computeNorm
  • lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:140-143 (at ea57360912ed) (computeNorm javadoc: 'greater unsigned norms must produce scores that are lower or equal')
  • lucene/core/src/java/org/apache/lucene/codecs/CompetitiveImpactAccumulator.java:25, (at ea57360912ed) :46-47 ('greater norms compare lower'), :64-72 (add keeps the highest freq per norm), :101 and :128-134 (competitive (freq, norm) set: a pair is dropped when a smaller norm has an equal or greater freq)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:59-66 (at ea57360912ed) (only the hit count is approximate: 'The TopDocs#scoreDocs array is always accurate however'), :100 (TOTAL_HITS_THRESHOLD = 1000)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/SweetSpotSimilarity.java:21-26, 36-38, 85-95, 97-120
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:140-145, 151-161, 211-216
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/TFIDFSimilarity.java:426-448, 467-472
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/CompetitiveImpactAccumulator.java:64-72, 101-111
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsWriter.java:280-296, 482-490; ForUtil.java:34
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, 597-599; TopScoreDocCollector.java:154-165; ImpactsDISI.java:67-98
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:59-67, 100
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SlowImpactsEnum.java:106-107
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:47-48, 89, 140-147, 211-216
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:59-67
  • Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/SweetSpotSimilarity.java:85-95
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/ClassicSimilarity.java:39-43
  • Lucene main ea57360912ed: lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:437-470
  • Lucene main ea57360912ed: lucene/misc/src/test/org/apache/lucene/misc/TestSweetSpotSimilarity.java:39, 73-162
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:132-134, 151-161
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/CombinedFieldQuery.java:60-64, 416, 447-448

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

Repro files

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.