All findings · Lucene finding · LFV-063 · axiomatic-f2-non-monotone

AxiomaticF2EXP and AxiomaticF2LOG scores can drop by 1 ulp when term frequency crosses a power of two

With the experimental AxiomaticF2EXP or AxiomaticF2LOG similarity at default settings, raising a term's frequency from 2^29 - 32 to 2^29 lowers its score by one float step (1.0134975 to 1.0134974). Top-k pruning assumes scores never fall as frequency rises, so a pruned search can return a different top hit.

Summary

Priority
Low, rank 1210The experimental AxiomaticF2LOG/F2EXP similarities drop 1 ulp when term frequency crosses a power of two near 2^29, contrived inputs with a 1-ulp effect.
Impact
silent wrong results
Since
8.0.0 (LUCENE-7997, 2017)introduced 2017-10-24 · about 9 years
Affected
8.0.0 to 10.6.0 and main
Status
documented
Found by
monotonicity analysis + cvc5 floating-point SMT counterexample

AI-assisted page: this finding was first signalled by a formal tool's own output 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

A rounding effect in the experimental Axiomatic F2 similarities can make the score fall by one float step at a power-of-two term frequency.

Background in three steps

  1. A similarity turns a term's frequency in a document (freq) and the field's length into a score. AxiomaticF2EXP and AxiomaticF2LOG compute it in double precision and return it as a float.
  2. Lucene requires that a higher freq never gives a lower score (SimScorer#score). Top-k search uses this to skip whole blocks of postings: a block's best possible score is the score of its highest freq.
  3. Doubles are evenly spaced inside each power-of-two range, and the spacing doubles at every power of two. Each arithmetic step rounds to that grid.
Diagram of the bug in four steps, from an executed repro on Lucene main. 1: the F2 score is tfln times idf with tfln = freq / (freq + c), each step rounded to a double and the result to a float. 2: the denominator freq + c is stored on a grid of doubles with steps of 2^-24 below 2^29 and 2^-23 from 2^29 on, so it rounds down at freq 2^29 - 32 and up at 2^29, and tfln falls by one double step. 3: the double score moves from one step above the midpoint between the floats 1.0134974 and 1.0134975 onto the midpoint, so the float cast gives 1.0134975 and then 1.0134974. 4: a postings block keeps only its highest freq, so its max score 1.0134974 is below doc 300's 1.0134975, and a pruned top-1 search returns doc 0 instead of doc 300.
Step by step on the repro's numbers: the formula, the denominator's double grid doubling at 2^29 so that tfln falls one double step, the float cast keeping the drop (1.0134975 to 1.0134974), and impacts-based pruning then skipping the block that holds the true top hit. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Score one term with new AxiomaticF2EXP() (default s = 0.25, boost 1) on a field of 611 documents (average length 1.53 billion tokens, docFreq 589), for a document whose field has 1,073,741,848 tokens.
  2. Raise its term frequency from 536,870,880 to 536,870,912 (2^29 - 32 to 2^29, adjacent floats). You expect the score not to go down: SimScorer#score must never decrease as freq grows, and pruning relies on that.
  3. You get 1.0134975, then 1.0134974: one float step lower. AxiomaticF2LOG drops the same way, 0.10277987 to 0.102779865, with assertions on or off.
  4. Why: in tfln = freq / (freq + 0.425) the denominator freq + 0.425 is rounded to the spacing of doubles at freq's size, and the quotient is rounded again. At 2^29 that spacing doubles, the denominator rounds up instead of down, and tfln falls by one double step although its true value rises. The double score moves from just above a float midpoint onto it, so the float cast rounds down.
  5. Consequence: a postings block remembers only its highest freq, so its max score (1.0134974) is below doc 300's real score (1.0134975). A pruning top-1 search returns doc 0; an exhaustive search returns doc 300.

Who is affected

Only users of the non-default, experimental AxiomaticF2EXP or AxiomaticF2LOG; the default BM25 is not affected. With the default s = 0.25 it needs a term frequency at a power of two near 2^29 in one field (for example custom term frequencies) and statistics on a float rounding midpoint, about 10 in 2.5 billion. The effect: a 1-ulp near-tie swap.

Mechanism, terms and numbers565 words

AxiomaticF2EXP and AxiomaticF2LOG compute tfln = freq / (freq + s + s * docLen / avgdl) in double and return the score as a float. Because freq appears both in the numerator and in a denominator that is rounded at freq's scale, the score can drop by one float step when freq crosses a power of two: with the default constructors, boost 1 and the statistics of a real index, 1.0134975 at freq 2^29 - 32 becomes 1.0134974 at 2^29 (F2LOG: 0.10277987 to 0.102779865). That breaks the SimScorer#score rule that a higher freq never gives a lower score, which impacts-based pruning relies on: on a real index a pruned top-1 search returns doc 0 while an exhaustive search returns doc 300. Every release from 8.0.0 to 10.6.0 and main are affected. The trigger is contrived (a frequency near 2^29 and statistics on a float rounding midpoint) and the effect is a 1-ulp near-tie swap, so the priority is low; the fix is a two-line rewrite like the one BM25 got in LUCENE-9071.

The formula

tfln = freq / (freq + s + s * docLen / avgdl) (AxiomaticF2EXP.java:72, AxiomaticF2LOG.java:62). F2 has tf = ln = 1 and gamma = 0, so the score is tfln * idf, times the boost, at least 0 (Axiomatic.java:107-116), and BasicSimScorer.score returns (float) of that double (SimilarityBase.java:186). In exact arithmetic tfln only grows with freq. In floating point the numerator freq is exact, but the denominator (freq + s) + (s * docLen / avgdl) is rounded to the double grid at freq's size, and the quotient is rounded again. docLen is the decoded norm read from a float table (SimilarityBase.java:150-154, :180-182), so a field length of 1,073,741,848 (norm byte 248) is used as (float) 1,073,741,848 = 1,073,741,824.

The numbers (F2EXP, from the repro's statistics)

c = s + s * docLen / avgdl = 0.25 + 0.25 * 1,073,741,824 / 1,533,824,654.9 = 0.4250105236...

freq = 2^29 - 32 = 536,870,880freq = 2^29 = 536,870,912
spacing of doubles at the denominator2^-24 (5.96e-8)2^-23 (1.19e-7)
stored denominator minus freq0.4250105023 (rounded down by 2.1e-8)0.4250105619 (rounded up by 3.8e-8)
tfln0.99999999920835620.9999999992083561
double score tfln * idf1.01349741220474271.0134974122047424
float score1.01349751.0134974

The exact tfln rises by 4.7e-17, less than the denominator's rounding error, so the computed tfln falls by one double step. The two floats around the score are 1.0134974 and 1.0134975, with midpoint 1.013497412204742431640625: the first double is one double step above it and rounds up, the second is exactly on it and rounds to even, down. Usually the float cast hides a double-level drop; it survives only on such a midpoint. F2LOG shows the same pattern with its own statistics: 0.10277987 to 0.102779865.

Why pruning then goes wrong

A postings block's impacts keep only the highest freq per norm (CompetitiveImpactAccumulator.java:67), and the block's max score is the score of that freq (MaxScoreCache.java:74-89). On the study's real index (default codec, 611 documents; docs 0 and 301 with freq 2^29, doc 300 with 2^29 - 32, all with field length 1,073,741,848), the block of docs 256-511 gets max score 1.0134974, below doc 300's 1.0134975. After doc 0 the collector asks for at least Math.nextUp(1.0134974) = 1.0134975 (TopScoreDocCollector.java:160), and ImpactsDISI skips the block (ImpactsDISI.java:75-88). Pruned top-1: doc 0; exhaustive top-1: doc 300.

Exposure and likelihood233 words
  • Who. Applications that set AxiomaticF2EXP or AxiomaticF2LOG (both @lucene.experimental) as their similarity. The study's monotonicity analysis of all 73 SimilarityBase model combinations flagged only these two.
  • Frequency. With the default s = 0.25, a power of two around 2^29 or 2^30 (the verifier's search found no double-level drop at 2^28 or below with docLen >= freq). That is 5.4e8 occurrences of one term in one field, or custom term frequencies through TermFrequencyAttribute, for example DelimitedTermFrequencyTokenFilter reading word|536870912 (DelimitedTermFrequencyTokenFilter.java:67); the field length then grows by the custom frequency (IndexingChain.java:2054-2060). A smaller s moves the boundary down: the finder's case used s = 0.0010000225, boost 0.88 and freq 2^24.
  • Alignment. The statistics (and boost) must put the double score on a float rounding midpoint: the verifier's random search found about 10 such points per 2.5e9 random index-realizable statistics at 2^29 and 2^30.
  • Visible effect. For a ranking change, a document with the higher freq and the same norm must share a postings block with the true best hit. The pruned search then returns a hit 1 float ulp (about 1e-7 relative) below the true best, silently. IndexSearcher.search(query, n) prunes once it has counted more than max(n, 1,000) hits (IndexSearcher.java:100, :599; TopScoreDocCollectorManager.java:69).
  • How likely. Long-standing (8.0.0 to main), but highly contrived: ordinary corpora are unlikely to reach it; the finding record rates it contrived, priority low.

Minimal repro

One JUnit test, public classes only, no index: it builds both scorers from the statistics of two real indexes and compares the score at freq 2^29 - 32 and 2^29, with the norm that computeNorm gives the field length. Deterministic, single thread, about one second; fails with assertions on and off.

TestAxiomaticF2ScoreMonotonicInFreq.java (1 test)51 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search.similarities;

import org.apache.lucene.search.FieldStats;
import org.apache.lucene.search.TermStats;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.SmallFloat;

public class TestAxiomaticF2ScoreMonotonicInFreq extends LuceneTestCase {

  public void testScoreDoesNotDecreaseWhenFreqReachesPowerOfTwo() {
    float lo = Math.nextDown(0x1p29f), hi = 0x1p29f;
    Similarity.SimScorer exp =
        new AxiomaticF2EXP()
            .scorer(
                1f,
                new FieldStats("f", 611, 611, 937_166_864_135L, 1200),
                new TermStats(new BytesRef("t"), 589, 1_610_613_290L));
    Similarity.SimScorer log =
        new AxiomaticF2LOG()
            .scorer(
                1f,
                new FieldStats("f", 644, 644, 944_092_194_137L, 1226),
                new TermStats(new BytesRef("t"), 582, 1_610_613_283L));
    long expNorm = SmallFloat.intToByte4(1_073_741_848);
    long logNorm = SmallFloat.intToByte4(805_306_392);
    float expLo = exp.score(lo, expNorm), expHi = exp.score(hi, expNorm);
    float logLo = log.score(lo, logNorm), logHi = log.score(hi, logNorm);
    // expected score(freq 2^29) >= score(freq 2^29 - 32) (SimScorer#score); actual 1 ulp lower
    assertTrue(
        "expected no drop, got F2EXP " + expLo + " -> " + expHi + ", F2LOG " + logLo + " -> "
            + logHi,
        expHi >= expLo && logHi >= logLo);
  }
}
Command
cp $REPRO/TestAxiomaticF2ScoreMonotonicInFreq.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/similarities/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.search.similarities.TestAxiomaticF2ScoreMonotonicInFreq -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
score(2^29) >= score(2^29 - 32) for both similarities.
Observed
F2EXP 1.0134975 -> 1.0134974 and F2LOG 0.10277987 -> 0.102779865, one float step lower, on main and every release 8.0.0 to 10.6.0.

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

More files, environment, full output277 words

The test sits in org.apache.lucene.search.similarities for locality but uses only public classes: the default constructors, Similarity#scorer with FieldStats/TermStats, SimScorer#score, and SmallFloat.intToByte4, which is how computeNorm encodes a field length (byte 248 for 1,073,741,848 tokens, 244 for 805,306,392). SmallFloat is marked @lucene.internal; a version without it can take the norm from Similarity#computeNorm(FieldInvertState) instead, as the cross-model reviewer's own test did. The statistics are those of the two real indexes on which the pruned and exhaustive top hits differ; sumDocFreq and the term's totalTermFreq are not read by F2 and only satisfy the stats assertions.

On releases the same test fails with CollectionStatistics/TermStatistics instead of FieldStats/TermStats (renamed on main by GITHUB#15929), and on 8.0.0 to 9.0.0 also with the older LuceneTestCase package.

As the regression test it is this same method. ./gradlew tidy (google-java-format) reflows the assertion message's string concatenation (formatting only); :lucene:core:check -x test then passes.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): affected; every release 8.0.0 to 10.6.0 with the release-API variant of this test
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestAxiomaticF2ScoreMonotonicInFreq > testScoreDoesNotDecreaseWhenFreqReachesPowerOfTwo FAILED
    java.lang.AssertionError: expected no drop, got F2EXP 1.0134975 -> 1.0134974, F2LOG 0.10277987 -> 0.102779865
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:8FD97C542809A73B]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.search.similarities.TestAxiomaticF2ScoreMonotonicInFreq.testScoreDoesNotDecreaseWhenFreqReachesPowerOfTwo(TestAxiomaticF2ScoreMonotonicInFreq.java:46)
:lucene:core:test (FAILURE): 1 test, 1  failure
# assertions off (-Ptests.asserts=false): the same AssertionError
# with fix A or fix B, assertions on and off: :lucene:core:test (SUCCESS): 1 test

# consequence on a real index (the study verifier's index-level class on 645d4fe14a20, -ea and -da; not the repro)
F2EXP: getMaxScore(511)=1.0134974 < score(doc300)=1.0134975; exhaustive top1=300 pruned top1=0
F2LOG: getMaxScore(511)=0.102779865 < score(doc300)=0.10277987; exhaustive top1=300 pruned top1=0

Why it looks unintended

  • SimScorer#score: "Score must not decrease when freq increases", for any norm, with no tolerance.
  • BaseSimilarityTestCase fails on any score(nextDown(freq)) > score(freq) and runs for both F2 models.
  • BM25 had the same freq / (freq + norm) form and was rewritten in LUCENE-9071 "to guarantee monotonicity"; F2 never was.
The full argument202 words
  • The contract (Similarity.java:207-209): "Score must not decrease when {@code freq} increases, ie. if {@code freq1 > freq2}, then {@code score(freq1, norm) >= score(freq2, norm)} for any value of {@code norm} that may be produced by {@link Similarity#computeNorm(FieldInvertState)}." No similarity is exempted.
  • The tests. BaseSimilarityTestCase lists "scores don't decrease as term frequencies increase" (:260-261) and compares score(Math.nextDown(freq)) or score(freq - 1) with score(freq), failing on any decrease (:403-435). TestAxiomaticF2EXP and TestAxiomaticF2LOG extend it through AxiomaticTestCase; random sampling practically never lands on an aligned power of two. Both models are also in the test framework's RandomSimilarity rotation (RandomSimilarity.java:126-127).
  • History. LUCENE-7997 (#9045) added these checks; Robert Muir wrote there "The other 4 models (F1EXP, F1LOG, F2EXP, F2LOG) are all fine". LUCENE-8010 (#9058) made the core similarities pass them, F2 with no change. LUCENE-8119 wrote the rule into the javadoc. LUCENE-9071 (#10113) rewrote BM25's freq / (freq + norm) as 1 - 1 / (1 + freq * 1/norm) "In order to guarantee monotonicity" (BM25Similarity.java:258-264), and in #13299 Adrien Grand explained that the naive form can give "a lower score evern [sic] though freq is greater by one ulp". F2's tfln has that naive form.
The devil's-advocate check152 words

The strongest case: the classes are @lucene.experimental, the effect is a 1-ulp near-tie with a contrived trigger, and in #9131 (LUCENE-8083, 2017) Robert Muir wrote about IB/SPL, F2EXP and F2LOG: "These similarities can't work with maxScore. We should remove or move them out". So it could be a known limitation.

Why it fails: that remark came before LUCENE-8010, after which F2EXP and F2LOG passed the monotonicity tests unchanged; LUCENE-8010 itself added them to the random test rotation, three weeks after the remark, they are still in core and in that rotation, and #9131 was closed as "invalidated by the indexing of impacts"; no mechanism was named and nothing documents an exemption. @lucene.experimental is about API stability, not about the contract pruning depends on. The contract has no tolerance, and Lucene already fixed this exact 1-ulp class for BM25. Verdict: a defect, low priority. The cross-model reviewer rated the intended-behaviour argument weak.

Suggested fix

Recommended tfln = 1 / (1 + c / freq) (recommended)

Rewrite tfln so freq appears once: double c = s + s * docLen / avgdl; return 1.0 / (1.0 + c / freq); in both classes. Every rounding step is then monotone and precision stays as today. The repro, the 119 similarities tests and check pass. The BM25 form 1 - 1/(1 + freq * (1/c)) also works.

   protected double tfln(BasicStats stats, double freq, double docLen) {
-    return freq / (freq + this.s + this.s * docLen / stats.getAvgFieldLength());
+    // freq / (freq + c) rewritten as 1 / (1 + c / freq): freq appears once, so every rounding
+    // step is monotone and the result cannot decrease as freq grows
+    double c = this.s + this.s * docLen / stats.getAvgFieldLength();
+    return 1.0 / (1.0 + c / freq);
   }
All options and trade-offs2 options ยท 666 words

Both options were run on a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3), removed afterwards. Both rewrite tfln so freq appears once: each IEEE operation with round-to-nearest is monotone in each argument, so the composition, the multiplication by the positive idf and boost, Math.max(0, .) and the float cast are all monotone in freq, the same argument as the comment in BM25's doScore. Both stay monotone in the document length. The exact value is unchanged; only rounding differs. In a probe of 50,000,000 random power-of-two boundaries (random s, document length and average length) the stock form had 561,849 double-level decreases and each option 0; that is a check, not a machine-checked proof.

tfln = 1 / (1 + c / freq) (recommended) (validated by the checks)

In AxiomaticF2EXP.tfln and AxiomaticF2LOG.tfln, compute c = s + s * docLen / avgdl once and return 1 / (1 + c / freq).

   protected double tfln(BasicStats stats, double freq, double docLen) {
-    return freq / (freq + this.s + this.s * docLen / stats.getAvgFieldLength());
+    // freq / (freq + c) rewritten as 1 / (1 + c / freq): freq appears once, so every rounding
+    // step is monotone and the result cannot decrease as freq grows
+    double c = this.s + this.s * docLen / stats.getAvgFieldLength();
+    return 1.0 / (1.0 + c / freq);
   }

Trade-offs

  • Keeps today's precision (no cancellation): worst relative error against an exact evaluation 3.65e-16, vs 3.49e-16 for the stock form, over freq 1e-3 to 1e9, document length up to 2e9 and average length 1 to 1e6 (1e6 samples).
  • Same number of divisions as the BM25 form; both use one more division per call than the stock form (3 instead of 2), and no scoring benchmark was run (c could be cached per norm). freq is finite and positive by contract, so c / freq is defined; s = 0 gives tfln = 1, as today.
  • Scores change only by rounding; the explain text ("computed as freq / (freq + s + s * dl / avgdl)") stays true.

What the checks validated

  • Stock 645d4fe14a20: the repro fails through gradle with assertions on and off.
  • With the fix: the repro passes with assertions on and off; the whole org.apache.lucene.search.similarities package passes (27 classes, 119 tests, seeds DEADBEEF and 5EED1234, 0 failures); :lucene:core:check -x test passes (after tidy reflowed the repro test only).
  • The study verifier's index-level class (the pruned vs exhaustive top hit) passes 4 of 4 with the fix shadowed onto the main jars, assertions on and off.

tfln = 1 - 1 / (1 + freq * (1 / c)), the BM25 form (the study verifier's fix direction) (validated by the checks)

Compute cInv = 1 / (s + s * docLen / avgdl) and return 1 - 1 / (1 + freq * cInv), the rewrite LUCENE-9071 made in BM25.

double cInv = 1.0 / (this.s + this.s * docLen / stats.getAvgFieldLength());
return 1.0 - 1.0 / (1.0 + freq * cInv);

Trade-offs

  • The same algebraic pattern as BM25's rewrite (BM25 does it in float, with a cached inverse norm), so reviewers know it; cInv could later be cached per norm as BM25 does.
  • Loses precision when freq / c is tiny, because 1 - 1/(1 + x) cancels: worst relative error 3.06e-5 over the wide range above (for example a small sloppy freq in a field far longer than average). In a typical range (integer freq 1 to 1,000, document length up to freq + 100,000, average length 10 to 10,000) none of 1e6 float-rounded tfln values differs from the exact one, for either option.

What the checks validated

  • The same gradle runs as the recommended option: the repro passes with assertions on and off, 119 similarities tests on two seeds, :lucene:core:check -x test.
  • Earlier, with the fix shadowed onto the main jars: the verifier's index-level class 4 of 4 and upstream TestAxiomaticF2EXP/TestAxiomaticF2LOG 4 of 4; the cross-model reviewer agreed that it fixes the bug.

Downstream note

Downstream, an application that ranks with F2EXP or F2LOG and indexes huge custom term frequencies could, by chance alignment of its statistics, show a top hit 1 float ulp below the true best; filters and deletions still hold.

How it was found

A monotonicity analysis of all 73 SimilarityBase model combinations proved 71 monotone in freq and flagged only the F2 tfln, where freq reaches a division from both sides. A floating-point SMT query (cvc5) then produced a counterexample in about 9 minutes; an independent verifier reproduced it with default parameters on real indexes.

The method in full183 words

Method: formal (the study's tier: the tool's own counterexample).

  • A monotonicity-typing analysis, a sound abstract interpretation over the exact Java expression trees under stated axioms (correctly rounded IEEE operations are monotone in each argument; Math.log/Math.pow semi-monotone), proved freq- and norm-monotonicity for 71 of the 73 SimilarityBase combinations. The two open ones were F2EXP and F2LOG, the only place where freq reaches an operator from both sides with opposite directions.
  • A cvc5 query in the theory of floating point (QF_FP) over that expression returned a double-level counterexample in about 9 minutes. A second stage showed which idf and boost values let the drop survive the float cast, and a scan on the real class found an index-realizable point at freq 2^24 with s = 0.0010000225 and boost 0.88; the finder wrote a JUnit test on a real index.
  • An independent verifier wrote its own test and search tool, found the default-parameter case at 2^29 without a boost, showed the pruned vs exhaustive top hit on two real indexes and documented the BM25-style fix direction. It was later reduced to the one-method test.

Prior reports, history and checks

Prior reports

No prior report found: two scripted runs of GitHub issue, PR, commit, mailing-list, JIRA-archive and downstream queries, 16 manual GitHub searches, the file lists of all 409 open PRs and 269 test-failure mails (2026-11-01). BM25's LUCENE-9071 fixed the same rounding pattern in BM25 only.

Searches5 searches

The closest items are context, not reports: #9131 (LUCENE-8083, a 2017 remark that these similarities "can't work with maxScore", before they passed the monotonicity tests), #9058 and #9045 (the tests and the contract), #9063 (the same bug class in DFR, fixed by rewriting it), #10113 and #13299 (BM25 only). A maintainer may still read the #9131 remark as prior knowledge of a broader F2/maxScore limitation. Private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists, JIRA archive2026-11-01scripted searches, run 1: 40 issue/PR queries, 5 commit queries, 4 downstream, 32 list queries (dev@, java-user@, issues@, commits@), 8 JIRA-archive queries; class names, tfln, monotonic, rounding, ulp, getMaxScore, impacts and symptom phrases135 distinct hits, none high-overlap, none this bug
Mailing lists (builds@, dev@, java-user@, issues@)2026-11-01scripted searches, run 2: 28 queries for test-failure reports; full bodies of the 190 truncated Jenkins/JIRA mails naming TestAxiomaticF2EXP/LOG (269 matching mails in total)no mail reports an Axiomatic test failure
GitHub, manual2026-11-0116 GitHub searches ("power of two", "1 ulp", non-decreasing, axiomatic maxScore/impacts/nextDown, "score must not decrease"); every comment of #9058, #9063, #9045, #8518, #9131, #9160, #10113, #9120, #13299, #16137; the file lists of all 409 open PRsno report; no open PR touches the Axiomatic files
Web2026-11-0110 queries8 nothing relevant, 2 not completed
GitHub and JIRA (the study)2026-10-22gh issue/PR searches for the class names, tfln and monotonicity phrasings; JIRA text ~ "Axiomatic" (8 issues)no report

History

Fails since 8.0.0: the repro starts failing at LUCENE-7997 (42717d5f4bb, 2017-10-24), which moved the similarity arithmetic to double with a final float cast and added the monotonicity tests (bisected); the formula itself is older. Every release from 8.0.0 to 10.6.0 and main fail the test; upstream main has the same code.

Versions tested6 rows

The repro starts failing at 42717d5f4bb "LUCENE-7997: More sanity testing of similarities" (Robert Muir, 2017-10-24; JIRA LUCENE-7997, migrated as #9045), which switched SimilarityBase and the Axiomatic models to double arithmetic with a final (float) cast and, in the same commit, added BaseSimilarityTestCase's monotonicity check and the F2 test classes. Its parent passes the repro; the later avgdl precision changes (LUCENE-8025, 7495a9d75bb; LUCENE-8007, ca5f9b34576) do not change the result. It did not write the freq / (freq + c) formula (below). The javadoc contract (LUCENE-8119, 838c604b765) and impacts (LUCENE-4198) also first ship in 8.0.0.

Origin of the formula: 4236da27d1b (LUCENE-7466, #8518, 2016-11-19, first in 6.4.0). In the float arithmetic of 6.4.0 to 7.7.3 the repro's inputs give equal scores; the float form drops by 1 ulp at other inputs (freq 16,777,215 to 16,777,216), but before 8.0.0 nothing required monotonicity in freq or pruned on it, so 7.7.3 counts as the last unaffected version.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe repro fails through gradle with assertions on and off
8.0.0 to 10.6.0 (61 releases) 8.0.0: first affectedthe release-API variant fails with the same numbers (assertions on; also off on 8.0.0, 10.5.2 and 10.6.0)
42717d5f4bb (LUCENE-7997) vs its parent 81a4f7cc9ce bisectthe parent gives equal scores; 42717d5f4bb drops 1.0134975 to 1.0134974
6.4.0 to 7.7.3 (26 releases) before the contractequal scores on the repro's inputs (float arithmetic); a 1-ulp drop at other inputs, before any monotonicity rule existed
upstream main 2f424fd428d readthe same AxiomaticF2EXP.java and AxiomaticF2LOG.java (2026-11-01); last change to either file in 2021 (@Override only)
fix on 645d4fe14a20 not affectedeither rewrite: the repro and the 119 similarities tests pass

Independent checks

The study's verifier reproduced it independently with default parameters on real indexes. An executing review by OpenAI GPT-5.6 (refute lens) wrote its own test, found it real with high confidence and agreed on root cause and fix. Re-runs on three later mains and on every release from 8.0.0 to 10.6.0 gave identical numbers.

Checks and verdicts179 words

Every check ran code. The intended-behaviour argument (experimental, contrived, 1 ulp) was rated weak by the cross-model reviewer and lowers only the priority.

Each independent check and its verdict
CheckVerdictWhat it found
Study finder (monotonicity analysis + cvc5) bugCounterexample and a real-index JUnit test at freq 2^24 with s = 0.0010000225 and boost 0.88.
Study verifier (similarity SMT) reproducedOwn test and search: default parameters, no boost, freq 2^29; pruned top-1 doc 0 vs exhaustive doc 300 on two real indexes.
Current-main verification realFails on main 645d4fe14a20 and 10.5.2 with assertions on and off, and on 9.12.3 and 8.0.0; the fix makes it pass.
Cross-model review, refute lens (OpenAI GPT-5.6, executing) real, highOwn test with norms from computeNorm and a lower-frequency control: fails 3 of 3 with assertions on and off, the control passes; root cause, fix and legal inputs agreed.
This doc's fix validation both fixes passGradle on a scratch worktree: stock fails, both rewrites pass the repro, 119 similarities tests on two seeds and check.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes479 words

Finding record

LFV-063, snapshot of 2026-11-06

Registry title
AxiomaticF2LOG and AxiomaticF2EXP scores drop by 1 ulp as term frequency crosses a power of two
Area
search / similarities
Priority
Low, rank 1210: The experimental AxiomaticF2LOG/F2EXP similarities drop 1 ulp when term frequency crosses a power of two near 2^29, contrived inputs with a 1-ulp effect.
Impact class
silent wrong results
Impact note
Users of the non-default experimental AxiomaticF2 similarities with term frequencies near a binade boundary (about 2^29); long-standing.
Summary
You score with AxiomaticF2LOG or AxiomaticF2EXP at default parameters and no boost. You expect the score never to decrease as term frequency rises, which impacts-based pruning relies on. Instead it drops by 1 ulp when the frequency crosses a power of two, so a pruned search can return a different top hit than exhaustive search.
Introduced
LUCENE-7997 (42717d5f4bb) [exec: bisected; its parent 81a4f7cc9ce passes] (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
formal: cvc5
Upstream and prior reports
none found (study searches 2026-10-22); LUCENE-9071 fixed BM25 only
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree, assertions on and off; stock, fix A and fix B; the similarities package on two seeds; :lucene:core:check -x test); the study verifier's index-level class on 645d4fe14a20 (stock with assertions on and off; with the BM25-form fix shadowed, assertions on; with the recommended fix shadowed, on and off); the release jars of every version from 8.0.0 to 10.6.0 (61 releases) and the commit pair around 42717d5f4bb; 6.4.0 to 7.7.3 with an API-adapted probe. Upstream main 2f424fd428d has the same F2 source files (read 2026-11-01).

Lucene and upstream sources

The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/similarities/AxiomaticF2EXP.java:57-85, AxiomaticF2LOG.java:47-75, Axiomatic.java:102-117, SimilarityBase.java:84-88, 150-154, 185-187
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/codecs/CompetitiveImpactAccumulator.java:67; lucene/core/src/java/org/apache/lucene/search/MaxScoreCache.java:74-89; TopScoreDocCollector.java:160; ImpactsDISI.java:75-88
Who is affected
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2054-2060; lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/DelimitedTermFrequencyTokenFilter.java:67; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, 599; TopScoreDocCollectorManager.java:69
Minimal repro
  • a Lucene main checkout lucene/MIGRATE.md:378 (GITHUB#15929 rename)
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:207-209; BM25Similarity.java:257-267
  • a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:260-261, 403-435; RandomSimilarity.java:126-127; lucene/core/src/test/org/apache/lucene/search/similarities/AxiomaticTestCase.java:22
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/similarities/BM25Similarity.java:257-267; Similarity.java:202-203, 218

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

Repro files

About the diagrams

Made in parallel with this doc, from the code at 645d4fe14a20 and an executed probe that replays the repro on main (assertions on and off) and with the fix.

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.