All findings · Lucene finding · LFV-920 · axiomatic-tf-explain-formula-omits-freq-plus-one

Axiomatic F1/F3 explanations print the tf formula as 1 + log(1 + log(freq)), but the value is computed on freq + 1

With the Axiomatic F1/F3 similarities, explain says the tf value is 1 + log(1 + log(freq)) of its freq child, but the code first adds 1 to freq: at freq 1 the node shows 1.526589; its formula gives 1.0. Scores are unaffected; only this formula text is stale, since 8.0.0.

Summary

Priority
Low, rank 1043The experimental Axiomatic F1/F3 similarities print a stale tf formula in explain while all values are correct, which is cosmetic.
Impact
minor
Since
8.0.0 (LUCENE-8010, b2f248164c1, 2017-12-28)introduced 2017-12-28 · about 8 years
Affected
8.0.0-10.6.0 and main; only the four Axiomatic F1/F3 similarities
Status
documented
Found by
semi-formal: a sweep that recomputes every explanation formula from its own children, then a JUnit test

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

A description "computed as <formula> from:" says the node's value is that formula of its children. The Axiomatic F1/F3 tf node states 1 + log(1 + log(freq)) over its freq child, but its value comes from tf(), which adds 1 to freq first. The printed formula is one step behind the value.

Background: how an explanation reads

  1. IndexSearcher.explain(query, doc) returns a tree that shows how a hit's score was computed: every node has a value, a description and child nodes.
  2. A description ending in computed as <formula> from: promises that the node's value is that formula applied to its children; Explanation.match documents the description as "how value was computed".
  3. The Axiomatic similarities score a term as tf x ln x tfln x idf (minus gamma; the rule is in Axiomatic.score, the tree only says "computed from:"), and the tree lists each component, with its formula and inputs, under the score node.
Diagram of the bug: an AxiomaticF1EXP explanation at freq 1. The tf node prints "tf, term frequency computed as 1 + log(1 + log(freq)) from:" with the child freq = 1.0, which by that formula gives 1.0, but the node shows 1.526589, which is 1 + log(1 + log(freq + 1)) because tf() adds 1 to freq first. The score of the one-document example, 1.9457303, is unaffected; the fix changes the text to 1 + log(1 + log(freq + 1)).
One tf node of an Axiomatic explanation: what it prints, what its printed formula gives on its child, and what tf() actually computes. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index three documents with AxiomaticF1EXP: field f = "a", "a a", "a a a". Search f:a and call IndexSearcher.explain on doc 0, where the term occurs once.
  2. The tf node reads tf, term frequency computed as 1 + log(1 + log(freq)) from: with one child, 1.0 = freq, number of occurrences of term in the document. You expect 1 + log(1 + log(1)) = 1.0.
  3. Instead the node shows 1.526589, which is 1 + log(1 + log(2)): tf() runs freq += 1 before the formula. At freq 2 and 3 it shows 1.7412763 and 1.8697417, the printed formula's values for 3 and 4.
  4. Recomputing the score from the printed parts by Axiomatic.score's rule gives tf 1.0 x ln 1.0 x tfln 1.0 x idf 1.1059324 = 1.106, but the tree and the hit say 1.6883042 (= 1.526589 x 1.1059324). The score, ranking and node value are unaffected; only the text is stale. Same in F1LOG, F3EXP and F3LOG.

Who is affected

Anyone who calls explain on searches that use AxiomaticF1EXP, F1LOG, F3EXP or F3LOG, which are non-default, @lucene.experimental similarities. Every explained hit shows the stale formula, whatever the term frequency. A reader who recomputes the node or the score from the printed text cannot make the numbers add up. Scores and ranking are unaffected.

Mechanism, terms and numbers506 words

AxiomaticF1EXP, AxiomaticF1LOG, AxiomaticF3EXP and AxiomaticF3LOG compute the term-frequency component as freq += 1; return 1 + Math.log(1 + Math.log(freq));, that is 1 + log(1 + log(freq + 1)) of the freq they are given (for a term query, the raw count). Their tfExplain shows that value but describes it as "tf, term frequency computed as 1 + log(1 + log(freq)) from:" over one child holding the unshifted freq, so the printed formula on the printed child does not give the printed value: 1.526589 instead of 1.0 at freq 1, 1.8697417 instead of 1.7412763 at freq 3. The defect changes no score, ranking or match and no value in the tree; only the formula text is stale. LUCENE-8011 wrote the text when tf() had no + 1; LUCENE-8010 added the + 1 four weeks later and left the text, so every release from 8.0.0 to 10.6.0 and main shows it. The fix is one string per class.

The bug, step by step (main 645d4fe14a20)

  1. The value. AxiomaticF1EXP.tf (AxiomaticF1EXP.java:59-62): freq += 1; // otherwise gives negative scores for freqs < 1 (:60), then return 1 + Math.log(1 + Math.log(freq));. Natural log. The same in AxiomaticF1LOG.java:50-53, AxiomaticF3EXP.java:59-62 and AxiomaticF3LOG.java:48-51.
  2. The explanation. AxiomaticF1EXP.tfExplain (:88-94) returns Explanation.match((float) tf(stats, freq, docLen), "tf, term frequency computed as 1 + log(1 + log(freq)) from:", Explanation.match((float) freq, "freq, number of occurrences of term in the document")): the value carries the + 1, the text (:92) and the child (:93, the unshifted freq) do not. The string is at AxiomaticF1LOG.java:83, AxiomaticF3EXP.java:92 and AxiomaticF3LOG.java:81.
  3. Where it shows. Axiomatic.explain adds the node under "score(<class>, freq=...), computed from:" (Axiomatic.java:162, :129-137); the score node's value uses the same tf() (:125-127), so the top value equals the hit score.
  4. Why no test noticed. CheckHits.verifyExplanation stops at a node ending in "computed from:", which is the Axiomatic score node (CheckHits.java:411-413), and treats any "., computed as . from:" node as consistent with its children (:375, :437-438). No upstream test contains the string.

The numbers (all four classes, 645d4fe14a20, assertions on and off)

raw freq (child)tf node showsprinted formula on the child1 + log(1 + log(freq + 1))
11.5265891.00000001.5265890
21.74127631.52658901.7412763
31.86974171.74127631.8697417

The ln and idf nodes of the same tree, and the F2 classes' tf node ("tf, term frequency, equals to 1"), match their own formulas and children.

The runs cover freq 1, 2, 3 and 7. By the math every legal freq is affected (Similarity.java:218: a "sloppy term frequency, must be finite and positive"): above 1/e both expressions are real and differ; at or below 1/e, which a sloppy PhraseQuery can reach (PhraseWeight.java:82-86, SloppyPhraseMatcher.java:200-202), the printed formula is not a real number while the value stays finite. That is the NaN rmuir found in LUCENE-7997 (#9045), which the + 1 avoids.

Exposure and likelihood145 words
  • Who is hit: callers of IndexSearcher.explain (or anything that prints an explanation) on a search with one of the four similarities, with default IndexWriterConfig, codec and searcher, assertions on or off.
  • Opt-in: non-default similarities; every class involved is @lucene.experimental (AxiomaticF1EXP.java:25, AxiomaticF1LOG.java:25, AxiomaticF3EXP.java:27, AxiomaticF3LOG.java:27, Axiomatic.java:34).
  • What goes wrong: misleading diagnostic text. Checking the tf node against its child, or combining the printed components by Axiomatic.score's rule (1.106 instead of 1.688 in the example), points at a scoring bug that does not exist. The hunt's evaluator, which recomputes explanation formulas from their children, flags it.
  • Scale: four string literals; the searches found no prior report.
  • Not claimed: any effect on scores, ranking, matching, the tf node's value or the top explanation value; in every run the tf value is tf()'s and the top value equals the hit score.

Minimal repro

One JUnit test on the public API: one document with the term once, AxiomaticF1EXP, one IndexSearcher.explain and one assertion that the tf description states the formula of its value. Deterministic, single thread, under a second.

TestAxiomaticF1EXPTfExplain.java (one test, public API)61 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 java.util.Arrays;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.Explanation;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestAxiomaticF1EXPTfExplain extends LuceneTestCase {

  public void testTfDescriptionStatesTheFormulaOfItsValue() throws Exception {
    Similarity sim = new AxiomaticF1EXP();
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setSimilarity(sim))) {
        Document doc = new Document();
        doc.add(new TextField("f", "a", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setSimilarity(sim);
        Explanation score = searcher.explain(new TermQuery(new Term("f", "a")), 0).getDetails()[0];
        Explanation tf =
            Arrays.stream(score.getDetails())
                .filter(e -> e.getDescription().startsWith("tf,"))
                .findFirst()
                .orElseThrow();
        // freq = 1: expected the formula of the value 1.5266 = 1 + log(1 + log(freq + 1)); actual: 1 + log(1 + log(freq)), which is 1.0
        assertEquals(
            "tf value " + tf.getValue(),
            "tf, term frequency computed as 1 + log(1 + log(freq + 1)) from:",
            tf.getDescription());
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestAxiomaticF1EXPTfExplain.java lucene/core/src/test/org/apache/lucene/search/similarities/
./gradlew :lucene:core:test --tests org.apache.lucene.search.similarities.TestAxiomaticF1EXPTfExplain -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure); the outcome does not depend on the seed
Expected
The tf description states the formula of its value: 1 + log(1 + log(freq + 1)), which is the shown 1.526589 at freq 1.
Observed
ComparisonFailure: tf value 1.526589 expected:<...1 + log(1 + log(freq[ + 1])) from:> but was:<...1 + log(1 + log(freq[])) from:>: the text says 1 + log(1 + log(freq)), which gives 1.0; assertions on and off.

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

More files, environment, full output225 words

The smallest input: one document, freq 1, where the printed formula gives 1.0 and the node shows 1.526589. The assertion pins the description to the formula tf() computes, so the test fails on stock and passes with the fix; its message carries the node's value. It covers AxiomaticF1EXP only; F1LOG, F3EXP and F3LOG carry the same string and are fixed by the same diff (the probe shows all four). It asserts nothing about scores, which this defect does not change. As a regression test it is the same test (it could sit in TestAxiomaticF1EXP). All runs were on Linux aarch64 with OpenJDK 25.0.3; the bug is a string literal, so the platform does not matter.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, stock 645d4fe14a20, seed DEADBEEF, assertions on
TestAxiomaticF1EXPTfExplain > testTfDescriptionStatesTheFormulaOfItsValue FAILED
    org.junit.ComparisonFailure: tf value 1.526589 expected:<...1 + log(1 + log(freq[ + 1])) from:> but was:<...1 + log(1 + log(freq[])) from:>

# assertions off (-Ptests.asserts=false): the same message
# 10.6.0 release jars, assertions on and off: the same message

# with the fix (four description strings), assertions on and off
classes=1 tests=1 failures=0 errors=0 skipped=0

# closest suites (lucene/core similarities package, TestSimpleExplanations*, TestComplexExplanations*), seed DEADBEEF
stock:    classes=32 tests=353 failures=1 (the repro) errors=0 skipped=2
with fix: classes=32 tests=353 failures=0 errors=0 skipped=2

Why it looks unintended

  • Explanation.match documents the description as "how value was computed", and Axiomatic.tfExplain promises "how the tf component was computed".
  • The node's only child is labelled "number of occurrences of term in the document" and holds the unshifted freq.
  • The text was exact when LUCENE-8011 wrote it; LUCENE-8010 added the + 1 four weeks later without discussing the tf explanation.
The full argument187 words
  • The contract. Explanation.java:31, :43: "@param description how {@code value} was computed". Axiomatic.java:195: "@return Explanation of how the tf component was computed". SimilarityBase.java:106: subclasses add clauses "to explain details of their scoring formulae".
  • The operand. The child "freq, number of occurrences of term in the document" holds the unshifted freq (AxiomaticF1EXP.java:93; Axiomatic.java:193 documents tfExplain's freq with the same words), so the text cannot mean a shifted freq.
  • The history. In the LUCENE-8011 (#9059) thread jpountz asked that the similarities "include the formula that was used to compute the score"; the strings it added matched tf() then. In LUCENE-8010 (#9058) jpountz asked "Axiomatic similarities add 1 to the freq, is it ok?" and rmuir answered with relevance numbers: the score change was deliberate. No #9058 comment mentions the explanation, and its issue body even lists "internal explanations calculations are sane" as a test requirement.
  • The neighbours. The ln and idf nodes and the F2 tf node match their operands.
  • Precedent. GITHUB#13691 "Fix incorrect exponent value in explain of SigmoidFunction" (0f5359a2ac6), an explain-only fix, is listed under Bug Fixes for 9.12.0.
The devil's-advocate check152 words

The strongest case for "intended" (the devil's-advocate checker): Explanation is an expert diagnostic API with human-readable descriptions, not an expression language; the classes are @lucene.experimental; 1 + log(1 + log(freq)) names the published F1/F3 tf function, so "freq" could mean the shifted local variable; CheckHits deliberately treats "computed as ... from:" nodes as opaque; no CHANGES or MIGRATE entry promises exact explain formulas.

Why it fails: the javadoc says the description is how the value was computed; the child is labelled and filled as the unshifted freq, which rules out the shifted reading; the text was exact until LUCENE-8010 changed the code without a word about the tf explanation; CheckHits' opacity explains why no test noticed, not why the text would be right; and an explain-only formula error was fixed as a bug before (GITHUB#13691). Every reviewer rated the intended case weak; it explains the low priority, not the behaviour.

Suggested fix

Recommended State the formula the code computes (four strings)

Change the four description strings to tf, term frequency computed as 1 + log(1 + log(freq + 1)) from: and keep the child, the unshifted freq. Text only: no value, score or tree shape changes. Validated through gradle: the repro passes with assertions on and off, and all 353 tests of the closest suites pass.

-        "tf, term frequency computed as 1 + log(1 + log(freq)) from:",
+        "tf, term frequency computed as 1 + log(1 + log(freq + 1)) from:",
All options and trade-offs3 options ยท 341 words

The hunt's fix, unchanged, run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF) with the minimal repro and the closest suites.

State the formula the code computes (four strings) (validated by the checks) (recommended)

Change the description in AxiomaticF1EXP.java:92, AxiomaticF1LOG.java:83, AxiomaticF3EXP.java:92 and AxiomaticF3LOG.java:81 to tf, term frequency computed as 1 + log(1 + log(freq + 1)) from:; the child stays the unshifted freq.

-        "tf, term frequency computed as 1 + log(1 + log(freq)) from:",
+        "tf, term frequency computed as 1 + log(1 + log(freq + 1)) from:",

Trade-offs

Text only: no value, score or tree shape changes. A downstream tool matching the exact old string would see the new one (none is known). Optional, not run: the child's label "number of occurrences of term in the document" (also Axiomatic.java:193) is exact for term queries only; "term frequency" would also fit sloppy phrase frequencies.

What the checks validated

Through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF): the minimal test fails on stock and passes with the fix, assertions on and off. The closest suites (every test class in org.apache.lucene.search.similarities plus TestSimpleExplanations* and TestComplexExplanations*: 32 classes, 353 tests, the repro included) give 1 failure, the repro, on stock and 0 with the fix (2 skipped both times). Earlier, the hunt's shadow fix check and the refute reviewer's runs (15 Axiomatic tests, 118 of 118 similarity tests) passed too.

Keep the text, show freq + 1 as the child

Make the child Explanation.match((float) (freq + 1), ...) with a relabelled description. Not run.

Trade-offs

The unshifted freq, which the score node's "freq=..." also shows, disappears from the tf subtree and the child's label must change: a larger, less obvious edit for the same result.

One shared description

Keep the F1/F3 tf formula text in one place (for example a constant in Axiomatic) so the four copies cannot drift again. Not run.

Trade-offs

Touches four public experimental classes for a four-line fix; optional on top of the first option.

Downstream note

Apache Solr has no factory for these four similarities (SOLR-9953, open since 2017), so the stale text reaches mainly applications that use Lucene directly.

How it was found

Semi-formal: an AI tool's sweep explained 3,178 hits over 79 similarity configurations and recomputed each of 14,554 "computed as" nodes from its own children. 200 tf nodes of the Axiomatic F1/F3 classes disagreed. Ten of ten injected mutants were caught, so the check is not vacuous; a public-API JUnit test then confirmed it.

The method in full124 words

Method: semi-formal, a differential explain-tree evaluation (a hunt unit, explanation formula strings checked against their own operand values), on the stock main a9779ef3c193 jars.

  • A test program explained hits for 79 similarity configurations x TermQuery, PhraseQuery, SynonymQuery and a 2-clause BooleanQuery x 2 boosts, plus the scoring queries: 3,178 explained hits, 14,554 "computed as <formula> from:" nodes, each evaluated on its own labelled children, with assertions on and off.
  • 200 tf nodes over AxiomaticF1EXP, F1LOG, F3EXP and F3LOG disagreed with their stated formula; F2EXP/F2LOG and every other Axiomatic node agreed.
  • The oracle is not vacuous: 10 of 10 injected non-equivalent mutants were detected.
  • The signal was reduced to a public-API JUnit test, then to the one-test repro.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, open PRs' file lists, JIRA, the mailing lists, downstream projects and the web (2026-10-28), re-checked on GitHub on 2026-11-01. LUCENE-8010 (#9058) and LUCENE-8011 (#9059) are the origin, not reports.

Searches3 searches

Context only: #9058 (LUCENE-8010, the deliberate score change), #9059 (LUCENE-8011, the strings; during its review the LUCENE-7996 commit a8a63464e7d let a single-child "computed as ... from:" node, such as this tf node, differ from its child in CheckHits; multi-child ones were accepted unchecked since LUCENE-7524), #9045 (LUCENE-7997, why the shift), #9071 (LUCENE-8023, open, "Remove divide-by-zero formula hacks across sim impls", which would make "good explain()" possible; about docFreq guards in other similarities), #16267 (open, explanation-testing gaps).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; the web2026-10-28about 168 queries of the hunt's search tool in four specs plus the finder's 45; manual GitHub searches including the formula text with and without + 1; the 9 LUCENE/SOLR JIRA issues and 85 list mails matching "axiomatic"; 18 web queriesno report; context #9058, #9059, #9045, #9071, #16267, SOLR-9953
Open PRs (apache/lucene)2026-10-28; re-checked 2026-11-01file lists of all 413 open PRs (2026-10-28); file lists of the 36 open PRs updated since 2026-10-28 (2026-11-01)no PR touches an Axiomatic file; #16949 touches only BM25Similarity and TFIDFSimilarity javadoc
GitHub issue and PR search (apache/lucene)2026-11-01Axiomatic (updated since 2026-10-28), tfExplain, "log(1 + log(freq))", AxiomaticF1EXP, AxiomaticF3LOG, explain similarity, explanation formula, "freq + 1" explain; the 33 items created since 2026-10-28no report

History

LUCENE-8011 (6771886cf33, 2017) wrote the string when tf() had no + 1; LUCENE-8010 (b2f248164c1, 2017-12-28) added freq += 1 and left the text. Affected: 8.0.0 to 10.6.0 and main. Clean: 7.7.3 and earlier, whose tf node states no formula. Not fixed upstream.

Versions tested6 rows

String written by 6771886cf33, LUCENE-8011 "Improve similarity explanations" (#9059; apache/lucene-solr#280 by mayya-sharipova, merged by jpountz; author date 2017-11-30). Then tf() was if (freq <= 0.0) return 0.0; return 1 + Math.log(1 + Math.log(freq));, so the text was right.

Introduced by b2f248164c1, LUCENE-8010 "Fix similarities so that they pass tests." (#9058; jpountz; author date 2017-12-28; a JIRA patch whose hunks for the four subclasses were made against the files before LUCENE-8011, so they show no tfExplain or formula string). It replaced the guard with freq += 1 in all four classes. git log -S finds exactly one commit for each line. Commit isolation (the 2017 subclass sources compiled against the 8.0.0 jars): the classes from b2f248164c1^ are consistent, those from b2f248164c1 are not.

All 51 release tags from 8.0.0 to 10.5.2 in the history clone contain both lines, none of the 14 7.x tags contains either; the 10.6.0 tag and upstream main 2f424fd428da (2026-10-30) still have both. The clone has no tags for 8.8.2 to 8.11.4; those releases were run instead (8.11.4 by the verifiers, the other eight after the fact checks). The Axiomatic classes exist since 6.4.0.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails through gradle, assertions on and off; the probe shows all four classes
10.6.0 affectedthe minimal test fails on the release jars, assertions on and off
10.5.2, 10.0.0, 9.12.3, 9.0.0, 8.11.4, 8.0.0 affectedthe history probe and the verifiers' tests fail (8.0.0, 9.12.3 and 10.5.2 with assertions on and off)
8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0, 8.11.1, 8.11.2, 8.11.3 affectedthe probe on the release jars prints the stale text: 12 of 12 tf nodes (four classes, freq 1-3) mismatch; top value = hit score (assertions on)
7.7.3, 7.2.0, 6.6.6 not affectedplain "tf" node with no formula (7.7.3 with assertions on and off)
upstream main 2f424fd428da (2026-10-30) readboth lines present in all four classes (read, not run)

Independent checks

Confirmed with high confidence by the study's judge after an independent reproducer, an intended-behaviour devil's advocate and reviews by another model family (OpenAI GPT-5.6), none dissenting; confirmed by a credibility audit; re-run through gradle and on the 10.6.0 jars for this doc, with the fix validated.

Checks and verdicts307 words

Each checker that executed wrote its own test from the claim; the adjudication re-ran the independent reproducer's test with a new seed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate200 F1/F3 tf nodes of 14,554 formula nodes disagree; 10 of 10 mutants detected; JUnit fails on main (assertions on and off), 10.5.2 and 9.12.3 and passes with the fix.
refute review (OpenAI GPT-5.6) real, highOwn JUnit fails with four seeds, assertions on and off; F2 control passes; the fix passes its test, 15 Axiomatic tests and 118 of 118 similarity tests.
Independent reproducer realOwn JUnit at freq 1, 2, 3 and 7: 16 of 16 tf nodes mismatch on main, 10.5.2, 9.12.3, 8.11.4 and 8.0.0, every value equal to the freq + 1 formula; controls pass.
Works-as-designed review (OpenAI GPT-5.6) real, highOwn JUnit fails on main and 10.5.2 (assertions on); intended argument weak.
Intended-behaviour devil's advocate realStrongest "by design" case rejected point by point; own JUnit fails on main (assertions on and off), 10.5.2 and 9.12.3.
adjudication confirmed, highRe-ran the independent reproduction with a new seed, assertions on and off: the same 16 mismatches. Impact minor.
credibility audit confirmedFinder's JUnit re-run on main with a new seed fails as expected; minor kept.
this doc real, fix validatedThe minimal test fails through gradle (assertions on and off) and on the 10.6.0 jars; the fix passes it and the 353 tests of the closest suites; a probe prints the trees of all four classes; a GitHub prior-art refresh (2026-11-01) found nothing new.
fact checks (independent checker, OpenAI GPT-5.6 reviewer, diagram checker) real; wording fixedCode, numbers, history, test and fix confirmed, the test and probe re-run; quote, citation and wording errors corrected and the 8.8.2-8.11.3 releases run.

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 notes1,416 words

Finding record

LFV-920, snapshot of 2026-11-06

Registry title
AxiomaticF1EXP, F1LOG, F3EXP and F3LOG explain tf as 'computed as 1 + log(1 + log(freq))' of the raw freq, but the value they show is 1 + log(1 + log(freq + 1)); the score itself is correct
Area
core / search similarities (Axiomatic F1/F3 term-frequency explanation, tfExplain)
Priority
Low, rank 1043: The experimental Axiomatic F1/F3 similarities print a stale tf formula in explain while all values are correct, which is cosmetic.
Impact class
minor
Impact note
Anyone who calls IndexSearcher.explain on a search that uses one of the non-default, @lucene.experimental similarities AxiomaticF1EXP, AxiomaticF1LOG, AxiomaticF3EXP or AxiomaticF3LOG (public constructors, default IndexWriterConfig, codec and searcher, -ea and -da, one document) sees the stale formula on every explained hit, for every term frequency; scores, ranking, matching, the tf node's value and the top explanation value are all correct, and nothing throws; every release from 8.0.0 to 10.6.0; Solr has no factory for these similarities (SOLR-9953, open since 2017) and, by the finder's reading, Elasticsearch's similarity settings do not expose them, so reach is mostly direct Lucene users.
Summary
You search with AxiomaticF1EXP (or F1LOG, F3EXP, F3LOG) and call IndexSearcher.explain on a hit. The tf node says 'tf, term frequency computed as 1 + log(1 + log(freq)) from:' and its only child is 'freq, number of occurrences of term in the document', the raw count, so you expect its value to be that formula of that child. Instead tf() first adds 1 to freq (since LUCENE-8010, 2017), so for freq = 1 the node shows 1.5265890 where the stated formula gives 1.0, and for freq = 3 it shows 1.8697417 instead of 1.7412763; the score is right and only the formula text is stale.
Introduced
b2f248164c1a3ddf213a56778d55c9252a022f18 ('LUCENE-8010: Fix similarities so that they pass tests', by jpountz; JIRA patch era, no pull request; LUCENE-8010 is apache/lucene#9058): added 'freq += 1; // otherwise gives negative scores for freqs < 1' to tf() in all four classes and left the four tfExplain description strings unchanged. Those strings were written one month earlier by 6771886cf3303a9a2c3cb86c8d0329e55ee0feaa ('LUCENE-8011: Improve similarity explanations', by mayya-sharipova, committed by jpountz; apache/lucene-solr#280, apache/lucene#9059), when tf() was 'if (freq <= 0.0) return 0.0; return 1 + log(1 + log(freq))' and the text was correct; 6771886cf330 never shipped without b2f248164c1. [read: git log -S on both lines and blame in the history clone (later commits on the files change neither line); the LUCENE-8010.patch JIRA attachment was made against the file before LUCENE-8011, so its hunk carried no explain context; exec: commit isolation on 8.0.0 jars, the four subclasses from b2f248164c1^ give a self-consistent tf node and those from b2f248164c1 give the mismatch, -ea and -da] (2017-12-28 (author date of b2f248164c1; committed 2017-12-29); the stale string itself from 6771886cf330, author date 2017-11-30 (committed 2017-12-13))
First affected version
8.0.0 [exec, -ea and -da] (both commits first appear in 8.0.0; no 7.x tag contains either); the last unaffected release is 7.7.3 [exec, -ea and -da], where the tf node is a plain 'tf' with no formula (also 7.2.0 and 6.6.6 [exec]); the Axiomatic classes exist since 6.4.0
Affected versions
Every release from 8.0.0 to 10.5.2 (the last release before 10.6.0), 10.6.0 (the branch_10_6 head 4ae946e5a9b9, the RC1 revision) and main a9779ef3c193; not fixed upstream. [exec] the follow-up's version-portable probe AxiomaticTfExplainHistoryProbe fails on 8.0.0 (-ea and -da), 8.11.4, 9.0.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da), branch_10_6 and main (-ea and -da) and passes (no formula node) on 6.6.6, 7.2.0 and 7.7.3 (-ea and -da); the finder's JUnit fails on main, 10.5.2 and 9.12.3 (each -ea and -da); the independent reproduction's JUnit fails on main (-ea and -da), 10.5.2, 9.12.3, 8.11.4 and 8.0.0; the intended-behaviour check's on main (-ea and -da), 10.5.2 and 9.12.3. [read] all 51 release tags 8.0.0-10.5.2 in the history clone contain both 'freq += 1' and the stale string in all four classes and none of the 14 7.x tags contains either (8.8.2-8.11.x are not tagged there; 8.11.4 executed); upstream main c5ab3e57b9b (2026-10-28) still has both lines (read-only GitHub contents API, re-checked 2026-10-28). 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 agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (verdict new, searched 2026-10-28, read-only: a scripted prior-report search in four specs (about 168 queries over apache/lucene issues and PRs, commits, Elasticsearch/OpenSearch/Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web) plus the finder's earlier 45-query run; manual GitHub searches (issue/PR, commit, downstream and all-of-GitHub, including the formula text with and without '+ 1'); the changed-file lists of all 413 open apache/lucene PRs; the 86 items updated since 2026-10-25 listed and the 31 created since 2026-10-24 read by title; the 9 LUCENE/SOLR JIRA issues mentioning 'axiomatic', read; every subject of the 85 list mails matching 'axiomatic'; 18 web queries); 15 hits read, none the same bug. Refresh 2026-10-28: the changed files of the 49 open PRs updated since 2026-10-26 (only #16916 touches a similarities file, the IBSimilarity class javadoc), the 15 newest issues/PRs up to #16931, and GitHub searches 'Axiomatic' (updated since 2026-10-26), 'tfExplain', 'Axiomatic explain', 'AxiomaticF1EXP', the formula text, 'axiomatic tf freq', PRs 'similarities' and items 'explain' updated in the last 48 h: nothing new; upstream main c5ab3e57b9b still has both lines and the four files were last touched in 2021-2023. Related, not this bug: LUCENE-8010 (#9058: 'Axiomatic similarities add 1 to the freq, is it ok?', the deliberate score change that made the text stale; no #9058 comment mentions the explanation), LUCENE-8011 (#9059, added the formula strings; during its review the LUCENE-7996 commit a8a63464e7d let a single-child 'computed as ... from:' node differ from its child in CheckHits, and multi-child ones were accepted unchecked since LUCENE-7524), LUCENE-7997 (#9045, motivation for the shift), LUCENE-8023 (#9071, open: 'Remove divide-by-zero formula hacks across sim impls', which would make 'good explain()' possible; about docFreq guards, not Axiomatic), #16267 (open, explanation test gaps, does not list similarity formula strings), #16921 (BM25/TFIDF idfExplain javadoc type name), #16909/#16916 (IBSimilarity javadoc).
Lucene commit tested
main 645d4fe14a20 through gradle in a scratch worktree: the minimal test stock and with the fix, assertions on and off, and the closest suites stock and with the fix; the minimal test on the 10.6.0 release jars (assertions on and off); a probe that prints the explanation trees of all four classes on 645d4fe14a20; the verifiers' tests on main a9779ef3c193, 10.5.2, 9.12.3, 8.11.4 and 8.0.0; the history probe from 6.6.6 to main and the commit isolation on the 8.0.0 jars; after the fact checks, the probe on the 8.8.2-8.11.3 release jars. All runs on Linux aarch64 with OpenJDK 25.0.3. Upstream main 2f424fd428da (2026-10-30) and the 10.6.0 tag have the same code.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/similarities/AxiomaticF1EXP.java:25 (@lucene.experimental), :59-62 (tf: 'freq += 1' at :60), :89-94 (tfExplain; string at :92) @ a9779ef3c193
  • lucene/core/src/java/org/apache/lucene/search/similarities/AxiomaticF1LOG.java:25, :50-53 ('freq += 1' at :51), :80-85 (string at :83); AxiomaticF3EXP.java:27, :59-62 (:60), :89-94 (:92); AxiomaticF3LOG.java:27, :48-51 (:49), :78-83 (:81) @ a9779ef3c193
  • lucene/core/src/java/org/apache/lucene/search/similarities/Axiomatic.java:34 (@lucene.experimental), :136 (root node 'computed from:'), :162 (explain adds tfExplain(stats, freq, docLen)), :195-197 (tfExplain javadoc: 'Explanation of how the tf component was computed') @ a9779ef3c193
  • lucene/core/src/java/org/apache/lucene/search/Explanation.java:31,43 ('@param description how {@code value} was computed') @ a9779ef3c193
  • GitHub REST and search (read-only), 2026-10-28: repos/apache/lucene/commits?path=<each file>, commits/main, contents of AxiomaticF1EXP.java on main, pulls?state=open sorted by update with pulls/N/files, issues sorted by creation, search/issues queries listed in
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/similarities/AxiomaticF1EXP.java:59-62 (at 645d4fe14a20) (:60), :88-94 (:92, :93); AxiomaticF1LOG.java:50-53, :80-85 (:83); AxiomaticF3EXP.java:59-62, :89-94 (:92); AxiomaticF3LOG.java:48-51, :78-83 (:81)
  • lucene/core/src/java/org/apache/lucene/search/similarities/Axiomatic.java:125-137, (at 645d4fe14a20) :162; lucene/core/src/java/org/apache/lucene/search/Explanation.java:31, :43
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:375, (at 645d4fe14a20) :411-413, :437-438
  • lucene/core/src/java/org/apache/lucene/search/similarities/AxiomaticF2EXP.java:89, (at 645d4fe14a20) AxiomaticF2LOG.java:79
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/similarities/AxiomaticF1EXP.java:25, (at 645d4fe14a20) AxiomaticF1LOG.java:25, AxiomaticF3EXP.java:27, AxiomaticF3LOG.java:27, Axiomatic.java:34
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Explanation.java:31, (at 645d4fe14a20) :43; lucene/core/src/java/org/apache/lucene/search/similarities/Axiomatic.java:195; SimilarityBase.java:106; AxiomaticF1EXP.java:93
  • a Lucene clone: git log -1 0f5359a2ac6 (#13691); lucene/CHANGES.txt:2689, (at 645d4fe14a20) :2705 (9.12.0, Bug Fixes)
History
  • a Lucene clone: git show releases/lucene/10.6.0 (four files, both lines; identical to 645d4fe14a20)

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

Repro files

About the diagrams

Made from the code at 645d4fe14a20 and the executed runs.

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.