lengthNorm does its distance arithmetic in int, so with the default factors it wraps negative and returns NaN from length 2^30 + 1. The NaN norm then drops the document and breaks top-k pruning.
- A TF-IDF similarity multiplies a hit's score by a length norm that shrinks as the field gets longer.
SweetSpotSimilaritydocuments it as1/sqrt(steepness * (|x-min| + |x-max| - (max-min)) + 1), which is1/sqrt(x)for the defaults (min = max = 1, steepness 0.5). - The index stores each field's length in one byte. At search time the similarity computes the norm for each of the 255 decoded lengths. Byte 248 is the first above 2^30: it decodes to 1,073,741,848.
- Top-k search keeps the best possible score of each 256-document block (and of each 8,192-document range) and skips any block whose best score cannot beat the current k-th hit.
int sum |x-1| + |x-1| wraps negative above 2^30, norm bytes 248-255 become NaN, and top-k search skips the long document's block and skip range. Select the still to open it at full size. Generated with AI, checked against the code.- Score with
new SweetSpotSimilarity()and index 10,000 documents with oneDOCS_AND_FREQSfield: five hold only "a" (score 1.0), the others "a" plus padding (score 0.5), and doc 2000 holds "a" with custom term frequency 1,100,000,000. - You expect doc 2000's length norm to be
1/sqrtof its decoded length (norm byte 248, 1,073,741,848), 3.05e-5, as the javadoc andClassicSimilaritysay. Then it scores 1.0121535 andsearch(q, 7)returns it first, followed by the five documents that score 1.0. - Instead
lengthNormcomputes|x-1| + |x-1|inint. From x = 2^30 + 1 the sum passesInteger.MAX_VALUEand wraps negative, so the square root is NaN. Every field length from 1,073,741,848 up gets a NaN norm: doc 2000 scores NaN, and a NaN hit is never collected (collecting every hit returns 9,999 of 10,000). - The NaN also becomes the best possible score of its 256-document block and 8,192-document skip range, and NaN never beats the threshold, so top-k pruning skips them, doc 2000 included:
search(q, 7)returns[50, 9000, 0, 1, 2, 3, 4]and misses docs 2010, 3000 and 7000 (score 1.0). No error, with assertions on and off.
Only applications that score a normed field with the opt-in SweetSpotSimilarity (lucene/misc; the default factors are affected) and index one document whose field length reaches 1,073,741,848: custom term frequencies summing past a billion on a DOCS_AND_FREQS field, or about a billion tokens. Contrived, but silent when it happens.
Mechanism, terms and numbers765 words
SweetSpotSimilarity (lucene/misc) documents its length norm as 1/sqrt( steepness * (abs(x-min) + abs(x-max) - (max-min)) + 1 ) and says it degrades to 1/sqrt(x) with the default factors (min = max = 1, steepness 0.5). lengthNorm evaluates the sum in int, so with the defaults 2·(x − 1) wraps negative from x = 2^30 + 1 and the norm is NaN.
Through an index, lengthNorm sees only the 255 decoded norm lengths, so every field length of 1,073,741,848 or more (norm bytes 248-255) scores NaN. Lucene accepts such lengths: the indexing chain rejects only an int overflow, and one token with custom term frequency 1.1e9 on a DOCS_AND_FREQS field reaches it (so do about a billion ordinary tokens). The NaN document is never collected, and because a NaN block maximum never passes the competitive check, top-k search also skips competitive documents in the same 256-document postings block and 8,192-document skip range. Computing the distance in long fixes it. The int expression dates from the class's first version (2006); it is silent even with assertions on since 10.1.0. Low impact: an opt-in class and a 2^30-scale field.
- Int arithmetic.
SweetSpotSimilarity.lengthNorm(SweetSpotSimilarity.java:110-120):final int l = ln_min; final int h = ln_max;(:112-113), then(float) (1.0f / Math.sqrt((s * (float) (Math.abs(numTerms - l) + Math.abs(numTerms - h) - (h - l))) + 1.0f))(:116-119). The sum isint; the cast tofloatcomes after it. - It wraps. With the defaults (
:36-38) the sum is2·(x − 1): 2,147,483,646 at x = 2^30 (correct norm), -2,147,483,648 at x = 2^30 + 1, so0.5 · sum + 1is negative,Math.sqrtreturns NaN and so doeslengthNorm. - Decoded lengths.
computeNormstoresSmallFloat.intToByte4(length)(Similarity.java:151-161);TFIDFSimilarity.scorerfillsnormTable[i] = lengthNorm(byte4ToInt(i))for i = 1..255 (TFIDFSimilarity.java:427-446) andscorereads it (:470). Byte 247 decodes to 1,006,632,984 (finite), byte 248 to 1,073,741,848 (NaN), byte 255 to 2,013,265,944 (NaN). A field of exactly 2^30 + 1 terms encodes to byte 247 and is unaffected. - Legal length.
IndexingChainsums the field length withMath.addExact(1 per token forDOCS_AND_CUSTOM_FREQS, the custom frequency otherwise) and throws "too many tokens" only on anintoverflow (IndexingChain.java:2053-2064).TermFrequencyAttributenamesDOCS_AND_FREQSand sets no upper bound (TermFrequencyAttribute.java:23-34). - Not collected.
BatchScoreBulkScorercollects onlyif (score >= scorable.minCompetitiveScore)(BatchScoreBulkScorer.java:53-55): false for NaN. - Block skipped.
MaxScoreCache.computeMaxScoretakesMath.maxover the block's impacts (MaxScoreCache.java:84-88), andMath.maxreturns NaN if either argument is.ImpactsDISIkeeps a block onlyif (maxScore >= minCompetitiveScore)(ImpactsDISI.java:80-82) andgetSkipLevelclimbs to the highest level whose maximum fails that test (MaxScoreCache.java:137-158), so the 256-doc block and the 8,192-doc level-1 range of the default postings format (Lucene104PostingsFormat.java:343-351) are skipped. In the 10,000-document example, doc 50 is collected before pruning starts (after 1,000 hits) and doc 9000 lies outside that range; docs 2010, 3000 and 7000 lie inside it. There the raised bar reaches the term scorer at doc 1024 and one skip goes from doc 1024 to doc 8192, so doc 2000 itself is skipped before it is scored; step 5 is what drops it when every hit is collected.
int arithmetic wraps consistently, so an intermediate overflow alone is harmless: the result is wrong only when the true value 2·(x − max) (for x ≥ max ≥ min) exceeds Integer.MAX_VALUE, i.e. x ≥ max + 2^30 (executed probe):
| factors (min, max, steepness) | x | x − max ≥ 2^30 | lengthNorm | formula |
|---|---|---|---|---|
| 1, 1, 0.5 (defaults) | 2^30 | no | 3.0517578E-5 | 3.0517578E-5 |
| 1, 1, 0.5 (defaults) | 2^30 + 1 | yes | NaN | 3.0517578E-5 |
| 1, 30, 0.5 | 1,073,741,848 (byte 248) | no (the sum overflows, the result does not) | 3.0517578E-5 | 3.0517578E-5 |
| 3, 5, 0.5 | 2^30 + 5 | yes | NaN | 3.0517578E-5 |
| 100, 1000, 0.5 | 1,073,741,848 (byte 248) | no | 3.0517593E-5 | 3.0517593E-5 |
| 100, 1000, 0.5 | 1,207,959,576 (byte 249) | yes | NaN | 2.877226E-5 |
For the decoded lengths the wrapped value lies between -2^31 and about -2.7e8, so the norm is NaN for any steepness above about 4e-9; steepness 0 never gives NaN. With the defaults, norm bytes 248-255 are NaN, and bytes 1-247 are finite and match ClassicSimilarity to within one float step (243 of 247 identical; bytes 226, 229, 234 and 241 differ by one float step). Byte 0 is not a legal norm ("norm is never 0", Similarity.java:205); its table entry 1f / normTable[255] is NaN too, but by that contract a scorer never receives norm 0.
Exposure and likelihood223 words
- Who is hit. Applications that score a normed field with
SweetSpotSimilarityand index at least one document whose field length is at least 1,073,741,848 (with the defaults; other factors from max + 2^30). Routes: custom term frequencies on aDOCS_AND_FREQSfield (TermFrequencyAttribute, orDelimitedTermFrequencyTokenFilterinput such asa|1100000000), or about a billion ordinary tokens (an independent verifier indexed 1,073,741,848 tokens in about 45 s and reproduced it). The 10.5 optionDOCS_AND_CUSTOM_FREQScounts tokens instead of summing frequencies, so it closes the custom-frequency route, not the ordinary-token one. - What goes wrong. Silently: the long document scores NaN and never appears in scored results (in the example it should be the top hit), and default top-k search (pruning after 1,000 hits) also skips the competitive documents of the same term in its 256-document block and 8,192-document skip range, returning weaker hits in their place. The skipping reach depends on the postings format: on 9.12.3 a different set is lost.
- Not affected. Non-scoring matching (filters,
count(), constant-score queries). The stored norm byte is the encoded length, which is correct, so a fix needs no reindex (since 7.0). - Likelihood: contrived. A 2^30-scale length in one field does not happen by accident, and maintainers ask users of custom frequencies to keep "reasonable values" (#11086). Low priority, but a real defect in the documented formula.
One JUnit test in lucene/misc, public API only, no index: it calls new SweetSpotSimilarity().lengthNorm(2^30 + 1) and compares with 1/sqrt(x). Deterministic; fails with assertions on and off on main and 10.6.0.
/*
* 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.tests.util.LuceneTestCase;
public class TestSweetSpotLengthNormOverflow extends LuceneTestCase {
public void testDefaultLengthNormAboveTwoToThe30() {
int numTerms = (1 << 30) + 1;
// javadoc: with the default factors lengthNorm degrades to 1/sqrt(x); actual: NaN
assertEquals(
(float) (1 / Math.sqrt(numTerms)), new SweetSpotSimilarity().lengthNorm(numTerms), 0f);
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20 (or any commit with the same SweetSpotSimilarity.java)
cp $REPRO/TestSweetSpotLengthNormOverflow.java lucene/misc/src/test/org/apache/lucene/misc/
./gradlew :lucene:misc:test --tests org.apache.lucene.misc.TestSweetSpotLengthNormOverflow -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
- Expected
lengthNorm(1073741825)= 3.0517578E-5, i.e.1/sqrt(x), asClassicSimilarityreturns.- Observed
- NaN:
expected:<3.0517578E-5> but was:<NaN>, on main and 10.6.0, assertions on and off.
More files, environment, full output258 words
The test calls the public lengthNorm with 2^30 + 1, the first length that overflows with the defaults. Through an index the first affected length is 1,073,741,848 (norm byte 248); the end-to-end effects (the NaN document dropped, other hits skipped) follow from the NaN norm and are shown by the larger tests of the study, summarised under "Observed" below.
As the regression test it can go in as is: with the fix and this file in place, :lucene:misc:check -x test (google-java-format, ecj lint, forbidden APIs, license checks) passes. It can also become a method of TestSweetSpotSimilarity.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
TestSweetSpotLengthNormOverflow > testDefaultLengthNormAboveTwoToThe30 FAILED
java.lang.AssertionError: expected:<3.0517578E-5> but was:<NaN>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:C95E8C6BB254070C]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:577)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:701)
at org.apache.lucene.misc.TestSweetSpotLengthNormOverflow.testDefaultLengthNormAboveTwoToThe30(TestSweetSpotLengthNormOverflow.java:26)
:lucene:misc:test (FAILURE): 1 test, 1 failure
# the same assertion with -Ptests.asserts=false, and on the 10.6.0 release jars (assertions on and off)
# end to end, the 10,000-document example (main 645d4fe14a20 jars, the same with -ea and -da):
SweetSpotSimilarity: count=10000 explain(2000)=NaN
default search(q, 7): [50:1.0 9000:1.0 0:0.5 1:0.5 2:0.5 3:0.5 4:0.5 ]
exhaustive top 7: [50:1.0 2010:1.0 3000:1.0 7000:1.0 9000:1.0 0:0.5 1:0.5 ]
exhaustive hits: 9999
ClassicSimilarity (and SweetSpotSimilarity with the fix): count=10000 explain(2000)=1.0121535
default search(q, 7): [2000:1.0121535 50:1.0 2010:1.0 3000:1.0 7000:1.0 9000:1.0 0:0.5 ]
exhaustive hits: 10000
# norm table of the default SweetSpotSimilarity
byte 247 decodes to 1006632984: SweetSpot 3.151842E-5, Classic 3.151842E-5
byte 248 decodes to 1073741848: SweetSpot NaN, Classic 3.0517578E-5
byte 255 decodes to 2013265944: SweetSpot NaN, Classic 2.2286888E-5- The javadoc formula has no length limit and "degrades to 1/sqrt(x)" with the default factors;
ClassicSimilarityreturns 3.0517578E-5 for the same length. - Lucene accepts the length: indexing rejects only an
intoverflow, the bound a maintainer set for custom frequencies (#9990). - Lucene's similarity tests require finite scores for every norm;
TestSweetSpotSimilaritynever runs them and checks lengths below 1,000 only.
The full argument224 words
- The contract. "Implemented as:
1/sqrt( steepness * (abs(x-min) + abs(x-max) - (max-min)) + 1 )" and "This degrades to1/sqrt(x)when min and max are both 1 and steepness is 0.5" (SweetSpotSimilarity.java:97-102), the defaults (:36-38). - The upstream test asserts that equivalence with
ClassicSimilarityfor lengths 1 to 999 only (@Nightly testSweetSpotComputeNorm, "base case, should degrade",TestSweetSpotSimilarity.java:72-86). - Finite scores.
BaseSimilarityTestCasechecks that "scores are non-negative and finite" for every norm value (LUCENE-6896, -7997, -8010), butTestSweetSpotSimilarityextendsLuceneTestCase.SimScorer#scoremust not decrease when freq increases "for any value of norm that may be produced by computeNorm", and must not increase when the unsigned norm increases "for any legal freq" (Similarity.java:207-213); NaN satisfies neither, since every comparison with NaN is false.TopScoreDocCollectorsays NaN andNEGATIVE_INFINITY"are not valid scores" (TopScoreDocCollector.java:30-31). - Legal input.
IndexingChainaccepts the length (Math.addExact,:2053-2064). Michael McCandless on #9990 (LUCENE-8947): "users must not try to write too many, too large custom term frequencies such that their sum overflows a Javaintin a single field / document"; 1.1e9 is inside that bound. The ordinary-token route needs no custom frequency at all. - History. The
intexpression is in the class's first version (LUCENE-577, 2006); no javadoc, CHANGES entry, test or issue sets a length limit belowInteger.MAX_VALUE.
The devil's-advocate check132 words
The strongest case (verifiers and a cross-model review): SweetSpotSimilarity is an expert, legacy class in lucene/misc; a billion-term field is absurd; and custom term frequencies are the user's responsibility: "If you use custom term frequencies (expert feature), then it is your responsibility to have reasonable values. Otherwise, things can easily overflow, break scoring" (Robert Muir, #11086, won't fix). Once a score is NaN, the collector's behaviour is documented.
Why it fails: #11086 is about bypassing the int-sum check, and the same comment names the bound ("less than int32"); this trigger is inside it, and the ordinary-token route needs no expert feature. The NaN arises in the similarity, whose own javadoc promises a finite value that the parent class computes. Rarity lowers the priority; it does not make the NaN intended.
Recommended Compute the distance in long
Compute the distance in long: final long l = ln_min; final long h = ln_max; in lengthNorm (two tokens). Validated on main: the repro passes with assertions on and off; TestSweetSpotSimilarity with its nightly length-norm test, the whole lucene/misc test task and check pass. No reindex needed.
public float lengthNorm(int numTerms) {
- final int l = ln_min;
- final int h = ln_max;
+ // long, not int: abs(x-min) + abs(x-max) can exceed Integer.MAX_VALUE for long fields
+ final long l = ln_min;
+ final long h = ln_max;
final float s = ln_steep;All options and trade-offs3 options · 533 words
The recommended fix was run in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3), removed afterwards. "Closest suite" means TestSweetSpotSimilarity, the class's own test, run with -Ptests.nightly=true so that its @Nightly length-norm test runs too. LFV-413 (a plateau breaks norm monotonicity in the same method) is a different defect: this fix does not fix it, and its redesign would remove this overflow as a side effect.
Declare l and h as long in lengthNorm, so the whole expression is evaluated in long; numTerms is an int, so abs(x-min) + abs(x-max) stays below 2^33. The cast to float follows as before. Since 7.0 the norm byte stores the encoded length, which was always correct, so the fix changes only the search-time norm table: no reindex.
public float lengthNorm(int numTerms) {
- final int l = ln_min;
- final int h = ln_max;
+ // long, not int: abs(x-min) + abs(x-max) can exceed Integer.MAX_VALUE for long fields
+ final long l = ln_min;
+ final long h = ln_max;
final float s = ln_steep;Trade-offs
- Values. With the fix the default norm table equals
ClassicSimilarityat 250 of 255 bytes; bytes 226, 229, 234 and 241 (unchanged from today) and byte 252 (2.49175E-5 vs 2.4917497E-5) differ by one float step, from the existingfloatconversion of the sum; lengths 1-999 are all identical. - Cost: negligible: 255 evaluations when a scorer is built, as today (
TFIDFSimilarity.java:442-446); not measured.
What the checks validated
- Gradle on a scratch worktree of
645d4fe14a20: stock fails the repro with assertions on and off; with the fix it passes with assertions on and off. TestSweetSpotSimilaritywith-Ptests.nightly=true: 3 of 3 pass, including the@NightlytestSweetSpotComputeNorm(16 s), which no earlier run had enabled (stock passes it too).- The whole
lucene/misctest task: 317 tests, 9 skipped, 0 failures (the repro included).:lucene:misc:check -x testpasses with the repro in place. - With the fixed class shadowed into the jars: the independent reproduction's end-to-end test passes 8 of 8; the 10,000-document example gives the
ClassicSimilarityhits; the study's 6-test JUnit and the ordinary-token test passed with the same fix ona9779ef3c193. - Not run: other JDKs, x86, the full Lucene test suite.
Cast to double before the subtraction, e.g. Math.abs((double) numTerms - l) + ....
Trade-offs
Also overflow-free and closer to the javadoc formula. For the default factors, keeping the sum in double (dropping the (float) cast) reproduces ClassicSimilarity exactly at all 255 bytes and all lengths 1-999, since 0.5 · 2(x − 1) + 1 = x is exact in double; keeping the (float) cast gives the same values as the long fix (a standalone probe of both variants). Neither changes the nightly comparison for lengths 1-999; for other factors a norm may move by about one float step versus today. Not run through gradle.
TFIDFSimilarity.scorer could throw when lengthNorm returns a non-finite value.
Trade-offs
Catches any subclass with the same kind of bug, but turns silent wrong results into a search-time failure, and still needs the long fix. Not run.
Downstream, an application that lets document authors set custom term frequencies (for example with DelimitedTermFrequencyTokenFilter) on a field scored with SweetSpotSimilarity lets one such document drop itself and nearby relevant hits from ranked results; filters and non-scoring queries are unaffected.
A harness compared Lucene's scoring components with their documented formulas. For SweetSpotSimilarity.lengthNorm it checked the real jar against the javadoc formula, evaluated exactly, over lengths 1 to 1,000 and all 255 decoded norm lengths for 48 factor settings: 305 NaN results, all at norm bytes 248-255. A JUnit then showed the effect end to end.
The method in full124 words
Method: semi-formal (bounded-exhaustive translation validation of the real jar against exact semantics).
- Harness
FormulaTV, part A1:lengthNormon the stock jar against the javadoc formula evaluated inBigDecimal, for n = 1..1000 and the 255 decoded norm lengths x 8 plateaus x 6 steepness values: 60,240 checks, 334 mismatches, 305 of them NaN, all at norm bytes 248-255; the rest are rounding differences of at most 1.2 ulp. Part A1b ("degrades to 1/sqrt(x)"): 8 mismatches in 1,255, all NaN at bytes 248-255. - The finder read the root cause in the code and bridged it end to end with one custom term frequency; an independent reproducer wrote its own test from the claim alone, and a devil's advocate reproduced it with ordinary tokens.
No prior report found: 68 tool queries over GitHub, commits, downstream projects, the mailing lists, the JIRA archive and the web, 51 manual GitHub, JIRA and list searches, and the changed files of all 408 open PRs (2026-10-27); GitHub re-checked 2026-10-31. #11086 and #9990 discuss custom-frequency bounds in general.
Searches4 searches
Verdict new: no prior report was found in the searches below; 26 hits were read, none the same bug. Context, not reports: #11086 / LUCENE-10048 and #9990 / LUCENE-8947 (the user-side bound for custom frequencies is the int sum); #15584 (adds DOCS_AND_CUSTOM_FREQS, which counts tokens); LUCENE-6896, -7997, -8010 (finite scores for every norm value, core similarities only); #3347 / LUCENE-2271 (collectors do not handle NaN scores). Limits: keyword-based search, one page per GitHub query; private channels and security@ were not searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits), downstream repositories, mailing lists, JIRA archive, web | 2026-10-27 | a scripted prior-report search, 68 queries (20 GitHub, 3 commit, 2 downstream, 28 mailing-list over dev@, java-user@, issues@ and commits@, 6 JIRA, 9 web) | 350 distinct hits; the only high-overlap one (#11086) is context, not this bug |
| GitHub, JIRA and lists, manual | 2026-10-27 | 33 GitHub searches (apache/lucene, apache/solr, apache/lucenenet, Elasticsearch, OpenSearch, all of GitHub), 5 JIRA and 13 list queries; 26 hits read | no report of an overflow, NaN norm or hidden hits in SweetSpotSimilarity |
| Open PRs and the file's upstream log | 2026-10-27 | changed files of all 408 open apache/lucene PRs; log of SweetSpotSimilarity.java on main and the 10.x branches | no open PR touches the file; last change a 2024 refactor (#13757) |
| GitHub, refresh | 2026-10-31 | SweetSpotSimilarity issues and PRs; "lengthNorm NaN"; #11086 and #9990 comments re-read | still only the same old SweetSpotSimilarity issues (newest #8547, 2016; "SweetSpotSimilarity" returns 7 issues, "SweetSpot" 5); no PR, no "lengthNorm NaN" issue |
In the class since its first check-in (LUCENE-577, 2006); first released in 2.0.0. NaN score and a document missing from top-k results since 7.0.0, skipped neighbours since 8.0.0, silent with assertions on since 10.1.0. The minimal test fails on main and 10.6.0. Not fixed upstream.
Versions tested8 rows
Introduced by d2b63d328be0 ("LUCENE-577: initial checkin of SweetSpotSimilarity", by Chris M. Hostetter, 2006-05-25, #1655; no PR). git log -S finds no other commit that changed the int expression; later edits (LUCENE-1420, LUCENE-2912 a01e9cbb861, which wrote today's final int l = ln_min; final int h = ln_max;, LUCENE-7730, LUCENE-9570 reformatting) kept it. Not a regression.
How the symptom changed: 2.0.0-6.6.6 stored the NaN norm in the index byte (on the aarch64 test host it became the largest norm, so the long document ranked first); LUCENE-7730 (06a6034d9bc, 7.0.0) moved lengthNorm to search time over decoded lengths and LUCENE-7854 (d276acfbbcb, 7.0.0) added custom term frequencies: score NaN, document dropped; LUCENE-4198 (f410df81136, 8.0.0, impacts) added the skipping; #13939 (f8ea1305144, 10.1.0) removed the collector assertion that used to trip with -ea.
Not fixed upstream: SweetSpotSimilarity.java is byte-identical on upstream main 2f424fd428da, branch_10_6 and the 10.6.0 tag (read, 2026-10-31).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails (gradle, assertions on and off); 10,000-document example loses 4 of the top 6 hits |
| 10.6.0 | affected | minimal test fails (release jars, assertions on and off) |
| 10.1.0 - 10.5.2 | silent | 10.5.2 executed: the finder's and the independent reproduction's JUnits fail with assertions on and off; the rest by code identity |
| 8.0.0 - 10.0.0 | skipping | NaN score, missing from top-k results (on 9.12.3 an exhaustive collection still returns it), skipped neighbours (reach varies by postings format); -ea trips assert score >= 0 first |
| 7.0.0 - 7.7.3 | dropped | NaN score, document dropped, top-k of the others correct; -ea trips the collector assertion |
| 2.0.0 - 6.6.6 | first affected 2.0.0 | NaN stored in the norm byte at index time: on the aarch64 test host the long document ranked first |
| 1.9.1 | API absent | no SweetSpotSimilarity: the probe does not compile |
fix on 645d4fe14a20 | not affected | repro passes (assertions on and off); TestSweetSpotSimilarity incl. nightly, lucene/misc tests and check pass |
An independent reproducer, a devil's advocate and an adjudication rated it real; cross-model reviews (OpenAI GPT-5.6, reading only) agreed on the root cause, one rating it real and one "not real" on procedure only. An audit re-ran it with fresh seeds and lowered the likelihood to contrived.
Checks and verdicts285 words
Neither cross-model review could run code; separate runs re-ran the repro. This brief re-ran the minimal test with gradle on 645d4fe14a20 and on 10.6.0, the independent reproduction's end-to-end test on 645d4fe14a20, and corrected the overflow condition (x ≥ max + 2^30, not an intermediate overflow of |x-min| + |x-max|).
| Check | Verdict | What it found |
|---|---|---|
| Finder (bounded-exhaustive formula check, then a JUnit) | candidate | 60,240 checks, 305 NaN, all at norm bytes 248-255; the JUnit fails on main, 10.5.2 and 9.12.3. |
| Cross-model review, refute lens (OpenAI GPT-5.6, reading only) | not real (low, procedural) | Could not run code; agreed with the root cause and rated the intended case weak; a separate re-run failed 4 of 6. |
| Independent reproducer | real | Own test from the claim: 6 of 8 fail on main and 10.5.2 with assertions on and off; whole level-1 range lost; 8 of 8 pass with long. |
| Cross-model adversary, intended lens (OpenAI GPT-5.6, reading only) | real (medium) | Intended argument weak; inputs in contract; separate runs fail on main, 10.5.2 and 9.12.3 and pass with the fix. |
| Devil's advocate | real (high) | Reproduced with 1,073,741,848 ordinary tokens, no custom frequency; rebutted #11086 with #9990. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's test (6 of 8 fail with assertions on and off; 0 with the fix); adopted the wording corrections. |
| Credibility audit | downgrade (likelihood contrived) | Fresh seeds: fails 4 of 6, passes 6 of 6 with the fix. |
| Minimal repro and this brief | still reproduces; fix validated | One test; fails on 645d4fe14a20 (gradle, assertions on and off) and 10.6.0; the fix passes the repro, the class's suite incl. nightly, the misc tests and check. |
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 492 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,647 words
- Registry title
- SweetSpotSimilarity.lengthNorm adds abs(x-min) + abs(x-max) in int, so a field of 1,073,741,848 or more terms (reachable with one custom term frequency) gets a NaN norm: the document is never collected, and default top-k search also skips competitive documents in the same postings block and skip range
- Area
- misc / similarities (SweetSpotSimilarity lengthNorm; NaN effect on impacts-based top-k pruning in core search)
- Priority
- Low, rank 972: A field of 2^30 terms under the opt-in legacy SweetSpotSimilarity gives NaN norms that drop the document and break top-k pruning, a contrived trigger that one custom term frequency can reach.
- Impact class
- silent wrong results
- Impact note
- Hits only applications that score a normed field with the opt-in SweetSpotSimilarity (lucene/misc; the default factors min = max = 1, steepness 0.5 are affected, other factors with steepness > 0 once |x-min| + |x-max| exceeds Integer.MAX_VALUE) and index one document whose field length is at least 1,073,741,848 (the smallest length that encodes to norm byte 248), in practice a custom term frequency of about 1.1e9 on a DOCS_AND_FREQS field or about 1.07e9 ordinary tokens: that document scores NaN and is never collected, and default top-k search silently skips competitive documents in its 256-doc postings block and, on main and 10.5.2, its whole 8192-doc level-1 skip range [exec: main and 10.5.2 with -ea and -da; 8.0.0 to 10.0.0 with -da, where -ea trips Lucene's own collector assertion instead; the NaN norm itself in every release since 2.0.0]; the 10.5 option DOCS_AND_CUSTOM_FREQS counts tokens and so closes the custom-frequency route, not the ordinary-token one.
- Summary
- You score with SweetSpotSimilarity (default factors) and index one document whose DOCS_AND_FREQS field has a length of at least 1,073,741,848, for example one token with custom term frequency 1.1e9 or about 1.07e9 ordinary tokens, expecting the documented lengthNorm 1/sqrt(x) (about 3.05e-5, the ClassicSimilarity value) and an ordinary, low-ranked hit. Instead lengthNorm's int sum |x-1| + |x-1| wraps negative, so the norm and the score are NaN and the document is never collected (exhaustive collection returns 5,999 of 6,000 matches); because the NaN also makes the postings block's maximum score NaN, default top-k search skips that block and the skip range above it, so search(q, 7) on a 10,000-document index misses three documents that score 1.0 and returns documents that score 0.5 instead. ClassicSimilarity on the same index is correct.
- Introduced
- d2b63d328be030bfd659319cdeea8b3adeb6f10e (LUCENE-577 'SweetSpotSimiliarity', the initial check-in of SweetSpotSimilarity, SVN r409472, mirrored as apache/lucene#1655; no pull request) [read: git log -S 'Math.abs(numTerms - l)' over the full history finds only this commit, git blame of today's lines; exec: 1.9.1 has no such class, 2.0.0 shows the NaN]: the int expression is in the class's first version, so this is not a regression, and every later change to the method (LUCENE-1420 24d76157a06, LUCENE-2912 a01e9cbb861, LUCENE-7730 06a6034d9bc, LUCENE-9570 2cbf261032d) kept it. Today's symptoms became reachable later: 06a6034d9bc (LUCENE-7730, #8781, 7.0.0) moved the NaN from the stored norm byte into the search-time norm table, d276acfbbcb (LUCENE-7854, #8905, 7.0.0) added custom term frequencies (the cheap trigger), and f410df81136 (LUCENE-4198, 8.0.0) added the impacts-based block skipping (2006-05-25)
- First affected version
- 2.0.0 [exec: lengthNorm(1073741825) = NaN and a wrong stored norm byte, end to end; 2.0.0 was tagged the day after the introducing commit and its SweetSpotSimilarity.java is identical]; 1.9.1 is not affected because SweetSpotSimilarity does not exist there (the probe does not compile) [exec]. The present symptoms start later: NaN score and dropped hit in 7.0.0, skipping of other competitive documents in 8.0.0 [exec]
- Affected versions
- Every release from 2.0.0 to 10.6.0 and main a9779ef3c193 and 645d4fe14a20 [exec 2026-10-31: the minimal test fails on the 10.6.0 release jars (SHA-1 checked) and on main 645d4fe14a20 with -ea and -da]; never fixed (SweetSpotSimilarity.java last changed upstream in 7c056ab88c7, #13757, 2024-09-13, a refactor; unchanged at upstream main f7bcc0abd81, branch_10x and branch_10_6; no open PR touches it; read 2026-10-27). [exec] a public-API probe shows the NaN length norm on 18 releases (2.0.0, 2.4.1, 2.9.4, 3.0.3, 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2) and main; end to end on 11 of them (2.0.0, 2.4.1, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2) and main. Symptoms by era: 2.0.0-6.6.6 the NaN is encoded into the stored norm byte at index time (on this aarch64 host it becomes the largest norm, so the long document ranks first; the x86 outcome, norm 0, is reasoning only); 7.0.0-7.7.3 the score is NaN and the long document is dropped (-ea: Lucene's own collector assertion throws); 8.0.0-10.0.0 top-k also skips competitive documents (-ea: TopScoreDocCollector's 'assert score >= 0' throws first; the distant lost hit depends on the postings format: lost on 8.0.0, 8.11.4 and 9.0.0, found on 9.12.3 and 10.0.0); 10.1.0-10.5.2 and main silent with -ea and -da (the collector assertion was removed by #13939, f8ea1305144): the finder's JUnit fails 6 run / 4 failed. [read] all 144 release tags from 2.0.0 on in the history clone contain the int expression. All runs with JDK 25.0.3 on aarch64
- 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: exhaustive check
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record, verdict new): a scripted prior-report search of 68 queries (20 GitHub issue/PR, 3 commit, 2 downstream-repository, 28 mailing-list queries over dev@, java-user@, issues@ and commits@, 6 JIRA-archive and 9 web queries; 350 distinct hits, the only high-overlap one #11086, context), 33 manual GitHub searches (apache/lucene, apache/solr, apache/lucenenet, Elasticsearch, OpenSearch and all of GitHub), 5 JIRA queries, 13 list queries, the changed files of all 408 open apache/lucene PRs and the upstream commit log of SweetSpotSimilarity.java on main, branch_10x and branch_10_6; the finder's own scripted prior-report search (374 hits, 0 high) agrees; 26 hits read, none the same bug. Closest items, context not reports: #11086 (LUCENE-10048, won't fix: a maintainer says custom term frequencies need 'reasonable values' or 'things can easily overflow, break scoring'; it is about bypassing the int-sum check, not about lengths inside it); #9990 (LUCENE-8947: the user-side bound is that the frequency sum fits in an int, which this trigger satisfies); #15584 (merged 2026-04-24: adds DOCS_AND_CUSTOM_FREQS, which counts tokens; a maintainer notes remaining search-time concerns in general terms, nothing on SweetSpotSimilarity or NaN norms); #7876, #7954, #9045, #9058 (LUCENE-6818, -6896, -7997, -8010: finite, non-negative scores for every norm value, tested for core similarities only; TestSweetSpotSimilarity never runs that check); #3347 (LUCENE-2271, open: collectors do not support NaN scores, the downstream mechanism). Not fixed upstream: no open PR touches SweetSpotSimilarity.java, TestSweetSpotSimilarity.java, TFIDFSimilarity.java or ClassicSimilarity.java. Gaps: keyword-based search, GitHub's search rate limit, web search throttling, private channels and security@ not searched
- Lucene commit tested
- main
645d4fe14a20: gradle in a scratch worktree (the minimal test with assertions on and off, stock and fixed;TestSweetSpotSimilaritywith its@Nightlylength-norm test; the wholelucene/misctest task;:lucene:misc:check -x test), and the standalone runner (the independent reproduction's end-to-end test, amain()of the 10,000-document index and norm-table probes, stock and with the fixed class shadowed into the jars); the 10.6.0 release jars (assertions on and off). The study's runs: maina9779ef3c193, whose relevant code is identical, 10.5.2 and 9.12.3, and a probe over 18 releases from 2.0.0 to 10.5.2 (end to end on 11). Only OpenJDK 25.0.3 on Linux aarch64. Upstream main2f424fd428da,branch_10_6and the 10.6.0 tag have the sameSweetSpotSimilarity.java(read, 2026-10-31).
- Finding record
lucene/misc/src/java/org/apache/lucene/misc/SweetSpotSimilarity.java:36-38 (at a9779ef3c193) (defaults ln_min = 1, ln_max = 1, ln_steep = 0.5), :98-102 (lengthNorm javadoc: 'Implemented as: 1/sqrt( steepness * (abs(x-min) + abs(x-max) - (max-min)) + 1 )' and 'This degrades to 1/sqrt(x) when min and max are both 1 and steepness is 0.5'), :111-119 (lengthNorm: 'final int l = ln_min; final int h = ln_max;' and Math.abs(numTerms - l) + Math.abs(numTerms - h) - (h - l) evaluated in int)lucene/core/src/java/org/apache/lucene/search/similarities/TFIDFSimilarity.java:427-446 (at a9779ef3c193) (LENGTH_TABLE = SmallFloat.byte4ToInt of every byte; normTable[i] = lengthNorm(LENGTH_TABLE[i]) for i = 1..255; normTable[0] = 1f / normTable[255])lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:200-221 (at a9779ef3c193) (SimScorer#score: score must not increase when the unsigned norm increases, for any norm computeNorm may produce)lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2054-2063 (at a9779ef3c193) (field length summed with Math.addExact, custom term frequencies included; only an int overflow is rejected, as 'too many tokens')lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/TermFrequencyAttribute.java:23-34 (at a9779ef3c193) (custom term frequency, for DOCS_AND_FREQS fields)lucene/core/src/java/org/apache/lucene/search/BatchScoreBulkScorer.java:54 (at a9779ef3c193) (collect only if score >= minCompetitiveScore: false for NaN), MaxScoreCache.java:86 (block max = Math.max over impacts: NaN propagates), ImpactsDISI.java:80 (a block is kept only if maxScore >= minCompetitiveScore), TopScoreDocCollector.java:30-31 (NaN and NEGATIVE_INFINITY 'are not valid scores')lucene/misc/src/test/org/apache/lucene/misc/TestSweetSpotSimilarity.java:73-86 (at a9779ef3c193) (@Nightly testSweetSpotComputeNorm, 'base case, should degrade': equality with ClassicSimilarity for lengths 1..999 only)
- The finding in 30 seconds
lucene/misc/src/java/org/apache/lucene/misc/SweetSpotSimilarity.java:36-38, (at 645d4fe14a20) 97-120lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:151-161; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/TFIDFSimilarity.java:417-446, 470lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2053-2064; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/FieldInfo.java:718-723; lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/TermFrequencyAttribute.java:23-34lucene/core/src/java/org/apache/lucene/search/BatchScoreBulkScorer.java:53-55; (at 645d4fe14a20) MaxScoreCache.java:84-88, 137-158; ImpactsDISI.java:80-97; lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsFormat.java:343-351
- Who is affected
lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:151-161 (at 645d4fe14a20) (the norm byte stores the encoded length)
- Why it looks unintended
lucene/misc/src/java/org/apache/lucene/misc/SweetSpotSimilarity.java:36-38, (at 645d4fe14a20) 97-102lucene/misc/src/test/org/apache/lucene/misc/TestSweetSpotSimilarity.java:39, (at 645d4fe14a20) 72-86lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:256, (at 645d4fe14a20) 281, 383-387lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:207-213; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31; lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2053-2064github.com/apache/lucene/issues/9990 and /issues/11086 (comments read with gh, read-only, 2026-10-31)
- Prior reports
gh read-only queries, 2026-10-31 (gh search issues/prs --repo apache/lucene SweetSpotSimilarity; "lengthNorm NaN"; gh issue view 11086 and 9990 --comments)
- History
a Lucene clone (SweetSpotSimilarity.java md5 at 645d4fe14a20, origin/main 2f424fd428da, origin/branch_10_6 7e5aec4370e, releases/lucene/10.6.0, releases/lucene/10.5.2: identical)
- TestSweetSpotLengthNormOverflow.java (1 test):
repro/LFV-754/src/org/apache/lucene/misc/TestSweetSpotLengthNormOverflow.java(29 lines)
Made in parallel from the code at 645d4fe14a20 and the executed runs.
assets/LFV-754/bug.png(492 KB)
