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.
- A similarity turns a term's frequency in a document (freq) and the field's length into a score.
AxiomaticF2EXPandAxiomaticF2LOGcompute it in double precision and return it as a float. - 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. - 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.
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.- 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. - 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#scoremust never decrease as freq grows, and pruning relies on that. - You get 1.0134975, then 1.0134974: one float step lower.
AxiomaticF2LOGdrops the same way, 0.10277987 to 0.102779865, with assertions on or off. - Why: in
tfln = freq / (freq + 0.425)the denominatorfreq + 0.425is 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, andtflnfalls 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. - 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.
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.
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.
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,880 | freq = 2^29 = 536,870,912 | |
|---|---|---|
| spacing of doubles at the denominator | 2^-24 (5.96e-8) | 2^-23 (1.19e-7) |
| stored denominator minus freq | 0.4250105023 (rounded down by 2.1e-8) | 0.4250105619 (rounded up by 3.8e-8) |
tfln | 0.9999999992083562 | 0.9999999992083561 |
double score tfln * idf | 1.0134974122047427 | 1.0134974122047424 |
| float score | 1.0134975 | 1.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.
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
AxiomaticF2EXPorAxiomaticF2LOG(both@lucene.experimental) as their similarity. The study's monotonicity analysis of all 73SimilarityBasemodel 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 exampleDelimitedTermFrequencyTokenFilterreadingword|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.
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.
/*
* 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);
}
}
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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): affected; every release 8.0.0 to 10.6.0 with the release-API variant of this test |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
# 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=0SimScorer#score: "Score must not decrease when freq increases", for any norm, with no tolerance.BaseSimilarityTestCasefails on anyscore(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.
BaseSimilarityTestCaselists "scores don't decrease as term frequencies increase" (:260-261) and comparesscore(Math.nextDown(freq))orscore(freq - 1)withscore(freq), failing on any decrease (:403-435).TestAxiomaticF2EXPandTestAxiomaticF2LOGextend it throughAxiomaticTestCase; random sampling practically never lands on an aligned power of two. Both models are also in the test framework'sRandomSimilarityrotation (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)as1 - 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'stflnhas 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.
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.
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 (
ccould be cached per norm).freqis finite and positive by contract, soc / freqis defined; s = 0 givestfln = 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.similaritiespackage passes (27 classes, 119 tests, seeds DEADBEEF and 5EED1234, 0 failures);:lucene:core:check -x testpasses (aftertidyreflowed 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;
cInvcould later be cached per norm as BM25 does. - Loses precision when
freq / cis tiny, because1 - 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-roundedtflnvalues 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/TestAxiomaticF2LOG4 of 4; the cross-model reviewer agreed that it fixes the bug.
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.
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.powsemi-monotone), proved freq- and norm-monotonicity for 71 of the 73SimilarityBasecombinations. 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists, JIRA archive | 2026-11-01 | scripted 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 phrases | 135 distinct hits, none high-overlap, none this bug |
| Mailing lists (builds@, dev@, java-user@, issues@) | 2026-11-01 | scripted 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, manual | 2026-11-01 | 16 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 PRs | no report; no open PR touches the Axiomatic files |
| Web | 2026-11-01 | 10 queries | 8 nothing relevant, 2 not completed |
| GitHub and JIRA (the study) | 2026-10-22 | gh issue/PR searches for the class names, tfln and monotonicity phrasings; JIRA text ~ "Axiomatic" (8 issues) | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the repro fails through gradle with assertions on and off |
| 8.0.0 to 10.6.0 (61 releases) | 8.0.0: first affected | the 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 | bisect | the parent gives equal scores; 42717d5f4bb drops 1.0134975 to 1.0134974 |
| 6.4.0 to 7.7.3 (26 releases) | before the contract | equal scores on the repro's inputs (float arithmetic); a 1-ulp drop at other inputs, before any monotonicity rule existed |
upstream main 2f424fd428d | read | the same AxiomaticF2EXP.java and AxiomaticF2LOG.java (2026-11-01); last change to either file in 2021 (@Override only) |
fix on 645d4fe14a20 | not affected | either rewrite: the repro and the 119 similarities tests pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| Study finder (monotonicity analysis + cvc5) | bug | Counterexample and a real-index JUnit test at freq 2^24 with s = 0.0010000225 and boost 0.88. |
| Study verifier (similarity SMT) | reproduced | Own 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 | real | Fails 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, high | Own 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 pass | Gradle on a scratch worktree: stock fails, both rewrites pass the repro, 119 similarities tests on two seeds 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 423 KB
Finding record, Lucene and upstream sources, repro files and diagram notes479 words
- 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 on645d4fe14a20(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 around42717d5f4bb; 6.4.0 to 7.7.3 with an API-adapted probe. Upstream main2f424fd428dhas the same F2 source files (read 2026-11-01).
- 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-187a 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-267a 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
- TestAxiomaticF2ScoreMonotonicInFreq.java (1 test):
repro/LFV-063/src/org/apache/lucene/search/similarities/TestAxiomaticF2ScoreMonotonicInFreq.java(51 lines)
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.
assets/LFV-063/bug.png(423 KB)
