All findings · Lucene finding · LFV-393 · nearestfuzzyquery-negative-idf-with-deletes

NearestFuzzyQuery and KNearestFuzzyClassifier throw "boost must be a positive float" when most of the index is deleted documents not yet merged away

Delete 4 of 5 "apple" documents before a merge reclaims them, and a NearestFuzzyQuery for "apple" (the query behind KNearestFuzzyClassifier) throws boost must be a positive float: its idf divides live documents by a docFreq that still counts the deleted copies. It affects 8.0.0 to 10.6.0 and main.

Summary

Priority
Low, rank 630The experimental KNearestFuzzyClassifier/NearestFuzzyQuery throws IllegalArgumentException only when more than about 63% of the index is unmerged deleted documents (non-default merge setups), so it sits with the other niche queries that throw instead of answering.
Impact
crash
Since
8.0.0 as an exception (LUCENE-7996); the negative idf since 7.0.0 (LUCENE-7838, 2017)introduced 2017-06-29 · about 9 years
Affected
8.0.0 to 10.6.0 and main; 7.x returns negative scores instead
Status
documented
Found by
AI tools: coverage-guided fuzzing (Jazzer) of the classifiers, confirmed by a public-API JUnit

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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 fuzzy kNN classification query weights its terms with an idf that turns negative on an index full of unmerged deletes, and then throws instead of matching.

Background: deleted documents and NearestFuzzyQuery

  1. A deleted document stays in its segment, only marked as deleted, until a merge rewrites that segment. numDocs() counts the live documents, maxDoc() every document still in a segment, and docFreq(term) the documents containing the term, deleted ones included: its javadoc says it "does not take into account deleted documents that have not yet been merged away".
  2. NearestFuzzyQuery (classification module) expands each input term into fuzzy variants (one edit away) and gives each a boost of edit-similarity² × idf. KNearestFuzzyClassifier runs it to find the k most similar training documents and lets them vote on the class.
Summary card of the bug. Title: NearestFuzzyQuery throws when most of the index is deleted documents not yet merged away. 5 documents with text apple, 4 deleted (NoMergePolicy), count of a NearestFuzzyQuery for apple: expected 1, actual IllegalArgumentException: boost must be a positive float, got -0.098612286. Left panel: one segment with maxDoc 5; numDocs() = 1 (live only, line 140), docFreq(text:apple) = 5 (deleted ones included, line 155); line 199: idf = ln((1 + 1) / (5 + 1)) + 1 = ln(2/6) + 1 = -0.0986; line 248: new BoostQuery(tq, -0.098612286) throws at BoostQuery.java:41. Right panel: a chart of the idf against the number of deleted copies of 1 live document, 0 to 7: with numDocs() it falls from 1.0 below zero from 4 deleted copies; with maxDoc() it stays at 1.0.
The minimal repro: 1 live and 4 deleted "apple" documents give numDocs() = 1 but docFreq = 5, so the idf is ln(2/6) + 1 < 0 and BoostQuery throws; with maxDoc() the idf is 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 5 documents whose text is "apple" and delete 4 before a merge reclaims them (the repro uses NoMergePolicy; a stock LogDocMergePolicy before a merge or soft-delete retention give the same counts). The reader reports numDocs() = 1 but docFreq(text:apple) = 5: docFreq still counts deleted documents, as documented.
  2. Count the hits of a NearestFuzzyQuery for "apple", or call KNearestFuzzyClassifier.assignClass("apple"). You expect 1 hit, the live document, and its class.
  3. Instead the query's rewrite throws IllegalArgumentException: boost must be a positive float, got -0.098612286, with assertions on and off.
  4. Why: the term weight is idf = ln((numDocs + 1) / (docFreq + 1)) + 1 = ln(2/6) + 1 = −0.0986, and BoostQuery rejects a negative boost. With maxDoc() = 5 in place of numDocs(): ln(6/6) + 1 = 1.
  5. It takes docFreq + 1 > e × (numDocs + 1): with 3 deleted copies (docFreq 4) the idf is still +0.084 and the query works. So more than about 63% of the index must be deleted but not merged away; the default TieredMergePolicy had reclaimed the deletes in every tested shape.

Who is affected

Users of the opt-in classification module's KNearestFuzzyClassifier (@lucene.experimental) or NearestFuzzyQuery, on an index where over about 63% of documents are deleted but not merged: a stock Log*MergePolicy before a merge, soft-delete retention of often-updated documents, NoMergePolicy. Requests whose kept fuzzy variants include an affected term throw; others work. The default merge policy reclaimed the deletes in every tested shape.

Mechanism, terms and numbers745 words

NearestFuzzyQuery, the query behind KNearestFuzzyClassifier in the classification module, weights each term with idf = ln((numDocs + 1) / (docFreq + 1)) + 1, taking numDocs from reader.numDocs() (live documents only) and docFreq from reader.docFreq(), which still counts deleted documents that are not merged away yet. When the deleted copies push docFreq + 1 above e × (numDocs + 1), the idf is negative, and new BoostQuery(termQuery, negativeScore) in rewrite() throws IllegalArgumentException: boost must be a positive float (since 8.0.0, when LUCENE-7996 made BoostQuery reject negative boosts; 7.x silently returned negative scores). The smallest case with a live document is 5 documents with "apple", 4 deleted: numDocs 1, docFreq 5, idf −0.0986 (with retained soft deletes and no live document, 2 documents already throw). It needs more than about 63% of all documents deleted but still present: reached with the stock Log*MergePolicy before a merge, soft-delete retention (even after forceMerge(1)) and NoMergePolicy, not with the default TieredMergePolicy in any tested shape. Using reader.maxDoc() keeps idf ≥ 1; the highlighter had the same mismatch: LUCENE-1355 clamped docFreq, then LUCENE-4289 switched to maxDoc().

The bug

  1. KNearestFuzzyClassifier.assignClass and getClasses (KNearestFuzzyClassifier.java:105-133) call knnSearch (:135-148), which adds one NearestFuzzyQuery for the input text (addTerms once per text field) as a MUST clause, next to a MUST WildcardQuery on the class field and the optional filter query, and calls indexSearcher.search(bq.build(), k) (:147). Those clauses do not help: rewrite takes its statistics from the whole reader.
  2. NearestFuzzyQuery.rewrite (NearestFuzzyQuery.java:217-264) collects the terms on the searcher's whole reader (:219, :223). The private addTerms reads int corpusNumDocs = reader.numDocs(); (:140), then per analyzed term int df = reader.docFreq(startTerm); (:155); when the term itself is absent, df is the average docFreq of its variants (:173-178), which counts deleted documents too.
  3. Each variant's score is (editBoost * editBoost) * idf(df, corpusNumDocs) (:187), with idf = (float) (Math.log((docCount + 1) / (double) (docFreq + 1)) + 1.0) (:198-200). The squared factor is never negative (it can be 0 for an edit of a one-character term, and 0 × a negative idf is −0.0, which BoostQuery rejects too), so a variant's boost is rejected exactly when the idf is negative: docFreq + 1 > e × (numDocs + 1).
  4. rewrite wraps each variant as new BoostQuery(tq, st.score) (:248, :256), and BoostQuery's constructor throws IllegalArgumentException("boost must be a positive float, got " + boost) for a negative boost (BoostQuery.java:38-44). It is a plain check, not an assertion.
  5. numDocs() counts live documents (IndexReader.java:292-298), docFreq also counts deleted ones (IndexReader.java:401-408), and docFreq ≤ maxDoc(). So with maxDoc() at :140 the idf is always ≥ 1.

The numbers

  • The minimal repro: 5 documents with "apple", 4 deleted under NoMergePolicy, an NRT reader: numDocs 1, maxDoc 5, docFreq 5; count(NearestFuzzyQuery("apple")) throws boost must be a positive float, got -0.098612286 = ln(2/6) + 1, with -ea and -da, on main and on the 10.6.0 jars.
  • Sweep, 1 live document plus d deleted copies: d = 0, 1, 2, 3 give idf 1.0, 0.595, 0.307, 0.084 and 1 hit; d = 4 to 8 throw exactly the predicted idf: -0.0986, -0.253, -0.386, -0.504, -0.609.
  • The same failure, executed on main and 10.6.0: KNearestFuzzyClassifier.assignClass("apple"); the misspelled input "appel" (-0.0631 = 0.8² × -0.0986, through the variant average); a commit-point reader instead of an NRT reader; one document soft-updated 4 times while SoftDeletesRetentionMergePolicy keeps the old versions, then forceMerge(1); LogDocMergePolicy at its defaults.
  • Not affected: no deletions; 3 of 4 deleted; the default IndexWriterConfig, which had already dropped the deletes when the reader opened (maxDoc 1).
  • Threshold in general: since docFreq ≤ maxDoc, it needs maxDoc + 1 > e × (numDocs + 1), more than about 1 − 1/e = 63% of the reader's documents deleted but present (80% with 1 live document). Then an input term whose df (its docFreq, or for an absent term its variants' average, deleted copies included) exceeds e × (numDocs + 1) − 1 makes rewrite throw if one of its variants is kept. For long input that is necessary, not sufficient: the query keeps only the 300 highest-scoring variants overall (MAX_NUM_TERMS, :58), so enough positive-scored variants can push the negative ones out; the one-term repro always keeps its variant. Inputs without such a term still work.
Exposure and likelihood332 words
  • Who is hit. Applications that use KNearestFuzzyClassifier (@lucene.experimental) or NearestFuzzyQuery (public, not tagged experimental) from the opt-in classification module, on an index or reader where more than about 63% of the documents are deleted but still present. A request throws when a kept fuzzy variant comes from a term above the threshold (in long input, positive-scored variants can push it out of the 300 kept); others work.
  • Default vs opt-in. The default IndexWriterConfig (TieredMergePolicy) dropped the deletes in every test, including a probe with up to 1,000 added-then-deleted documents; its deletesPctAllowed, "the maximum percentage of doc id space taken by deleted docs", is 20% by default and at most 50% (TieredMergePolicy.java:120-124), both under the 63% needed, but it is a target for merge selection, not a guarantee, and heavy churn on large segments was not tested. The state does arise with supported setups: the stock LogByteSizeMergePolicy or LogDocMergePolicy before a merge; SoftDeletesRetentionMergePolicy (itself @lucene.experimental), whose "main purpose ... is to implement retention policies for document modification to vanish in the index" (SoftDeletesRetentionMergePolicy.java:40-44; forceMerge(1) keeps the retained versions); NoMergePolicy; or a reader view that hides documents through live docs (no deletions at all; a custom wrapper, an informational run).
  • Scale. No scale needed: 5 documents. The failure is immediate and loud; nothing wrong is returned and nothing is written. It lasts as long as the index state does.
  • Likelihood: rare. No downstream caller of these classes was found: Solr's classification processor uses KNearestNeighborDocumentClassifier and SimpleNaiveBayesDocumentClassifier, and code search of Solr, Elasticsearch and OpenSearch finds neither class. No public report was found in the documented searches, although the exception has existed since 8.0.0 (2019).
  • Workaround. Reclaim the deletes before classifying (a merge that drops them removes the trigger), or catch IllegalArgumentException and treat the input as unclassified. Under soft-delete retention a merge keeps the retained versions (even forceMerge(1)), so there the retention query or the retained data has to change first.

Minimal repro

One JUnit class for lucene/classification/src/test/org/apache/lucene/classification/utils/, one method, public API only: 5 documents with "apple", 4 deleted under NoMergePolicy, an NRT reader, then count of a NearestFuzzyQuery for "apple". It calls no random(), and it failed the same way under every recorded seed of Lucene's randomized test framework, with assertions on and off; it is also the suggested regression test.

TestNearestFuzzyQueryDeletedDocs.java (JUnit for Lucene's build)60 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.classification.utils;

import java.util.List;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
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.NoMergePolicy;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestNearestFuzzyQueryDeletedDocs extends LuceneTestCase {

  public void testDeletedDocsNotMergedAway() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) {
      for (int i = 0; i < 5; i++) {
        w.addDocument(
            List.of(
                new StringField("id", i == 0 ? "live" : "deleted", Field.Store.NO),
                new TextField("text", "apple", Field.Store.NO)));
      }
      w.deleteDocuments(new Term("id", "deleted"));
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        NearestFuzzyQuery query = new NearestFuzzyQuery(new StandardAnalyzer());
        query.addTerms("apple", "text");
        try {
          assertEquals(1, new IndexSearcher(reader).count(query));
        } catch (IllegalArgumentException e) {
          fail(
              "expected: 1 hit (numDocs=" + reader.numDocs() + ", docFreq(apple)="
                  + reader.docFreq(new Term("text", "apple")) + "); actual: " + e.getMessage());
        }
      }
    }
  }
}
Command
cp $REPRO/TestNearestFuzzyQueryDeletedDocs.java $LUCENE/lucene/classification/src/test/org/apache/lucene/classification/utils/
cd $LUCENE && ./gradlew --no-daemon :lucene:classification:test --tests org.apache.lucene.classification.utils.TestNearestFuzzyQueryDeletedDocs -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
Expected
1 hit: the one live document containing "apple".
Observed
IllegalArgumentException: boost must be a positive float, got -0.098612286 from BoostQuery.<init> in NearestFuzzyQuery.rewrite, with assertions on and off.

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

More files, environment, full output173 words

The test catches only the IllegalArgumentException and turns it into a failure that states expected vs actual, including numDocs and docFreq. NoMergePolicy is just the simplest way to keep the deletes: a stock LogDocMergePolicy before a merge or one document soft-updated 4 times under SoftDeletesRetentionMergePolicy (even after forceMerge(1)) reach the same counts and fail the same way, and so does KNearestFuzzyClassifier.assignClass("apple") on the same documents with a class field added. Formatting: google-java-format reflows the fail(...) message. The test could equally be a method of the existing TestKNearestFuzzyClassifier.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release; the exception exists since 8.0.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

TestNearestFuzzyQueryDeletedDocs > testDeletedDocsNotMergedAway FAILED
    java.lang.AssertionError: expected: 1 hit (numDocs=1, docFreq(apple)=5); actual: boost must be a positive float, got -0.098612286
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:643F8D37CED96DDE]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at org.apache.lucene.classification.utils.TestNearestFuzzyQueryDeletedDocs.testDeletedDocsNotMergedAway(TestNearestFuzzyQueryDeletedDocs.java:53)

1 test completed, 1 failed

# the same with -Ptests.asserts=false and on the 10.6.0 release jars; the exception is thrown at
#   BoostQuery.<init>(BoostQuery.java:41) <- NearestFuzzyQuery.rewrite(NearestFuzzyQuery.java:248) (:245 on 10.6.0)
# with the recommended fix the test passes

Why it looks unintended

  • docFreq counts deleted documents (IndexReader.java:402-404); core similarities pair it with a count of the same documents for exactly this reason (TFIDFSimilarity.java:360-363), and so does MoreLikeThis.
  • MIGRATE.md (LUCENE-7996): a query's score formula "may never produce negative scores".
  • The same numDocs-against-docFreq idf bug was fixed twice in the highlighter (LUCENE-1355 clamped docFreq; LUCENE-4289 then switched numDocs() to maxDoc()).
The full argument344 words
  • The two counts come from different populations, and Lucene documents it. IndexReader.docFreq: "This method does not take into account deleted documents that have not yet been merged away" (IndexReader.java:402-404). TFIDFSimilarity.idfExplain: FieldStats#docCount() "is used instead of IndexReader#numDocs() because also TermStats#docFreq() is used, and when the latter is inaccurate, so is FieldStats#docCount(), and in the same direction" (TFIDFSimilarity.java:360-363). MoreLikeThis takes its idf corpus size from ir.getDocCount(fieldName) (MoreLikeThis.java:634).
  • Queries must not produce negative scores. MIGRATE.md:1368-1375, "Scorer must produce positive scores (LUCENE-7996)": "If you have custom query implementations, you should make sure their score formula may never produce negative scores. As a side-effect of this change, negative boosts are now rejected".
  • Fixed as a bug before, in the highlighter. LUCENE-1355 (#2432, 2008): "An index with a few documents and many deletes can report a lower total docs than docFreq for a term - total docs will account for deletes while docFreq will not - this causes the idf to be negative" (fixed by clamping docFreq). LUCENE-4289 (#5357, 2012): "highlighter uses numDocs instead of maxDoc" (fixed by reader.numDocs() to reader.maxDoc(), which also removed the clamp). QueryTermExtractor.java:74 and WeightedSpanTermExtractor.java:549 use reader.maxDoc() today. NearestFuzzyQuery (2017) copied the older pairing from the sandbox FuzzyLikeThisQuery.
  • No precondition anywhere. No javadoc in the classification module restricts the classifier or the query to merged or deletion-free indexes, and Classifier.assignClass promises to "Assign a class (with score) to the given text String" (Classifier.java:31). The shared test base starts from deleteAll() (ClassificationTestBase.java:243, which keeps no deleted documents), then only adds documents, commits and calls forceMerge(1) (:231-232, :259-260), so the case was never exercised.
  • History. The LUCENE-7838 discussion (#8889) is about removing a sandbox dependency and never mentions idf or deletes; the LUCENE-7996 commit did not touch the classification module, so nothing shows its new rule was checked against this formula. Before NearestFuzzyQuery, the first form of KNearestFuzzyClassifier (bd9e32d3583, 2017-05-18) used the sandbox FuzzyLikeThisQuery with the same pairing; no release shipped it (7.0.0 already has NearestFuzzyQuery).
The devil's-advocate check210 words

The strongest case for "works as designed": the classifier is @lucene.experimental; the docFreq skew is documented behaviour; the formula was carried over from FuzzyLikeThisQuery; the trigger needs more than 63% of the index deleted, which the default merge policy avoided in every test; and BoostQuery rejects negative boosts on purpose. A maintainer could also prefer a clamp, or reject a change of ranking on indexes with deletes.

Why it fails: the tag's text, "This API is experimental and might change in incompatible ways in the next release", warns about API changes, not about exceptions on valid indexes, and NearestFuzzyQuery is not tagged; the documented docFreq skew is exactly why numDocs is the wrong partner (core similarities and MoreLikeThis avoid it, and the highlighter was fixed for it twice); the commit made a trimmed copy, and neither it nor its discussion mentions idf or deletes, so nothing shows the mismatch was chosen; rarity lowers the impact but does not make supported merge policies illegal; and the BoostQuery check is correct: the defect is the formula that feeds it a negative number, which MIGRATE.md forbids. The earlier cross-model reviews rated the intended-behaviour case weak, the fact check's cross-model reviewer moderate, without doubting the bug. Verdict: not intended; a small bug.

Suggested fix

Recommended A: use reader.maxDoc()

Take the corpus size from reader.maxDoc() instead of reader.numDocs() at NearestFuzzyQuery.java:140 (one line). docFreq ≤ maxDoc, so the idf stays ≥ 1; indexes without deletions score exactly as before. With it the repro and the classification module's tests (44 tests: the repro and 43 upstream, 1 @Monster skipped) pass in gradle, with assertions on and off.

// docFreq also counts deleted documents that are not merged away yet, so take the corpus
// size from the same population: then docFreq <= corpusNumDocs and idf stays >= 1
int corpusNumDocs = reader.maxDoc(); // was: reader.numDocs()
All options and trade-offs3 options · 496 words

Options A and B were applied in a scratch worktree of 645d4fe14a20 and run with gradle (the worktree was removed after the runs); option C was not built. Any fix changes the idf, and so the order of neighbours, on indexes with deletes. The sandbox FuzzyLikeThisQuery has the same numDocs() line (FuzzyLikeThisQuery.java:204, read, not executed), and open draft PR #16847 edits the same lines (int to long) without touching this bug, so a fix PR may need a rebase.

A: use reader.maxDoc() (validated by the checks) (recommended)

One line at NearestFuzzyQuery.java:140; the comment is optional. The same change LUCENE-4289 made in the highlighter.

// docFreq also counts deleted documents that are not merged away yet, so take the corpus
// size from the same population: then docFreq <= corpusNumDocs and idf stays >= 1
int corpusNumDocs = reader.maxDoc(); // was: reader.numDocs()

Trade-offs

  • docFreq ≤ maxDoc, so idf = ln((maxDoc + 1) / (docFreq + 1)) + 1 ≥ 1, for the variant-average fallback too. On an index without deletions numDocs() == maxDoc(), so nothing changes there.
  • On an index with deletions the idf values, and so the neighbours' order, change, in line with core scoring statistics, which also count deleted documents.

What the checks validated

  • Gradle, stock 645d4fe14a20: the repro fails with assertions on and off.
  • Gradle with the fix (seed DEADBEEF): the repro and every test of the classification module, 44 tests (the repro plus 43 upstream, including TestKNearestFuzzyClassifier 3), 1 skipped (Test20NewsgroupsClassification, @Monster), 0 failures, with assertions on and off. checkGoogleJavaFormat accepts the change.
  • Earlier hunt checks: the finder's, a verifier's, the adjudication's and the minimization's shadowed fix checks pass, and all 7 checks of the tiebreak program pass with it on main.

B: use the field's Terms.getDocCount() (tested configuration only) (validated by the checks)

int corpusNumDocs = terms.getDocCount(); (terms is the field's Terms from MultiTerms.getTerms(reader, field) at NearestFuzzyQuery.java:133: the leaf's own Terms for one segment, a MultiTerms for several), as TFIDFSimilarity and MoreLikeThis do.

int corpusNumDocs = terms.getDocCount(); // was: reader.numDocs()

Trade-offs

  • A term's docFreq in the field is at most the number of documents with the field (both count deleted documents), so the idf stays ≥ 1.
  • Also changes the idf on deletion-free indexes whenever the field is sparse (docCount < maxDoc), so it moves more rankings than A.

What the checks validated

Same scratch worktree, gradle, seed DEADBEEF, assertions on: the repro and the classification module, 44 tests, 1 skipped, 0 failures, with the codecs those runs drew. MoreLikeThis keeps a fallback to numDocs() for getDocCount() == -1 (MoreLikeThis.java:635-637) that B lacks; at 645d4fe14a20 the Terms.getDocCount() javadoc documents no -1 case (Terms.java:117-121).

C: clamp docFreq to numDocs (not built)

The LUCENE-1355 style: df = Math.min(df, corpusNumDocs) before computing the idf.

Trade-offs

Never negative, but it keeps mixing the two populations for every term below the clamp; LUCENE-4289 later replaced exactly this clamp with maxDoc() in the highlighter.

Downstream note

No downstream caller of these classes was found; an application that exposes KNearestFuzzyClassifier over an index that keeps deletes would see requests with common terms fail, and can catch the IllegalArgumentException until a fix ships.

How it was found

AI tools fuzzed every classifier in the module with Jazzer (coverage-guided) against simple invariants, on small random training sets with deletions. After 184,134 runs one input threw this exception; the thrown value, ln(2/7) + 1, pointed to docFreq 6 against 1 live document, and a public-API JUnit confirmed it on the stock jar.

The method in full135 words

Method: non-formal baseline (coverage-guided fuzzing).

  • The hunt unit for the classification module ran Jazzer 0.30.0 on a property harness that builds random small training sets (with deletions under NoMergePolicy) and checks invariants for every classifier: 2 runs of 20 minutes, 427,743 executions. Its one finding, after 184,134 runs, was IllegalArgumentException: boost must be a positive float, got -0.25276297 from NearestFuzzyQuery.rewrite via KNearestFuzzyClassifier.getClasses. The seeded random driver (10,000 cases) had not hit it.
  • The value ln(2/7) + 1 gave away docFreq 6 against numDocs 1, and the finder wrote a public-API JUnit on the stock jar. Independent verifiers then reproduced it with their own tests and other triggers, found the exact threshold (docFreq 5 for 1 live document) and that the exception dates from 8.0.0; the case was later reduced to the one-method test.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, 125 logged queries): GitHub issues and PRs incl. the JIRA archive, four mailing lists, downstream projects, the web and the changed files of every open PR; re-checked 2026-10-30. Nearest: #10608 (same message, different cause) and the highlighter fixes LUCENE-1355 and LUCENE-4289 (same defect class, other code).

Searches4 searches

No report of this bug was found, and upstream main, branch_11x and branch_10_6 still have the line. #10608 / LUCENE-9568 (open) shares the exception text, but there a negative FuzzyTermsEnum edit boost reaches a highlighting rewrite; NearestFuzzyQuery squares that factor, so it cannot cause this. LUCENE-1355 (#2432), LUCENE-4289 (#5357) and the MoreLikeThis changes LUCENE-8333 / LUCENE-8984 fixed the same kind of count mismatch in other code: precedent, not reports. Open draft PR #16847 edits the same lines without touching the bug.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, commits (apache/lucene incl. the migrated JIRA archive; downstream repositories)2026-10-26an automated pass (18 issue/PR, 3 commit, 3 downstream queries) plus 25 manual searches: both class names, the exception text, negative idf, numDocs/maxDoc idf, FuzzyLikeThis, downstream projects (Elasticsearch, OpenSearch, Solr, Lucene.NET)no report; closest #10608 (same message, different cause), #8889 (introduced the class), #9044 (the positive-score rule)
Mailing lists, JIRA, web2026-10-2632 automated and 9 manual list queries over dev@, java-user@, issues@ and commits@; 8 automated and 13 manual JIRA queries (LUCENE and SOLR, all 47 classification issues); 12 web queriesnothing about this bug; one Stack Overflow page on the #10608 highlighter case could be read by title only
Open PRs and upstream history2026-10-26module labels for PRs since 2025-02 and the changed-file lists of the 163 older open PRs; git log -S on the full historyonly draft PR #16847 touches the file (int to long, keeps numDocs()); no fix
Re-check for this page2026-10-30the line on upstream main, branch_11x and branch_10_6; GitHub searches for both class names, the exception text and negative idf with deletesunchanged; only #8889 and #10608 come up

History

Introduced by LUCENE-7838 (92e460389dc9, 2017, first released in 7.0.0), which copied the numDocs/docFreq pairing from the sandbox FuzzyLikeThisQuery. It throws since 8.0.0, when LUCENE-7996 (a8a63464e7da) made BoostQuery reject negative boosts; 7.x returns negative scores silently. 8.0.0 to 10.6.0 and main are affected (7 releases and main run).

Versions tested10 rows

92e460389dc9 "LUCENE-7838 - removed dep from sandbox, created a minimal FLT version specific for knn classification" (tteofili, 2017-06-29, committed directly, issue #8889; CHANGES 7.0.0 New Features) wrote every defective line; git blame attributes NearestFuzzyQuery.java:140, :155, :187 and :248 to it; :199 and :255-256 show the later reformat 7f8b7ffbcad (LUCENE-9570, same expressions). Before it, bd9e32d3583 (2017-05-18, same issue) had built KNearestFuzzyClassifier on the sandbox FuzzyLikeThisQuery with the same pairing; no release shipped that form. a8a63464e7da "LUCENE-7996: Queries are now required to produce positive scores." (jpountz, 2017-12-06, #9044; CHANGES 8.0.0) added the BoostQuery check; that change is correct and turned the wrong idf into an exception. Swapping only that check confirms the boundary: added to 7.7.3, 7.7.3 throws the same value; removed from 8.0.0, the test passes with assertions off and a negative hit score.

The 10.6.0 row and the gradle runs on main use the minimal test; the older rows use the finder's tests (1 live and 5 deleted copies, thrown value -0.25276297 = ln(2/7) + 1). All on OpenJDK 25.0.3, Linux aarch64; release jars SHA-1 checked against Maven Central. The releases not run have the same lines and the check (read at every tag, not executed).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (unreleased 11.0) affectedthrows, -ea and -da (gradle and standalone)
main ea57360912ed, a9779ef3c193 affectedthrows (ea57360912ed with -ea and -da)
10.6.0 affectedthrows, -ea and -da (minimal test and the 7-check program)
10.5.2 affectedthrows, -ea and -da
10.0.0, 9.0.0 affectedthrows
9.12.3, 8.11.4 affectedthrows, -ea and -da
8.0.0 affectedthrows, -ea and -da (first release with the BoostQuery check)
7.0.0, 7.7.3 silentno exception: negative boost -0.25276297 and negative hit score -0.3893675 (same root cause)
7.7.3 + only the LUCENE-7996 check; 8.0.0 − only that check swap7.7.3 then throws the same value; 8.0.0 then passes with -da and a negative score
6.6.6 n/adoes not compile: neither class exists

Independent checks

The finder, an independent reproducer, a devil's advocate, an adjudication, a credibility audit, a tiebreak check, this page's gradle runs and its fact check all reproduce it. Cross-model reviews (OpenAI GPT-5.6) could not execute the repro: some said real; the others said not real on procedure alone, agreeing with the root cause.

Checks and verdicts512 words

Every executing check ran on the stock jars and voted real. The "not real" verdicts are procedural: those reviews could not execute (one ran read-only, the other could run no command) and they needed an executed repro of their own; both agreed with the root cause, called the inputs legal and rated the intended-behaviour case weak.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (finder) candidateJazzer finding (1 in 427,743 runs); public-API JUnit fails 2 of 2 on main (-ea, -da), 20 of 20 iterated, 10.5.2 and 9.12.3; the maxDoc fix passes it, with 42 upstream classification tests.
Cross-model review, refute lens (OpenAI GPT-5.6, 2026-10-26) not real, low (procedural)Read-only, could run nothing; root cause agreed, inputs in contract, intended-behaviour case weak; a separate re-run fails 2 of 2.
Independent reproducer realOwn 4-test JUnit (update sweep, soft-delete retention + forceMerge(1), classifier, control): 3 of 4 fail on main (-ea, -da), 10.5.2 and 9.12.3; thrown values equal the predicted idf; corrected the version (exception since 8.0.0) and the threshold (docFreq 5).
Cross-model review, intended lens (OpenAI GPT-5.6) real, medium; intended case weakRead-only review; separate controls fail 2 of 5 as predicted and pass 5 of 5 with the fix.
Intended-behaviour devil's advocate realOwn JUnit with LogByteSizeMergePolicy and LogDocMergePolicy at their defaults fails on main (-ea, -da) and 10.5.2; the default TieredMergePolicy did not reach it; found LUCENE-1355 and LUCENE-4289.
Adjudication confirmed, high; severity LowRe-ran the independent reproduction's test with a new seed: 3 of 4 fail with -ea and -da, 4 of 4 pass with the fix.
Credibility audit keepRe-ran the finder's test with -da and a new seed: 2 of 2 fail.
Second cross-model refute review (OpenAI GPT-5.6, 2026-10-30) not real, medium (procedural)It could run no command, so it ran nothing; root cause agreed, inputs in contract, intended case weak. A separate re-run of the minimal test fails 1 of 1 with -ea and -da.
Tiebreak check (2026-10-30) reproducedThe minimal test fails on main and 10.6.0 (-ea, -da); a stand-alone 7-check program (query, classifier, misspelled input, commit-point reader, soft-delete retention + forceMerge(1), LogDocMergePolicy) fails 7 of 7 with 3 controls passing on both; everything passes with the fix.
This page's runs reproduced; fixes A and B validatedGradle on 645d4fe14a20: the minimal test fails with -ea and -da; with fix A it passes with the whole classification module (-ea and -da), with fix B likewise (-ea).
Fact check of this page (2026-10-30) real, no blocking errorThe page's exact gradle command fails the same way (-ea, -da); fix A with the whole classification module, new seed, -da: 44 tests, 1 skipped, 0 failures; the minimal test fails on main and 10.6.0 and passes with fix A or B; precision fixes applied.
Cross-model fact check (OpenAI GPT-5.6, read-only, 2026-10-30) real; intended case moderateRead the code and the recorded runs, executed no Java; confirmed the root cause, the numbers and the history, and asked for wording corrections, applied here.

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

Finding record

LFV-393, snapshot of 2026-11-06

Registry title
NearestFuzzyQuery (used by KNearestFuzzyClassifier) computes idf from reader.numDocs() (live documents) and reader.docFreq() (which still counts deleted documents), so when deleted documents that are not yet merged away make up most of the index the idf is negative and rewrite throws 'boost must be a positive float' (released since 8.0.0)
Area
classification / NearestFuzzyQuery (KNearestFuzzyClassifier)
Priority
Low, rank 630: The experimental KNearestFuzzyClassifier/NearestFuzzyQuery throws IllegalArgumentException only when more than about 63% of the index is unmerged deleted documents (non-default merge setups), so it sits with the other niche queries that throw instead of answering.
Impact class
crash
Impact note
Applications that use the opt-in lucene/classification module's KNearestFuzzyClassifier (@lucene.experimental) or NearestFuzzyQuery directly are hit when more than about 63% (1 - 1/e) of the whole index is deleted documents that are still present and the input has a term whose docFreq, counting deleted copies, exceeds e*(numDocs+1) - 1: every such request throws IllegalArgumentException instead of returning hits or a class. That index state was reached with NoMergePolicy, the stock LogByteSizeMergePolicy and LogDocMergePolicy before a merge, SoftDeletesRetentionMergePolicy (even after forceMerge(1)) and a reader view that hides documents through live docs, but never with the default TieredMergePolicy in any test. No scale is needed (6 documents suffice), and every release from 8.0.0 to 10.6.0 is affected (10.6.0 executed on its release jars).
Summary
You index 6 documents containing 'apple' (class fruit), delete 5 of them under NoMergePolicy (or soft-delete them and keep them with SoftDeletesRetentionMergePolicy), and then search NearestFuzzyQuery('apple') or call KNearestFuzzyClassifier.assignClass('apple'), expecting the one live document and the class fruit. Instead both throw IllegalArgumentException 'boost must be a positive float, got -0.25276297'. NearestFuzzyQuery computes idf = ln((numDocs+1)/(docFreq+1)) + 1 from reader.numDocs(), which counts only live documents, and reader.docFreq(), which still counts the 5 deleted copies, so idf = ln(2/7) + 1 < 0, and since LUCENE-7996 (8.0.0) BoostQuery rejects the resulting negative boost.
Introduced
92e460389dc9b0af83c445cb029e3a51799a37dc 'LUCENE-7838 - removed dep from sandbox, created a minimal FLT version specific for knn classification' (by GitHub tteofili; issue apache/lucene#8889; committed directly to master, no pull request) created NearestFuzzyQuery and wrote every defective line: corpusNumDocs = reader.numDocs() (:140 on main), df = reader.docFreq(startTerm) (:155), score = edit^2 * idf(df, corpusNumDocs) (:187), the idf formula (:198-199; line 199 later reformatted by 7f8b7ffbcad, LUCENE-9570, same expression) and new BoostQuery(tq, st.score) (:248, :256) [read: git blame on main; the pairing was copied from the sandbox FuzzyLikeThisQuery, where it dates from 87768c51c632, 2006]. The exception comes from a later, correct change, not from the defect: a8a63464e7da63b3dbc884634fd0e00b3f0c140b 'LUCENE-7996: Queries are now required to produce positive scores' (by GitHub jpountz; apache/lucene#9044) added the BoostQuery positive-boost check [exec: adding only that 3-line check to 7.7.3's BoostQuery makes 7.7.3 throw the same exception and value; removing only it from 8.0.0 makes the test pass under -da with a negative hit score] (2017-06-29 (92e460389dc9, the defect); 2017-12-06 (a8a63464e7da, LUCENE-7996, which turns it into an exception))
First affected version
8.0.0 [exec] for the IllegalArgumentException (the first release with the LUCENE-7996 BoostQuery check). The defective idf is in every release since 7.0.0, the first release with NearestFuzzyQuery and KNearestFuzzyClassifier (6.6.6: API absent, compile error [exec]); on 7.0.0 and 7.7.3 [exec] the same input silently gives a negative boost (-0.25276297) and a negative hit score (-0.3893675) instead of throwing
Affected versions
Exception: every release from 8.0.0 to 10.6.0 and main ea57360912ed (10.6.0 [exec] on its release jars, -ea and -da, in the bug doc's re-check). [exec] the follow-up history matrix (same seed, same thrown value -0.25276297): 8.0.0 (-ea and -da), 8.11.4 (-ea and -da), 9.0.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da) fail; the finder's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2 and 9.12.3 (9.x accessor variant repro/src-9x); the independent reproduction's JUnit fails 3 of 4 (its control passes) on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's LogByteSizeMergePolicy/LogDocMergePolicy JUnit fails 2 of 2 on main (-ea and -da) and 10.5.2. [read] The NearestFuzzyQuery lines are identical at every release tag from 7.0.0 to 10.5.2 apart from the 9.0.0 reformat, and BoostQuery has the check at every tag from 8.0.0; 8.8.2-8.11.3 are not tagged in the history clone (bracketed by the 8.0.0 and 8.11.4 runs). Same root cause, different symptom: 7.0.0-7.7.3 return a negative boost and score without an exception [exec 7.0.0, 7.7.3]. Not affected: 6.x and earlier (API absent). Not fixed upstream: the file's last upstream change is a7704f5242d (2025-06-18) and upstream main still has the numDocs line (the intended-behaviour check, the prior-report record, 2026-10-26)
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal baseline: fuzzing/PBT
Upstream and prior reports
none found (2026-10-26, read-only, the prior-report record verdict new: 125 logged queries, 0 errors: scripted prior-report searches with 18 GitHub issue and PR, 3 commit, 3 downstream, 32 mailing-list (dev@, java-user@, issues@, commits@), 8 JIRA-archive and 6 web queries; 25 manual GitHub searches, 13 manual JIRA (LUCENE and SOLR), 9 manual list, 6 manual web and 2 git queries; every open apache/lucene PR checked for changes to NearestFuzzyQuery.java or FuzzyLikeThisQuery.java (path labels for PRs since 2025-02, changed-file lists for the 163 older open PRs); the finder's and the intended-behaviour check's earlier searches agree); 23 hits assessed, none the same bug. Nearest: #10608 / LUCENE-9568 (open: the same exception text from a negative FuzzyTermsEnum edit boost in highlighting; different code, and in NearestFuzzyQuery that factor is squared), open PR #16847 (long global doc ids: edits NearestFuzzyQuery.java:140, :155, :198 from int to long but keeps reader.numDocs(); neither fixes nor mentions the bug, and a fix would touch the same lines), #8889 / LUCENE-7838 (the introducing issue; no mention of deletes or idf), #9044 / LUCENE-7996 (the positive-score rule); same defect class fixed only in other code: #2432 / LUCENE-1355 and #5357 / LUCENE-4289 (highlighter idf negative with deletes, fixed there by numDocs -> maxDoc), LUCENE-8333 and LUCENE-8984 (MoreLikeThis maxDoc / docCount). Not fixed upstream (file last changed 2025-06-18).
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28; main 716d6f216f52, branch_11x 603af8703753 and branch_10_6 7e5aec4370e0 as fetched on 2026-10-29, and live upstream main f6e64aa0db37 on 2026-10-30, have the same line): the minimal repro with gradle in a scratch worktree, stock with assertions on and off, and with fix A (assertions on and off) and fix B together with every test of the classification module (the worktree was removed after the runs); a standalone runner on main 645d4fe14a20, a9779ef3c193 and ea57360912ed and on the published jars of 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.0.0, 8.11.4, 8.0.0, 7.7.3, 7.0.0 and 6.6.6; the independent tests of the finder, verifiers, an adjudication, a credibility audit and a tiebreak check. JDK OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/classification/src/java/org/apache/lucene/classification/utils/NearestFuzzyQuery.java:48 (class javadoc 'Simplification of FuzzyLikeThisQuery, to be used in the context of KNN classification'), :140 (corpusNumDocs = reader.numDocs()), :155 (df = reader.docFreq(startTerm)), :157-161 (int totalVariantDocFreqs), :187 (score = edit^2 * idf), :198-200 (idf = log((docCount+1)/(docFreq+1)) + 1), :248 and :256 (new BoostQuery(tq, st.score))
  • lucene/classification/src/java/org/apache/lucene/classification/KNearestFuzzyClassifier.java:46-48 (javadoc 'based on NearestFuzzyQuery', @lucene.experimental), :137-147 (knnSearch builds and searches the NearestFuzzyQuery)
  • lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:39-42 (negative or non-finite boost -> IllegalArgumentException 'boost must be a positive float')
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:401-408 (docFreq 'does not take into account deleted documents that have not yet been merged away')
  • lucene/sandbox/src/java/org/apache/lucene/sandbox/queries/FuzzyLikeThisQuery.java:204 (same corpusNumDocs = reader.numDocs() line; sandbox, out of scope, not executed)
The finding in 30 seconds
  • lucene/classification/src/java/org/apache/lucene/classification/utils/NearestFuzzyQuery.java:48 (at 645d4fe14a20) (class javadoc), :58 (MAX_NUM_TERMS = 300), :140 (numDocs), :155 (docFreq), :173-178 (variant average), :187 (score), :198-200 (idf), :217-264 (rewrite; BoostQuery at :248, :256)
  • lucene/classification/src/java/org/apache/lucene/classification/KNearestFuzzyClassifier.java:45-50 (at 645d4fe14a20) (javadoc, @lucene.experimental), :105-133 (assignClass, getClasses), :135-148 (knnSearch)
  • lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:38-44; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexReader.java:292-305 (numDocs, maxDoc), :401-408 (docFreq javadoc)
  • lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:250-257 (edit boost 1 - ed/minTermLength); BoostQuery.java:40 (Float.compare(boost, 0f) < 0 also rejects -0.0)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:40-44 (at 645d4fe14a20) (purpose; @lucene.experimental); lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:82, :120-124, :138-141 (deletesPctAllowed: default 20, at most 50)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:401-408; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/TFIDFSimilarity.java:350-370; lucene/queries/src/java/org/apache/lucene/queries/mlt/MoreLikeThis.java:634-660
  • lucene/MIGRATE.md:1368-1375 (at 645d4fe14a20)
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/QueryTermExtractor.java:74; (at 645d4fe14a20) WeightedSpanTermExtractor.java:549
  • lucene/classification/src/java/org/apache/lucene/classification/Classifier.java:31; (at 645d4fe14a20) lucene/classification/src/test/org/apache/lucene/classification/ClassificationTestBase.java:231-232, :243, :259-260
  • Lucene main 645d4fe14a20 build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:502 (the @lucene.experimental tag text)
Suggested fix
  • lucene/sandbox/src/java/org/apache/lucene/sandbox/queries/FuzzyLikeThisQuery.java:204 (at 645d4fe14a20)
  • lucene/queries/src/java/org/apache/lucene/queries/mlt/MoreLikeThis.java:634-637; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/Terms.java:117-121; lucene/core/src/java/org/apache/lucene/index/MultiTerms.java:68-93
History
  • lucene/CHANGES.txt:6636 (at 645d4fe14a20) (LUCENE-7996), :8020 (LUCENE-7838)

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 page: a summary card (PNG)(what the user does, the two counts, the idf inside rewrite(), the arithmetic, the threshold, where it happens on main and 10.6.0, and a fix sketch), built from facts checked against the code, the minimal repro and executed probe output.

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.