All findings · Lucene finding · LFV-922 · aftereffectb-explain-shows-ttf-and-df-under-plus-one-labels

AfterEffectB's explanation prints totalTermFreq and docFreq under labels that say "+ 1", so its own formula does not give its value (scores are correct)

With DFRSimilarity and after effect B, explain labels two inputs "F, ... + 1" and "n, ... + 1" but prints them without it: a term found 6 times in 3 documents shows 6 and 3, not 7 and 4, so its own formula gives 0.719, not the 0.616 shown. Scores are correct; only the explanation is wrong (since 8.0.0).

Summary

Priority
Low, rank 1039In core DFRSimilarity with the common after effect B, explain shows F and n one lower than their labels plus a duplicate tfn child while scores stay correct.
Impact
minor
Since
8.0.0 (LUCENE-8011, 2017); 7.7.3 is the last good releaseintroduced 2017-11-30 · about 8 years
Affected
8.0.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: a check that recomputes every explain formula from the inputs it lists

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 term found 6 times in 3 documents: the explanation says its inputs are those counts + 1, but prints 6 and 3.

Background: explain and the DFR after effect

  1. IndexSearcher.explain(query, doc) returns a tree: each node is a number, a description of how it was computed, and its inputs as children.
  2. DFRSimilarity (divergence from randomness) scores a term as basic model x after effect, from tfn, the term's count in the document adjusted for document length. Lucene ships two after effects, B and L.
  3. After effect B uses F = the term's total count in the index + 1 and n = the number of documents that contain it + 1. Its value is (F + 1) / (n x (tfn + 1)).
Diagram of the bug: the AfterEffectB node of a Lucene explanation for a term with totalTermFreq 6 and docFreq 3. The node reads 0.616 = AfterEffectB, computed as (F + 1) / (n * (tfn + 1)), with inputs labelled 'F, ... + 1' and 'n, ... + 1'. The labels promise 7 and 4, but the tree prints 6 and 3 and lists tfn twice, so the formula on the printed inputs gives 0.719 instead of 0.616. The score and the node value are correct; the fix prints 7 and 4 and lists tfn once.
The after-effect part of one explanation: the labels promise F = 6 + 1 = 7 and n = 3 + 1 = 4, the tree prints 6 and 3 (and tfn twice), so the printed formula gives 0.719 while the node correctly shows 0.616. The fix prints 7 and 4. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Score with new DFRSimilarity(new BasicModelIn(), new AfterEffectB(), new NormalizationH2()) and index five short documents in which the term a occurs 6 times in 3 documents (totalTermFreq 6, docFreq 3). Call IndexSearcher.explain for f:a on document 0, a b c a a.
  2. The after-effect node reads 0.61625993 = AfterEffectB, computed as (F + 1) / (n * (tfn + 1)) from:, with inputs labelled F, ... + 1 and n, ... + 1. You expect 6 + 1 = 7 and 3 + 1 = 4: (7 + 1) / (4 x (2.245 + 1)) = 0.616, the value shown (tfn = 2.245 is the term's count, 3, adjusted for document length).
  3. Instead the inputs print as 6 = F, ... + 1 and 3 = n, ... + 1, and tfn is listed twice. Redo the printed formula with the printed numbers and you get (6 + 1) / (3 x 3.245) = 0.719, not 0.616.
  4. The node's 0.616 and the score, 1.0760068, are right: scoring adds the 1s, but explain prints the raw counts under labels that promise the + 1. Every explained hit with after effect B shows this, since 8.0.0.

Who is affected

Users who choose DFRSimilarity with AfterEffectB (any basic model and normalization; not Lucene's default) and read explain output. Every explained hit shows F and n one too low and tfn twice, so recomputing the node from its printed inputs gives a wrong number. Scores, ranking and matching are unaffected.

Mechanism, terms and numbers515 words

AfterEffectB scores with F = totalTermFreq + 1 and n = docFreq + 1, and its explanation node says so: "AfterEffectB, computed as (F + 1) / (n * (tfn + 1)) from:", with children labelled "F, total number of occurrences of term across all documents + 1" and "n, number of documents containing term + 1". But the children are the raw stats.getTotalTermFreq() and stats.getDocFreq(), and tfn is listed twice. For a term with totalTermFreq 6 and docFreq 3 the children show 6 and 3 where the labels promise 7 and 4, and the formula on the printed children gives 0.71897 while the node correctly shows 0.61626 (DFR I(n)B2). Scores, ranking and the node value are correct. It came in with LUCENE-8011 (8.0.0), which added the formula text and the "+ 1" labels but kept the raw values; 7.7.3 printed accurate raw labels. A fix that changes three lines (+2/-3) prints the + 1 and drops the duplicate child.

Mechanism (Lucene main 645d4fe14a20, AfterEffectB.java)

  • scoreTimes1pTfn (:32-36): long F = stats.getTotalTermFreq() + 1; long n = stats.getDocFreq() + 1; return (F + 1.0) / n;
  • explain(BasicStats stats, double tfn) (:39-49):
    • node value scoreTimes1pTfn(stats) / (1 + tfn) (:41): right;
    • description "AfterEffectB, computed as (F + 1) / (n * (tfn + 1)) from:" (:42);
    • child tfn (:43);
    • child stats.getTotalTermFreq() labelled "F, total number of occurrences of term across all documents + 1" (:44-46): the raw value;
    • child stats.getDocFreq() labelled "n, number of documents containing term + 1" (:47): the raw value;
    • child tfn again (:48).
  • DFRSimilarity.explain (DFRSimilarity.java:134-145) adds this node (:144) to every explanation of a DFRSimilarity built with AfterEffectB, whatever the basic model and normalization. Scoring (:127-131) uses only scoreTimes1pTfn, so the score is not involved.

The example, step by step

Five documents a b c a a, a d e, f g h, a a b, x y z; term a: docFreq 3, totalTermFreq 6. Document 0 has a 3 times in 5 terms; NormalizationH2 gives tfn = 3 x log2(1 + 3.4 / 5) = 2.2453837.

  1. Node: (6 + 1 + 1) / ((3 + 1) x (2.2453837 + 1)) = 8 / 12.98 = 0.61626, printed 0.61625993: right.
  2. Printed children: 6 = F, ... + 1, 3 = n, ... + 1.
  3. The printed formula on the printed children: (6 + 1) / (3 x 3.2453837) = 7 / 9.74 = 0.71897.
  4. Score: 1.7460274 (BasicModelIn) x 0.61625993 = 1.0760068: right, the same on 7.7.3, 8.0.0, main and main with the fix.

Why no test caught it

CheckHits.verifyExplanation accepts a node whose description matches .*, computed as .* from: (CheckHits.java:375, :437-438) by setting combined = value (:494-496), so it never checks such a node against its children. The test framework's RandomSimilarity does use AfterEffectB (RandomSimilarity.java:96, :131), so Lucene's tests explain with it, but nothing compares the children with their labels.

Exposure and likelihood156 words
  • Who. Users of DFRSimilarity built with AfterEffectB (public constructors, @lucene.experimental, not the default similarity) who read IndexSearcher.explain output, for example to tune or debug relevance. Lucene ships two after effects, B and L; L's explanation is consistent.
  • What they see. On every explained hit, F and n one lower than their labels say, and tfn twice. Recomputing the node from its printed inputs gives 0.719 instead of the 0.616 shown in the example, which can make a reader suspect the score.
  • Not affected. Scores, ranking, matching, the node's own value and the top explanation value; nothing throws; assertions on and off behave the same.
  • Downstream (by reading, not run): Elasticsearch and OpenSearch expose a DFR similarity with after effect b and an explain API, so their explain output would show the same numbers.
  • Likelihood: plausible. Every explained hit is affected, but only a reader who checks children against labels notices; no report since 8.0.0 (2019).

Minimal repro

One JUnit test, no index: it calls the public AfterEffectB.explain (what DFRSimilarity.explain uses) on a BasicStats with totalTermFreq 6 and docFreq 3 and checks the two inputs whose labels say "+ 1". Deterministic, single thread; fails with assertions on and off.

TestAfterEffectBExplain.java (one test class)32 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.Explanation;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestAfterEffectBExplain extends LuceneTestCase {

  public void testPlusOneChildren() {
    BasicStats stats = new BasicStats("field", 1);
    stats.setTotalTermFreq(6);
    stats.setDocFreq(3);
    Explanation[] children = new AfterEffectB().explain(stats, 1).getDetails();
    // children "F, ... + 1" and "n, ... + 1": expected 6 + 1 and 3 + 1, actual the raw 6 and 3
    assertEquals("F=7, n=4", "F=" + children[1].getValue() + ", n=" + children[2].getValue());
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.similarities.TestAfterEffectBExplain -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
The inputs labelled "F, ... + 1" and "n, ... + 1" are 7 and 4 (totalTermFreq 6 + 1, docFreq 3 + 1).
Observed
org.junit.ComparisonFailure: expected:<F=[7, n=4]> but was:<F=[6, n=3]> with assertions on and off; with the fix the test passes.

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

More files, environment, full output443 words

One test class, one method, nothing random (the seed only satisfies LuceneTestCase). It uses public API only (BasicStats constructor and setters, AfterEffectB.explain, Explanation.getDetails); it sits in the similarities package like Lucene's other similarity tests but needs nothing package-private. children[1] is the F child and children[2] the n child; with tfn = 1 the node itself is (7 + 1) / (4 x 2) = 1.0.

The test pins the recommended fix. With the alternative fix (relabel instead of adding 1), its expected string would change to F=6, n=3 and it would check the new labels instead.

Regression test: this same test. It fails on stock main and passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0 and 10.5.2: fail; with the fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run)
Command (environment)
cp $REPRO/TestAfterEffectBExplain.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/similarities/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.similarities.TestAfterEffectBExplain -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestAfterEffectBExplain > testPlusOneChildren FAILED
    org.junit.ComparisonFailure: expected:<F=[7, n=4]> but was:<F=[6, n=3]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:14CF3C90051FC187]:0)
        at app//org.junit.Assert.assertEquals(Assert.java:117)
        at app//org.junit.Assert.assertEquals(Assert.java:146)
        at app//org.apache.lucene.search.similarities.TestAfterEffectBExplain.testPlusOneChildren(TestAfterEffectBExplain.java:30)
BUILD FAILED

# -Ptests.asserts=false: the same ComparisonFailure; 10.6.0 and 10.5.2 (-ea and -da): the same
# with the recommended fix: 1 test, 0 failures, both ways

# explain probe, main 645d4fe14a20, DFR I(n)B2, term a (docFreq 3, totalTermFreq 6), doc 0
1.0760068 = weight(f:a in 0) [DFRSimilarity], result of:
  1.0760068 = score(DFRSimilarity, freq=3.0), computed as boost * basicModel.score(stats, tfn) * afterEffect.score(stats, tfn) from:
    2.2453837 = NormalizationH2, computed as tf * log2(1 + c * avgfl / fl) from:
      3.0 = tf, number of occurrences of term in the document
      1.0 = c, hyper-parameter
      3.4 = avgfl, average length of field across all documents
      5.0 = fl, field length of the document
    1.7460274 = BasicModelIn, computed as tfn * log2((N + 1) / (n + 0.5)) from:
      2.2453837 = tfn, normalized term frequency
      5 = N, total number of documents with field
      3 = n, number of documents containing term
    0.61625993 = AfterEffectB, computed as (F + 1) / (n * (tfn + 1)) from:
      2.2453837 = tfn, normalized term frequency
      6 = F, total number of occurrences of term across all documents + 1
      3 = n, number of documents containing term + 1
      2.2453837 = tfn, normalized term frequency

# the same probe with the fix: the AfterEffectB children become
      2.2453837 = tfn, normalized term frequency
      7 = F, total number of occurrences of term across all documents + 1
      4 = n, number of documents containing term + 1

# the same probe on 7.7.3 (before LUCENE-8011)
    0.6162599 = AfterEffectB, computed from: 
      2.245384 = tfn
      6.0 = totalTermFreq
      3.0 = docFreq

Why it looks unintended

  • The node states its formula and its labels promise the + 1, as the scoring code and Lucene's testIFB compute.
  • BasicModelG prints F = totalTermFreq + 1 under the same label; 7.7.3 printed raw counts under raw labels.
  • LUCENE-8011 added the "+ 1" labels and a second tfn but kept the raw values; GITHUB#13691 fixed a similar explain-only error.
The full argument241 words
  • The node defines its operands. "computed as (F + 1) / (n * (tfn + 1)) from:" and the "+ 1" labels (AfterEffectB.java:42-47) match the scoring code (:33-34); only the printed values differ.
  • What a description means. Explanation.match: "@param description how {@code value} was computed", "@param details sub explanations that contributed to this explanation" (Explanation.java:42-47).
  • Lucene's own test writes the + 1. TestSimilarityBase.testIFB (:449-453) computes the after effect as B = (TOTAL_TERM_FREQ + 1 + 1) / ((DOC_FREQ + 1) * (FREQ + 1)). On LUCENE-8020 (#9068), rmuir described the code as using (F + 1 + 1) / ((n + 1) * (tfn + 1)).
  • Same family. BasicModelG.explain computes F = stats.getTotalTermFreq() + 1 (BasicModelG.java:55) and prints it under "F, total number of occurrences of term across all docs + 1" (:62-63). BasicModelIn prints the raw docFreq under "n, number of documents containing term", without "+ 1".
  • History. Before LUCENE-8011 the node read "AfterEffectB, computed from:" with 6.0 = totalTermFreq and 3.0 = docFreq: accurate (7.7.3, executed). LUCENE-8011 (8.0.0 CHANGES "Improved similarity explanations", CHANGES.txt:6825) added the formula, the "+ 1" labels and a second tfn, and kept the raw values. Its issue (#9059) and review (apache/lucene-solr#280) do not discuss the values.
  • Precedent. 9.12.0 Bug Fixes: "GITHUB#13691: Fix incorrect exponent value in explain of SigmoidFunction." (CHANGES.txt:2705), a wrong explain value with a correct score.
The devil's-advocate check192 words

The strongest case for "not a bug": IndexSearcher.explain "is intended to be used in developing Similarity implementations" (IndexSearcher.java:934-940); AfterEffectB, AfterEffect and DFRSimilarity are @lucene.experimental; no javadoc promises that a "computed as" node can be rebuilt from its children, and CheckHits treats such nodes as opaque; the strings have stood for about nine years without a complaint; and the open LUCENE-8023 (#9071) proposes dropping the "+ 1" terms from the scores, so one could call the raw counts the real F and n.

Why it fails: under any reading, a child whose label says "+ 1" and whose value has no + 1 contradicts itself. If the raw counts were meant, the label and the formula would say so, as 7.7.3's did and as BasicModelIn's n child does today. The duplicated tfn child reads as a copy slip. The rest (expert API, experimental class, no complaints) argues for Low priority, which is how it is classed, not for intent. LUCENE-8023 would change scores; it does not make today's explanation right. The independent verifier, the devil's-advocate check and the cross-model reviews (OpenAI GPT-5.6) all rated the works-as-designed case weak.

Suggested fix

Recommended Print the values the labels name, list tfn once (validated)

Print stats.getTotalTermFreq() + 1 and stats.getDocFreq() + 1 under the existing labels and drop the second tfn child (3 lines in AfterEffectB.explain). Scores do not change. The repro passes, and the similarities package and explanation suites pass with assertions on and off.

@@ -42,10 +42,9 @@ public class AfterEffectB extends AfterEffect {
         getClass().getSimpleName() + ", computed as (F + 1) / (n * (tfn + 1)) from:",
         Explanation.match((float) tfn, "tfn, normalized term frequency"),
         Explanation.match(
-            stats.getTotalTermFreq(),
+            stats.getTotalTermFreq() + 1,
             "F, total number of occurrences of term across all documents + 1"),
-        Explanation.match(stats.getDocFreq(), "n, number of documents containing term + 1"),
-        Explanation.match((float) tfn, "tfn, normalized term frequency"));
+        Explanation.match(stats.getDocFreq() + 1, "n, number of documents containing term + 1"));
   }
All options and trade-offs3 options ยท 429 words

One fix was validated through gradle in a scratch worktree of 645d4fe14a20; the alternative keeps the raw values and fixes the text instead; changing the scores (LUCENE-8023) is a separate decision.

Print the values the labels name, list tfn once (validated) (recommended)

AfterEffectB.java +2/-3:

@@ -42,10 +42,9 @@ public class AfterEffectB extends AfterEffect {
         getClass().getSimpleName() + ", computed as (F + 1) / (n * (tfn + 1)) from:",
         Explanation.match((float) tfn, "tfn, normalized term frequency"),
         Explanation.match(
-            stats.getTotalTermFreq(),
+            stats.getTotalTermFreq() + 1,
             "F, total number of occurrences of term across all documents + 1"),
-        Explanation.match(stats.getDocFreq(), "n, number of documents containing term + 1"),
-        Explanation.match((float) tfn, "tfn, normalized term frequency"));
+        Explanation.match(stats.getDocFreq() + 1, "n, number of documents containing term + 1"));
   }

Trade-offs

  • The explanation then matches its own formula and BasicModelG's "F, ... + 1" child; the probe tree with the fix shows 7 = F, 4 = n, one tfn, and the same score 1.0760068.
  • Explain text changes for anyone who parses it: two numbers go up by 1 and one child goes away.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle --offline, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off.
  • With the fix: the repro passes both ways; the whole org.apache.lucene.search.similarities package plus TestSimpleExplanations* and TestComplexExplanations* pass with assertions on and off (32 suites, 353 tests, 0 failures, 2 skipped by the suite's own assumption); :lucene:core:checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass with the fix and the test in place.
  • Earlier runs: the finder's, the independent reproducer's and the cross-model reviewers' tests pass with it; 118 of 118 lucene/core similarity tests passed with it on a9779ef3c193.

Keep the raw values, fix the text (not run)

Labels "F, total number of occurrences of term across all documents" and "n, number of documents containing term", formula "(F + 2) / ((n + 1) * (tfn + 1))", and drop the second tfn.

Trade-offs

The tree shows raw index statistics, as 7.7.3 did and as BasicModelIn's n child does, but the formula moves away from the F and n of the scoring code; the regression test would check the new labels instead.

What the checks validated

Not run.

Remove the "+ 1" from the scores (LUCENE-8023; not for this bug) (not run)

The open LUCENE-8023 (#9071, since 2017) proposes removing divide-by-zero "+ 1" terms across similarities.

Trade-offs

Changes scores and needs its own discussion; it is related context, not a fix for the explanation.

What the checks validated

Not run.

Downstream note

Elasticsearch and OpenSearch expose a DFR similarity with after effect b and an explain API, so their explain output would show the same off-by-one inputs (by reading, not run); scores there are unaffected too.

How it was found

A semi-formal check explained 3,178 hits under 79 similarity configurations and recomputed every "computed as" formula from the inputs its node lists. All 1,000 AfterEffectB nodes disagreed with their own formula while the other DFR nodes agreed, and the check caught 10 of 10 injected errors. The finding was then reduced to a public-API test.

The method in full171 words

Method: semi-formal (a differential evaluation of explain trees against their own formula strings).

  • Sweep. A test program ran IndexSearcher.explain over 79 similarity configurations x TermQuery, PhraseQuery, SynonymQuery and a 2-clause BooleanQuery x boosts 1 and 2.5, plus other scoring queries: 3,178 explained hits, 14,574 "computed as <formula> from:" nodes (14,554 evaluated on their own labelled children) and 3,806 labelled statistics checked against the index, with assertions on and off.
  • Result. All 1,000 AfterEffectB formula nodes over the 20 DFR configurations that use after effect B disagreed with their formula, and 200 + 200 "F, ... + 1" / "n, ... + 1" children were 1 below the index statistic + 1; every other DFR node agreed.
  • Oracle check. 10 of 10 injected mutants were detected, so the check is not vacuous.
  • Then a public-API JUnit, a cross-model refute review (OpenAI GPT-5.6, with execution), an independent reproducer, a cross-model intended-behaviour review, a devil's-advocate check, an adjudication, the registration check, an audit, a repro minimization, the version-history runs and a prior-report search followed.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-28 and 2026-11-01: GitHub issues and PRs including the JIRA archive, mailing lists, downstream trackers and the web; no open PR touches AfterEffectB or DFRSimilarity). Related only: LUCENE-8011 (#9059), which introduced the labels, and the open LUCENE-8023 (#9071) on the "+ 1" score terms.

Searches2 searches

Verdict new: no issue, PR, commit, JIRA entry, list post or web page reports that AfterEffectB.explain prints totalTermFreq and docFreq under "+ 1" labels, or that its formula does not reproduce its value. Related, not this bug: #9059 (LUCENE-8011, origin; the thread covers goals only), #9063 (LUCENE-8015, score monotonicity; quotes the old accurate explain), #9068 (LUCENE-8020, rmuir's comment on the "+ 1" formula), open #9071 (LUCENE-8023), open #16267 (other explanation improvements), similarity javadoc items #16921 and #16916, and the general javadoc PR #16949 (27 files; of the similarity files only BM25Similarity/TFIDFSimilarity); none touches AfterEffectB.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr, Lucene.NET), dev@/java-user@/issues@/commits@ lists, the web2026-10-28scripted prior-report search (65 queries), 74 manual GitHub searches incl. both label texts and the formula text, the changed files of all 413 open PRs, commit history by path, 8 web queries; 13 hits readno report
apache/lucene issues and PRs, all of GitHub, upstream main2026-11-01"AfterEffectB"; "explain", "DFRSimilarity", "similarity explanation", "AfterEffect" updated since 2026-10-28; the label text and "AfterEffectB explain" across GitHub; the 62 PRs updated since 2026-10-28; AfterEffectB.java at upstream main 2f424fd428dano report; #16949 is a general javadoc PR (27 files); of the similarity files it touches only the BM25Similarity/TFIDFSimilarity javadoc; upstream file unchanged

History

Introduced by LUCENE-8011 (6771886cf33, 2017), first released in 8.0.0. 7.7.3 prints accurate raw labels (6.0 = totalTermFreq); 8.0.0 to 10.6.0 and main print the "+ 1" labels with the raw values. Not fixed upstream.

Versions tested8 rows

Introduced by 6771886cf33 "LUCENE-8011: Improve similarity explanations" (author GitHub mayya-sharipova, author date 2017-11-30, committed by jpountz 2017-12-13; apache/lucene-solr#280, issue now #9059). It rewrote the node to "computed as (F + 1) / (n * (tfn + 1)) from:", relabelled the children with "+ 1" and added a second tfn, but kept the raw values. Scoring has used F = totalTermFreq + 1 and n = docFreq + 1 since 2011. The only later change to the file is 2d6ad2fee6d (2020-12-23, a reformat). A commit-level A/B on the main jars: the file at 6771886cf33^ passes, at 6771886cf33 fails. Never backported to 7.x.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails through gradle (-ea, -da); explain probe prints 6 = F, ... + 1, 3 = n, ... + 1, tfn twice
10.6.0, 10.5.2 affectedminimal test fails (-ea, -da)
10.0.0, 9.12.3, 9.0.0, 8.11.4, branch_10_6 affectedthe version-history tests fail
8.0.0 affectedexplain probe prints the same affected tree; first affected release
7.7.3 not affectedexplain probe: 0.6162599 = AfterEffectB, computed from: with 6.0 = totalTermFreq, 3.0 = docFreq; same score 1.0760068
7.2.0 not affectedthe version-history test passes (old format, accurate)
main jars + AfterEffectB.java at 6771886cf33^ / at 6771886cf33 A/Bpasses before the commit, fails after it
upstream main 2f424fd428da (2026-10-30) readthe same AfterEffectB.java blob as 645d4fe14a20

Independent checks

Reproduced by the finder, a cross-model refute review and a cross-model intended-behaviour review (OpenAI GPT-5.6, both with execution), an independent reproducer, a devil's-advocate check, the adjudication, the registration check and audit, a repro minimization and this doc's gradle runs. All say real; the works-as-designed case was rated weak.

Checks and verdicts264 words

Every reproduction ran on stock builds; the fix checks ran with the patched AfterEffectB only. The cross-model reviews ran their own tests.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (explain-formula unit) candidateSweep: all 1,000 AfterEffectB nodes mismatch, 10 of 10 mutants caught; public-API JUnit fails -ea/-da, passes with the fix.
Cross-model refute review (OpenAI GPT-5.6, with execution) real (high)Own JUnit fails on main with three seeds; AfterEffectL control passes; the fix passes its test, TestSimilarityBase (27/27) and the basic-model suites (8/8).
Independent reproducer realOwn JUnit (I(n)B, GB2, I(F)B1, every hit) fails on main -ea/-da, 10.5.2 and 9.12.3; passes with its own fix.
Cross-model intended-behaviour review (OpenAI GPT-5.6, with execution) real (high)Own test observes the values on main and 10.5.2 and the old accurate tree on 7.7.3; works-as-designed case weak.
Devil's advocate real, minorOwn main() fails on main, 10.5.2 and 8.11.4; the works-as-designed case does not survive.
Adjudication confirmed (high), LowRe-ran the independent reproduction's JUnit with a new seed: fails -ea/-da, passes with only AfterEffectB fixed.
Registration check and audit confirmedRe-runs on main -ea/-da; fix passes; hand check (7 + 1) / (4 x 3.24538) = 0.61626.
Repro minimization verifiedThe one-test repro fails on 645d4fe14a20 -ea/-da; the fix passes.
This doc's runs (2026-11-01) reproduced, fix validatedGradle on 645d4fe14a20 stock and fixed; minimal test on 10.6.0 and 10.5.2; explain probe on main, main + fix, 8.0.0 and 7.7.3.

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,512 words

Finding record

LFV-922, snapshot of 2026-11-06

Registry title
AfterEffectB.explain shows the raw totalTermFreq and docFreq under children labelled 'F, ... + 1' and 'n, ... + 1' (and lists tfn twice), so its own formula (F + 1) / (n * (tfn + 1)) on the children it lists does not give the value it shows; the score itself is correct
Area
core / search similarities (DFR after effect B explanation: AfterEffectB.explain, reached through DFRSimilarity.explain)
Priority
Low, rank 1039: In core DFRSimilarity with the common after effect B, explain shows F and n one lower than their labels plus a duplicate tfn child while scores stay correct.
Impact class
minor
Impact note
Anyone who calls IndexSearcher.explain on a search that uses a DFRSimilarity built with AfterEffectB (any basic model and normalization; public constructors, @lucene.experimental, not the default similarity; default IndexWriterConfig, codec and searcher, -ea and -da, a plain TermQuery) sees F and n each 1 lower than their labels promise, plus a duplicated tfn child, on every explained hit; scores, ranking, matching, the AfterEffectB node value and the top explanation value are all correct and nothing throws; every release from 8.0.0 to 10.6.0; by the finder's reading (not executed) Elasticsearch and OpenSearch expose a DFR similarity with after effect 'b' and an explain API, so their explain output would show the same.
Summary
You search with DFRSimilarity(basic model, new AfterEffectB(), normalization) and call IndexSearcher.explain on a hit. The AfterEffectB node says 'computed as (F + 1) / (n * (tfn + 1)) from:' and labels its children 'F, total number of occurrences of term across all documents + 1' and 'n, number of documents containing term + 1', so for a term with totalTermFreq 6 and docFreq 3 you expect F = 7 and n = 4. Instead the children show 6 and 3 (the raw statistics) and tfn is listed twice, so the formula on the listed children gives 0.71897 while the node correctly shows 0.61626 (I(n)B2); the score is right and only the explanation's operands are wrong.
Introduced
6771886cf3303a9a2c3cb86c8d0329e55ee0feaa ('LUCENE-8011: Improve similarity explanations', author mayya-sharipova, committed by jpountz; apache/lucene-solr#280, landed as a squashed commit with 'Closes #280'; the JIRA issue is now apache/lucene#9059): it rewrote the node as 'computed as (F + 1) / (n * (tfn + 1)) from:', relabelled the children with '+ 1' and added a second tfn child, but kept passing the raw stats.getTotalTermFreq() and stats.getDocFreq(); scoring has used F = totalTermFreq + 1 and n = docFreq + 1 since 2011 (LUCENE-3430/LUCENE-3722), and LUCENE-8015 (63b63c57348, 2017-12-06) changed only the value computation and kept the accurate labels. [read: git show and git blame -w --ignore-rev 2d6ad2fee6d in the history clone; the only later change to the file, 2d6ad2fee6d (LUCENE-9570, 2020-12-23), is a reformat] [exec: on the main a9779ef3c193 jars, AfterEffectB shadowed from 6771886cf33^ gives an accurate 'computed from:' tree (pass), and from 6771886cf33 the mismatch (fail)] (2017-11-30 (author date 2017-11-30T20:03:39-05:00, i.e. 2017-12-01T01:03:39Z; committed 2017-12-13; PR opened 2017-12-01, closed 2017-12-13))
First affected version
8.0.0 [exec] (CHANGES.txt 8.0.0 'LUCENE-8011: Improved similarity explanations'; never backported to 7.x); the last unaffected release is 7.7.3 [exec] (also 7.2.0 [exec], and 5.0.0, 5.5.5 and 6.6.6 by the finder's release probe [exec]), where the node reads 'AfterEffectB, computed from:' with accurate raw labels 'totalTermFreq' and 'docFreq'; AfterEffectB itself exists since 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 4ae946e5a9b, the RC1 revision) and main a9779ef3c193; not fixed upstream. [exec] follow-up history runs (seed DEADBEEF): 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main (-ea and -da) fail; 7.2.0 and 7.7.3 pass (old accurate format); the finder's JUnit fails on main (-ea and -da); the independent reproduction's JUnit on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's main() on main (-ea and -da), 10.5.2 and 8.11.4; a separate test observes the same values on main and 10.5.2 and the old accurate tree on 7.7.3; the finder's main() release probe: 8.0.0 to 10.5.2 affected, 5.0.0 to 7.7.3 not. [read] every release tag from 8.0.0 on contains 6771886cf33 (git tag --contains); 8.9.0-8.11.4 from the lucene-solr branch_8_11 commit list; upstream main c5ab3e57b9ba (2026-10-28) still passes the raw statistics (read-only contents API, re-checked 2026-10-28), and branch_10x and branch_10_6 have no later commit on the file. 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 with 65 queries (16 GitHub issue/PR on apache/lucene, 3 commit, 2 downstream Elasticsearch/OpenSearch/Solr, 32 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 8 web; 547 distinct hits, 1 high-overlap hit, LUCENE-8015 #9063, read: not this bug), 74 manual GitHub searches (apache/lucene, all of GitHub, Lucene.NET, Elasticsearch, OpenSearch, Solr; including both label texts and the formula text), 7 read-only API reads (the changed files of all 413 open apache/lucene PRs: none touches AfterEffectB, AfterEffect or DFRSimilarity; commit history by path with author and committer dates on main, branch_10x and branch_10_6; the main and branch_11x source), 8 web queries; 13 hits read, none the same bug; plus the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's own searches. Refresh 2026-10-28: GitHub searches 'AfterEffectB', 'AfterEffect explain', 'DFRSimilarity', the 'n' label text, 'AfterEffectB explain' over all of GitHub, and 'similarities', 'explain', 'explanation' and 'DFR' updated since 2026-10-26: nothing new (only #16921, #16909, #16839, #16858 and #16931, unrelated); the changed files of the 69 PRs (open and closed) updated since 2026-10-26, newest #16931: only #16916 touches a similarities file (the IBSimilarity class javadoc); the last upstream commit on AfterEffectB.java is still 2d6ad2fee6d (2020-12-23, reformat) and upstream main c5ab3e57b9ba still passes the raw statistics; 2 web queries: nothing. Related, not this bug: LUCENE-8011 (#9059, apache/lucene-solr#280: introduced the strings; the issue and review discuss goals only), LUCENE-8015 (#9063, AfterEffectB score monotonicity; quotes the old accurate explain), LUCENE-8020 (#9068: a maintainer's comment documents that the code uses (F + 1 + 1) / ((n + 1) * (tfn + 1)), which supports the expected values), LUCENE-8023 (#9071, open since 2017: remove the '+ 1' hacks, a score-changing alternative direction, not the explanation), #16267 (open, explanation test gaps), #16921, #16909 and #16916 (open similarity javadoc items, 2026-10-28).
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off; with the fix it passes, the whole org.apache.lucene.search.similarities package and the TestSimpleExplanations* / TestComplexExplanations* suites pass with assertions on and off (32 suites, 353 tests, 0 failures, 2 skipped by the suite's own assumption), and :lucene:core format, ECJ and forbidden-apis checks pass. Outside gradle: the minimal test fails on 10.6.0 and 10.5.2 (-ea and -da); an explain probe prints the affected tree on main and 8.0.0, the fixed tree on main with the fix, and the old accurate tree on 7.7.3. Earlier runs used main a9779ef3c193 (identical similarity sources) and 7.2.0 to 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/similarities/AfterEffectB.java:32-36 (at a9779ef3c193) (scoreTimes1pTfn: F = getTotalTermFreq() + 1, n = getDocFreq() + 1), :39-49 (explain: raw getTotalTermFreq() :45 and getDocFreq() :47 under '+ 1' labels, tfn at :43 and :48)
  • lucene/core/src/java/org/apache/lucene/search/similarities/DFRSimilarity.java:134-145 (at a9779ef3c193) (explain adds afterEffect.explain(stats, tfn))
  • lucene/core/src/test/org/apache/lucene/search/similarities/TestSimilarityBase.java:448-453 (at a9779ef3c193) (testIFB: B = (TOTAL_TERM_FREQ + 1 + 1) / ((DOC_FREQ + 1) * (FREQ + 1)))
  • lucene/core/src/java/org/apache/lucene/search/Explanation.java:31,43 (at a9779ef3c193) ('@param description how {@code value} was computed')
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:375, (at a9779ef3c193) :437-495 ('computed as ... from:' nodes accepted as self-consistent)
  • lucene/CHANGES.txt:2688 (at a9779ef3c193) (Lucene 9.12.0, Bug Fixes: 'GITHUB#13691: Fix incorrect exponent value in explain of SigmoidFunction.')
  • upstream (read-only): apache/lucene#9059, #9063, #9068, #9071, #16267, #16909, #16916, #16921; apache/lucene-solr#280; GitHub commits API on AfterEffectB.java, AfterEffect.java and DFRSimilarity.java (main head c5ab3e57b9ba, 2026-10-28)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/AfterEffectB.java:32-36, 39-49
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/DFRSimilarity.java:127-131, 134-145
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:375, 437-438, 494-496
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/RandomSimilarity.java:96, 131
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/AfterEffectB.java:24; DFRSimilarity.java:73 (@lucene.experimental)
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/RandomSimilarity.java:96 (after effects B and L)
Minimal repro
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/BasicStats.java:50, 101, 111 (public constructor and setters)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/AfterEffectB.java:24, 33-34, 42-47
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/Explanation.java:42-47
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/similarities/TestSimilarityBase.java:449-453
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/BasicModelG.java:55, 62-63; AfterEffect.java:28; DFRSimilarity.java:73
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:934-940
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:2537, 2689, 2705 (9.12.0 Bug Fixes); 6611, 6818, 6825 (8.0.0 Improvements)
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/AfterEffectB.java:39-49

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 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.