All findings · Lucene finding · LFV-380 · snbdoc-zero-token-field-nan

SimpleNaiveBayesDocumentClassifier returns NaN for every class when one field value has no tokens

SimpleNaiveBayesDocumentClassifier averages each field value's log-likelihood over its tokens, so a value with no tokens (empty, stop words only, punctuation only) gives 0.0 / 0 = NaN for every class and assignClass returns null. The division is in every release tag checked since 6.0.0 (83 of 83).

Summary

Priority
Medium, rank 187The same experimental classifier silently returns NaN scores and a null class whenever one field value analyzes to no tokens, which is ordinary data but needs an empty value, so it follows the two broader classification rows.
Impact
silent wrong results
Since
6.0.0 (LUCENE-6631, 2015)introduced 2015-10-20 · over 11 years
Affected
6.0.0 to 10.5.2 and main
Status
documented
Found by
SMT inventory of the classifier's score divisions, confirmed by JUnit

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

SimpleNaiveBayesDocumentClassifier (module lucene/classification) classifies a whole Document. For every class it adds, per value of each configured text field, a log prior plus the value's log-likelihood averaged over its tokens, then normalises the class scores into probabilities that sum to 1.

Summary card. Title: SimpleNaiveBayesDocumentClassifier: a field value with no tokens makes every class score NaN and assignClass return null. Example: 3 training documents, StandardAnalyzer, assignClass for the document {title: "apple", body: ""}. Step 1: the title gives the token apple, the empty body gives no tokens. Step 2: each value scores log prior plus the sum of ln P(w | class) divided by its token count; for the body, line 222 computes 0.0 / 0 = NaN; the title alone gives fruit -1.322, vehicle -2.485. Step 3: every value's score is added to every class, so both become NaN, and the normalisation keeps NaN. Step 4: assignClass keeps a class only if its score is greater than the maximum so far; NaN > -Double.MAX_VALUE is false, so it returns null; getClasses keeps term order, fruit first. Expected: fruit 0.762, vehicle 0.238, assignClass fruit. Actual: fruit NaN, vehicle NaN, assignClass null, no exception. Fix: skip a value with no tokens, fruit 0.762 again. Notes: affected from 6.0.0 to 10.5.2 and main; an absent field, empty values in training documents and the text classifier are not affected. The assignClass javadoc, the 0-1 range comment and the upstream test assertions are quoted at the bottom.
Summary card of the example {title: "apple", body: ""}: the title value scores -1.322 for fruit and -2.485 for vehicle, the empty body adds 0.0 / 0 = NaN to both, the normalisation keeps NaN and assignClass returns null, where fruit 0.762 is expected. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Train SimpleNaiveBayesDocumentClassifier on three documents with text fields title and body (stock EnglishAnalyzer): apple / "sweet fruit" is fruit, pear / "green fruit" is fruit, car / "four wheels" is vehicle. Classify title "apple": fruit 0.762, vehicle 0.238.
  2. Add a body that carries no words: "", "To be or not to be" (all stop words) or "!!!". You expect fruit again, with probabilities that sum to 1.
  3. You get fruit = NaN and vehicle = NaN, and assignClass returns null. No exception, with assertions on or off; one empty body value next to five "sweet fruit" values does the same.
  4. Why: each value adds its log prior plus the sum of log P(word | class) divided by its token count, here 0.0 / 0 = NaN. It is added to every class, the normalisation spreads it, and NaN > max is never true.
  5. getClasses(doc, 1) then returns the first class name in term order: title "car" alone gives vehicle 0.556, but with an empty body it gives fruit (NaN). Skipping values with no tokens fixes it.

Who is affected

Users of the opt-in, @lucene.experimental lucene/classification module who classify documents with SimpleNaiveBayesDocumentClassifier. One value in any configured text field that analyzes to no tokens is enough: an empty value, stop words only, or punctuation only. No setting or scale is needed; empty values in training documents do not trigger it.

Mechanism, terms and numbers692 words

SimpleNaiveBayesDocumentClassifier scores each class by adding, for every value of every configured text field of the input Document, a log prior plus the value's log-likelihood, which is the sum of log P(w|c) over the value's tokens divided by the number of tokens. A value with no tokens, such as an empty TextField or one that the analyzer reduces to nothing (stop words only, punctuation only), gives 0.0 / 0 = NaN. That NaN is added to every class, the log-sum-exp normalisation turns every probability into NaN, assignClass returns null because NaN > maxscore is never true, and getClasses lists the classes in index term order. On three training documents, title "apple" gives fruit 0.762, and the same title with an empty, stop-word-only or punctuation-only body gives NaN for both classes and no class. It is silent, with assertions on or off, needs no setting, and the division is in all 83 release tags checked from 6.0.0 to 10.5.2 and in main; skipping values with no tokens fixes it.

Terms

  • Document classifier: new SimpleNaiveBayesDocumentClassifier(reader, query, classFieldName, field2analyzer, textFieldNames...) learns from the labelled documents of an index (the class is a term of classFieldName) and classifies an unseen Document: assignClass(doc) returns the best class with its score, getClasses(doc) all classes sorted by score.
  • Per-value score: the input document's values are analyzed with the field's analyzer (SimpleNaiveBayesDocumentClassifier.java:148-172). For every class and every value, the class score gains log prior + log-likelihood * boost (:121-131).
  • Log-likelihood: the sum of log P(w|c) over the value's tokens, divided by the number of tokens (:201-223). The original comment explains the division: "this is normalized because if not, long text fields will always be more important than short fields" (commit 71cea88773e, 2015).
  • Normalisation: a log-sum-exp turns the class scores into probabilities, "the values transforms to a 0-1 range" (SimpleNaiveBayesClassifier.java:292-318, comment at :294).

The bug

  1. An empty value, or one whose tokens are all removed by the analyzer, gives a token array of length 0 (an absent field gives an empty list, so the scoring loop never runs for it).
  2. calculateLogLikelihood then skips its loop and returns result / (tokenizedText.length) with result = 0.0 and length 0 (:222): 0.0 / 0 = NaN.
  3. assignNormClasses adds prior + NaN * boost = NaN to every class (:124-130).
  4. The normalisation takes the top score as smax and sums exp(score - smax): one NaN input makes every output NaN.
  5. assignClass keeps a result only if c.score() > maxscore, starting from -Double.MAX_VALUE (:80-87); with NaN this is never true, so it returns null.
  6. getClasses sorts with Double.compare (ClassificationResult.java:31-33), which treats all NaN values as equal, so the list stays in class-term order and getClasses(doc, n) returns the first n class names (SimpleNaiveBayesDocumentClassifier.java:98-103).

The numbers

Index: {apple, "sweet fruit", fruit}, {pear, "green fruit", fruit}, {car, "four wheels", vehicle}, EnglishAnalyzer (it stems apple to appl). Title field: 3 postings over 3 documents, so 1.0 unique term per document.

classword probability of applloglog priortitle valueempty body value
fruit(1 + 1) / (1.0 x 2 + 3) = 0.4-0.916log(2/3) = -0.405-1.322-0.405 + 0.0 / 0 = NaN
vehicle(0 + 1) / (1.0 x 1 + 3) = 0.25-1.386log(1/3) = -1.099-2.485-1.099 + 0.0 / 0 = NaN

Without the body: fruit 0.7619, vehicle 0.2381. With the empty body: both NaN, assignClass = null. Executed cases (stock main):

inputgetClassesassignClass
title "apple", no bodyfruit 0.7619, vehicle 0.2381fruit
title "apple", body "sweet"fruit 0.9014, vehicle 0.0986fruit
title "apple", body "", "To be or not to be" or "!!!"fruit NaN, vehicle NaNnull
title "apple", five "sweet fruit" bodies and one ""fruit NaN, vehicle NaNnull
title "car", no bodyvehicle 0.5556, fruit 0.4444vehicle
title "car", body ""fruit NaN, vehicle NaN (getClasses(doc, 1) = fruit)null
text SimpleNaiveBayesClassifier, input ""fruit 0.6667, vehicle 0.3333fruit
KNearestNeighborDocumentClassifier, title "apple", body ""fruit 0.5639
Exposure and likelihood271 words
  • Who is exposed. Applications that classify documents with SimpleNaiveBayesDocumentClassifier ("A simplistic Lucene based NaiveBayes classifier", @lucene.experimental). No other Lucene module uses the class.
  • What triggers it. One value of any configured text field in the document being classified that analyzes to no tokens: an empty description, a title made only of stop words ("To be or not to be" under EnglishAnalyzer), a value of punctuation or symbols. Three training documents and stock analyzers show it; one such value among many non-empty ones is enough.
  • What breaks. Every class probability is NaN and assignClass returns null, so a caller that reads assignClass(doc).assignedClass() throws a NullPointerException later, and a caller that takes getClasses(doc, 1) gets the first class name in index term order whatever the document says. Nothing fails inside Lucene, with assertions on or off.
  • Not affected. Classification after empty values in training documents (they enter only through index statistics; with 50 such training documents in the index a normal document still got finite scores and the right class), later calls on the same instance, the index, and the other classifiers on the same input. A different case, not this bug: if every training value of a configured text field is empty, so the field has no indexed terms, classifying a document with tokens in that field throws a NullPointerException (getTextTermFreqForClass, :233-236); the fact check reproduced it, and it has not been checked for prior reports.
  • How likely. Such values are ordinary in real data and trigger it every time, but the module has few users, and no report of it was found since 6.0.0 (2016).

Minimal repro

The test shows that SimpleNaiveBayesDocumentClassifier returns no class when one field value of the document has no tokens ({title: 'apple', body: ''}): the value's log-likelihood is 0.0 / 0 = NaN, which makes every class score NaN.

TestSimpleNaiveBayesDocumentClassifierEmptyField.java (JUnit test)71 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.document;

import java.util.List;
import java.util.Map;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.classification.ClassificationResult;
import org.apache.lucene.document.Document;
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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestSimpleNaiveBayesDocumentClassifierEmptyField extends LuceneTestCase {

  public void testEmptyFieldValue() throws Exception {
    Analyzer analyzer = new StandardAnalyzer();
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
        w.addDocument(
            List.of(
                new TextField("title", "apple", Field.Store.NO),
                new TextField("body", "sweet fruit", Field.Store.NO),
                new StringField("cls", "fruit", Field.Store.NO)));
        w.addDocument(
            List.of(
                new TextField("title", "car", Field.Store.NO),
                new TextField("body", "four wheels", Field.Store.NO),
                new StringField("cls", "vehicle", Field.Store.NO)));
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        SimpleNaiveBayesDocumentClassifier classifier =
            new SimpleNaiveBayesDocumentClassifier(
                reader, null, "cls", Map.of("title", analyzer, "body", analyzer), "title", "body");
        Document doc = new Document();
        doc.add(new TextField("title", "apple", Field.Store.NO));
        doc.add(new TextField("body", "", Field.Store.NO));
        ClassificationResult<BytesRef> result = classifier.assignClass(doc);
        // expected a class with a finite score; actual null, because every class score is NaN
        assertTrue(
            "expected a class with a finite score; assignClass: "
                + result
                + ", getClasses: "
                + classifier.getClasses(doc),
            result != null && Double.isFinite(result.score()));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-380/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/classification/build/libs/lucene-classification-$V.jar
CP=$CP:$LUCENE/lucene/grouping/build/libs/lucene-grouping-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.classification.document.TestSimpleNaiveBayesDocumentClassifierEmptyField
Expected vs actual
Expected a non-null class with a finite score; actual assignClass returns null and getClasses gives fruit=NaN and vehicle=NaN, with -ea and -da.

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

More files, environment, full output53 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected a class with a finite score; assignClass: null, getClasses: [ClassificationResult[assignedClass=[66 72 75 69 74], score=NaN], ClassificationResult[assignedClass=[76 65 68 69 63 6c 65], score=NaN]]
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.classification.document.TestSimpleNaiveBayesDocumentClassifierEmptyField.testEmptyFieldValue(TestSimpleNaiveBayesDocumentClassifierEmptyField.java:62)

Why it looks unintended

  • assignClass returns "a ClassificationResult holding assigned class ... and score", with no null case; scores are normalised "to a 0-1 range".
  • The upstream test base asserts a non-null class and a score in [0, 1]; nothing requires values to have tokens.
  • On the same input the field-absent path, the text classifier and the k-NN document classifier stay finite.
The full argument239 words
  • Interface contract. DocumentClassifier.assignClass: "Assign a class (with score) to the given Document", returning "a ClassificationResult holding assigned class of type T and score" (DocumentClassifier.java:31-40). Only getClasses mentions null, "if the classifier can't make lists" (:48-49, :62-63). The contract is implied, not explicit: no javadoc promises a finite score, and the implementation already returns null when the index has no class terms at all.
  • Range. "normalization; the values transforms to a 0-1 range" (SimpleNaiveBayesClassifier.java:156, :294). NaN is not in that range.
  • The comment above the division describes a sum, log(P(d|c)) = log(P(w1|c))+...+log(P(wn|c)) (SimpleNaiveBayesDocumentClassifier.java:221), which is 0 for no tokens, not NaN.
  • Tests. DocumentClassificationTestBase.checkCorrectDocumentClassification asserts a non-null class (:61) and 0 <= score <= 1 (:67); ClassificationTestBase asserts a non-null result (:93, a line added on purpose by LUCENE-7981, #9030, 2017) and a non-null class (:95, older). No test uses an empty or stop-word-only value, and both fixes keep the whole module suite green.
  • History. The division is deliberate length normalisation from the commit that created the class (LUCENE-6631, 71cea88773e, 2015); the zero-length case was never handled. LUCENE-5698 (552c367c5d2, 2019) only inlined it. The issue's description and 21 comments do not mention empty values.
  • Legal input. No javadoc says field values must produce tokens; TextField and IndexWriter accept empty values, and stock analyzers remove stop words and punctuation.
The devil's-advocate check214 words

The strongest "intended" case (built by an intended-behaviour checker; a cross-model reviewer rated it weak): the class is @lucene.experimental and "simplistic", the division is deliberate length normalisation, and an average over zero tokens is undefined, so a caller who passes empty values gets what they asked for.

Why it fails: no javadoc, test, CHANGES or MIGRATE entry, issue or list thread states a "values must have tokens" precondition, and such values are legal input; one empty value cancels the evidence of every other field and value, and assignClass returns no class where its javadoc describes a result holding a class and a score; every sibling path (field absent, the text SimpleNaiveBayesClassifier, KNearestNeighborDocumentClassifier) returns a finite score and a class on the same input; @lucene.experimental allows API changes, not results outside the documented range. The independent reproduction, the cross-model review and the intended-behaviour check rated the bug real and the adjudication confirmed it with high confidence; the refute-lens cross-model review gave only a procedural "not real" at low confidence (it could not run Java) while agreeing with the root cause. Not settled: whether an empty value should count like an absent one (fix A), keep its prior (fix B) or be rejected with an exception; a maintainer could reasonably choose any of these.

Suggested fix

Recommended Skip a value with no tokens (fix A)

Skip a field value that has no tokens in assignNormClasses (3 added lines), so it counts like an absent value: the repro then gives fruit 0.7619, the no-body result. The new test, the closest suites, the whole lucene/classification suite (assertions on and off) and check -x test pass with it.

           for (String[] fieldTokensArray : tokensArrays) {
+            if (fieldTokensArray.length == 0) {
+              continue; // no tokens, no evidence: treat the value as absent
+            }
             fieldScore +=
                 calculateLogPrior(term, docsWithClassSize)
All options and trade-offs3 options · 653 words

Two fixes were built and validated in a scratch worktree of a9779ef3c193. Ask upstream only for finite scores and a class; that an empty value scores like an absent one is fix A's design choice, not a documented promise. Other defensible choices, not built: reject a value with no tokens with an exception, document a precondition, or guard the normalisation against NaN.

Skip a value with no tokens (fix A) (validated by the checks) (recommended)

In assignNormClasses (SimpleNaiveBayesDocumentClassifier.java:124), continue when the value's token array is empty; an empty value then adds neither the prior nor a likelihood, exactly like an absent field. The hunt's own fix is the same change.

           for (String[] fieldTokensArray : tokensArrays) {
+            if (fieldTokensArray.length == 0) {
+              continue; // no tokens, no evidence: treat the value as absent
+            }
             fieldScore +=
                 calculateLogPrior(term, docsWithClassSize)

Trade-offs

  • An empty, stop-word-only or punctuation-only body gives the no-body result: fruit 0.7619 for title "apple", vehicle 0.5556 for title "car".
  • A document whose every value is empty gets uniform scores (0.5 / 0.5, assignClass = the first class), which is what a document with no fields at all already gets today; the text SimpleNaiveBayesClassifier returns the prior (0.6667) for empty input instead.

What the checks validated

  • Gradle, JDK 25: the new test fails 3 of 4 on stock with assertions on and off; with the fix, the new test plus TestSimpleNaiveBayesDocumentClassifier, TestSimpleNaiveBayesClassifier and TestKNearestNeighborDocumentClassifier pass (4 suites, 15 tests); the whole :lucene:classification:test passes with assertions on and off (13 suites, 47 tests, 1 skipped, 0 failures each); :lucene:classification:check -x test passes.
  • Standalone runner with the fix shadowed: the new test 4 of 4 (-ea, -da), the finder's test 2 of 2, the independent reproducer's test 6 of 6.
  • Earlier, on ea57360912ed: the 42 upstream classification tests and the security probe (poisoned documents get the no-body result).

Return 0 from calculateLogLikelihood for zero tokens (fix B) (validated by the checks)

At the division (SimpleNaiveBayesDocumentClassifier.java:222), return 0 when the value has no tokens: the empty sum the comment above it describes.

     // log(P(d|c)) = log(P(w1|c))+...+log(P(wn|c))
+    if (tokenizedText.length == 0) {
+      return 0; // empty sum; dividing by the length would give 0.0 / 0 = NaN
+    }
     return result / (tokenizedText.length);

Trade-offs

  • Scores become finite, but the empty value still adds its class prior once more (the per-value prior at :125-126), which pulls the result towards the larger class: title "apple" plus an empty body gives fruit 0.8649 instead of 0.7619, and title "car" plus an empty body gives fruit 0.6154 while "car" alone gives vehicle 0.5556. So an empty value can still change the class.
  • The smallest change at the division; the adjudication noted upstream may prefer it if they see the per-value prior as intended (the audit lists both fixes as acceptable). With equal class priors it gives the same scores as fix A (arithmetic: the extra prior cancels in the normalisation; no run compared the two).

What the checks validated

  • Gradle, JDK 25: the same four suites (15 tests) and the whole :lucene:classification:test with assertions on and off (13 suites, 47 tests, 1 skipped, 0 failures each); check -x test passes; standalone: the new test 4 of 4, the finder's 2 of 2, the independent reproducer's 6 of 6.

Workaround until a fix ships

Drop field values that analyze to no tokens before classifying (run the field's analyzer, or at least skip empty strings), or treat NaN scores and a null from assignClass as "unclassified" instead of taking the first entry of getClasses. Lucene itself stores nothing wrong (the classifier only reads the index), so the index needs no repair; an application that stored the returned class (for example through Solr's update processor) may need to reclassify the affected documents.

Trade-offs

Needs one extra analysis pass per value, or a NaN check in the caller.

Downstream note

Solr's opt-in classification update processor with algorithm=bayes uses this classifier and writes the returned class names into incoming documents that do not already carry the class field (the default algorithm is k-NN), so there a document with one zero-token field value would get the first class name in term order (code reading only).

How it was found

An AI finder listed every division and logarithm in the naive-Bayes classifiers' raw scores and asked an SMT solver (Z3) which of them can produce NaN: the token-count division with zero tokens can, for every class. A bounded check of the real normalisation showed that one NaN spoils every class, and a public-API JUnit confirmed it on the stock jar.

The method in full174 words

Method: semi-formal (an SMT inventory plus a bounded check of the real code), then a JUnit test. A hunt unit (classification), 2026-10-26, main ea57360912ed.

  • The finder wrote a Z3 4.12.2 model (floating-point arithmetic with an uninterpreted log) of every division and log site in the classifiers' raw scores. Site S4, result / tokenizedText.length with length 0, was satisfiable (a NaN field score for every class); with length >= 1 it was proved finite. The site was read in the code while building the inventory, so this was a guided rather than a blind find.
  • 21 of 21 Z3 and cvc5 lemmas characterised normClassificationResults, and a bounded check of the real method over 1,082,400 special-value vectors (0 failures) showed that an output is NaN exactly when an input is NaN or +Inf, or every input is -Inf: one NaN poisons every class.
  • A public-API JUnit on the stock jar then witnessed it (fruit = NaN for an empty and a stop-word-only body), with assertions on and off, on main, 10.5.2 and 9.12.3.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26, refreshed 2026-10-27): 84 logged queries over GitHub issues, PRs and commits, JIRA, the Lucene and Solr mailing lists, downstream projects and the web; of the 408 open PRs only #16847 changes the class (int to long, keeping the division). Nearest: an NPE in the same class (LUCENE-7950, fixed in 7.1.0).

Searches6 searches

All 27 assessed hits are a different bug. The nearest items are listed in the last row.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, the old lucene-solr repository, apache/solr, Elasticsearch, OpenSearch, all of GitHub)2026-10-26the scripted prior-report search (16 issue and PR, 3 commit and 2 downstream queries) plus 16 manual searches: class names, "classification NaN", "classifier null", the module:classification label (68 items), open classification PRsno report; #8999 (LUCENE-7950) and #9030 (LUCENE-7981) are the nearest
Open PRs2026-10-262026-10-26: the changed files of the 170 older or unlabelled of the 410 open apache/lucene PRs, and the path-based module:classification label for the other 240; 2026-10-27: the changed files of all 408 then-open PRs#16847 edits calculateLogLikelihood (int to long) and keeps the division; #12295 and #681 touch other module files
JIRA and mailing lists2026-10-26all 47 LUCENE modules/classification titles; LUCENE and SOLR text searches; comments of 16 related issues; dev@, java-user@, issues@, commits@ and the Solr user and dev lists (37 CI-failure mails scanned)no report; the solr-user@ empty-string thread is about the class field, not an input field
History, CHANGES and the web2026-10-26git log -S'tokenizedText.length', lucene/CHANGES.txt, web searches, the class author's design poston main's history only the introducing commit (an all-branches search also lists branch-housekeeping commits); no CHANGES entry about empty values or NaN
Refresh (GitHub, read-only)2026-10-27SimpleNaiveBayesDocumentClassifier, "naive bayes NaN" and calculateLogLikelihood in apache/lucene issues and PRs; upstream main f7bcc0abd816no new item: calculateLogLikelihood returns only #7494 (LUCENE-6435, a different bug already assessed); line 222 unchanged
Nearest items (read)2026-10-26#8999 / LUCENE-7950, #9030 / LUCENE-7981, #9158 / LUCENE-8110, #5992 / LUCENE-4927, #7689 / LUCENE-6631, lucene-solr#544 / LUCENE-5698different defects (NPE without class field, getClasses bound, text-classifier underflow), a test-only non-null assertion, and the commits that added and inlined the division

History

Present since the class was added by LUCENE-6631 (71cea88773e, 2015), so since 6.0.0; LUCENE-5698 (552c367c5d2, 2019) only inlined the division. Executed on 10 releases from 6.0.0 to 10.5.2 and on main: all fail. Not fixed upstream (main f7bcc0abd816, 2026-10-27).

Versions tested5 rows

Introduced by LUCENE-6631 (#7689), commit 71cea88773e (2015-10-20, "added document classification api and impls"; svn era, no pull request), which created the class with double normScore = result / (tokenizedText.length); // this is normalized because if not, long text fields will always be more important than short fields. LUCENE-5698 (552c367c5d2, 2019-01-18, first in 9.0.0) inlined it and dropped the comment. The division is in all 83 release tags of the history clone that contain the file.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test: 3 of 4 fail (gradle and the standalone runner, assertions on and off): fruit NaN, vehicle NaN, assignClass null
10.5.2, 9.12.3 affectedthis doc's test: 3 of 4 fail, the same messages (assertions on and off; 9.12.3 with assertions off in the fact check's run)
6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0 release sweepthe finder's test fails 2 of 2 on each (6.0.0 also with assertions off); the independent reproducer's test fails 3 of 6 on 8.11.4 (its own run) and on 6.0.0 (the version-history runs's run)
5.5.0, 5.5.5 no APIthe class does not exist (compile error)
upstream main f7bcc0abd816 (2026-10-27) code readingline 222 still divides by the token count

Independent checks

An independent reproducer (3 of 6 fail, controls pass), a devil's advocate, an adjudication (confirmed, high), an audit and cross-model reviews (OpenAI GPT-5.6, reading only) agree on the root cause; one review's "not real" was procedural. This doc's gradle runs reproduced it on main a9779ef3c193 and validated both fixes, and fact checks re-ran them.

Checks and verdicts447 words

Every executed check reproduced the same symptom (NaN for every class, assignClass null) and the same root cause at line 222.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateSMT inventory flagged the zero-token division; the JUnit test fails 2 of 2 on main with assertions on and off, on 10.5.2 and on 9.12.3, and passes with fix A.
cross-model review, refute lens not real (low, procedural)OpenAI GPT-5.6, reading only: it could not run Java, so the review voted not real on procedure only; it agreed with the root cause, called the inputs legal and the intended case weak. A separate re-run of the finder's test failed 2 of 2.
independent reproducer realOwn 6-test class with other data and equal priors: 3 of 6 fail on main (assertions on and off), 10.5.2, 9.12.3 and 8.11.4 (and on 6.0.0 in the version-history runs's run of this test); 60 of 60 failures in 20 iterations; the 3 controls pass; 6 of 6 with fix A.
cross-model review, intended lens real (medium)OpenAI GPT-5.6, reading only; intended argument weak. The step re-ran the finder's test on main (assertions on and off) and a probe: body "sweet" fruit 0.901, body "" NaN and null.
devil's advocate real (high)Stock EnglishAnalyzer with the body "To be or not to be": NaN and null on main (assertions on and off) and 10.5.2; field-absent, k-NN document and text-classifier controls finite. No precondition anywhere.
adjudication confirmed (high)Re-ran the independent reproduction with a new seed (3 of 6 fail, assertions on and off) and the finder's test (2 of 2).
audit keepRe-ran both tests with assertions off; its own plain main() probe with the stock StandardAnalyzer gives NaN and null for an empty and a punctuation-only body, controls finite.
this doc reproducedNew 4-test class through gradle and the standalone runner on main a9779ef3c193, 10.5.2 and 9.12.3; fixes A and B validated; found that fix B can flip the class ("car" plus an empty body gives fruit 0.615).
fact check reproduced; 7 wording errors fixedSeparate AI tool. Re-ran the gradle command (assertions on and off), the standalone runner on main, 10.5.2 and 9.12.3 (assertions on and off), both fixes through the whole module suite and check -x test, and two probes: every number reproduced. Its 7 wording and attribution errors are fixed.
cross-model fact check real; errors fixedOpenAI GPT-5.6, reading only, over generated references (history, blame, tags, open PRs, Solr sources, run records): the core bug is real and well demonstrated; its 9 factual errors, 4 overclaims and 7 missing caveats are resolved.

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

Finding record

LFV-380, snapshot of 2026-11-06

Registry title
SimpleNaiveBayesDocumentClassifier returns NaN for every class and null from assignClass when the document has a field value with no tokens (empty, stop words only or punctuation only), because each value's log-likelihood is divided by its token count (released since 6.0.0)
Area
classification / document classifiers (SimpleNaiveBayesDocumentClassifier.assignClass and getClasses)
Priority
Medium, rank 187: The same experimental classifier silently returns NaN scores and a null class whenever one field value analyzes to no tokens, which is ordinary data but needs an empty value, so it follows the two broader classification rows.
Impact class
silent wrong results
Impact note
Applications that use the opt-in, @lucene.experimental lucene/classification module and classify documents with SimpleNaiveBayesDocumentClassifier are hit whenever one of the classifier's text fields in the document being classified has a value that analyzes to no tokens (an empty TextField value, or a stop-word-only or punctuation-only value under stock analyzers such as EnglishAnalyzer or StandardAnalyzer): with no error, every class score is NaN, assignClass returns null and getClasses(doc, n) returns classes in index term order, whatever the other fields say; no setting or scale is needed, every release from 6.0.0 to 10.5.2 is affected, and empty values in training documents do not trigger it.
Summary
You train SimpleNaiveBayesDocumentClassifier on the fields title and body and classify the document {title: 'apple', body: ''} (or a body such as 'the of and' that the analyzer reduces to stop words), expecting a class with a finite score, as the DocumentClassifier.assignClass javadoc and the '0-1 range' normalisation comment imply and as the same document without the body value gets (fruit). Instead getClasses returns fruit=NaN and vehicle=NaN, and assignClass returns null. calculateLogLikelihood returns result / tokenizedText.length, which is 0.0 / 0 = NaN for a value with no tokens; assignNormClasses adds that NaN to every class, and neither the log-sum-exp normalisation nor the 'score > maxscore' test in assignClass can recover from it.
Introduced
71cea88773e8fe1086d0f2d7829b17b9c23bcc63 'LUCENE-6631 - added document classification api and impls' (svn trunk r1709522; committed by GitHub tteofili from a JIRA patch by alessandrobenedetti; migrated issue apache/lucene#7689; no pull request) [read: git log --follow creates the file here, git log -S'tokenizedText.length' finds only this commit; exec: 5.5.0 and 5.5.5 lack the class, 6.0.0 fails]. git blame on main :222 points to 552c367c5d2 (LUCENE-5698, 2019-01-18, apache/lucene-solr#544), which only inlined the division and dropped its comment (behaviour unchanged). (2015-10-20)
First affected version
6.0.0 [exec] (tagged 2016-04-07, the first release with the class: CHANGES 6.0.0 'LUCENE-6631: Lucene Document classification'; 5.5.0 and 5.5.5 lack the API [exec: compile error on the missing class])
Affected versions
Every release from 6.0.0 to 10.5.2 and main ea57360912ed; not fixed upstream (upstream main cf398bfb142c, 2026-10-26, still divides at line 222). [exec] the finder's JUnit fails 2 of 2 on 6.0.0 (-ea and -da), 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), with API-spelling variants for older releases; the independent reproduction's JUnit fails 3 of 6 (controls pass) on main (-ea and -da), 10.5.2, 9.12.3, 8.11.4 and 6.0.0. [read] the division is present in all 83 release tags of the history clone that contain the file (6.0.0 to 10.5.2; 8.8.2-8.11.3 are not tagged there, 8.11.4 was executed).
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 low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: Z3
Upstream and prior reports
none found (2026-10-26, read-only, the prior-report record verdict new: 84 logged queries: a scripted prior-report search (16 GitHub issue and PR, 3 commit, 2 downstream, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA and 6 web queries), 16 more GitHub searches (apache/lucene, apache/solr, Elasticsearch and OpenSearch, all of GitHub), 6 JIRA REST searches plus a read of the related classification issues and their comments, lists.apache.org searches of solr-user@, users@solr, dev@solr, java-user@ and dev@lucene (37 CI-failure mail bodies scanned), web searches, the full git history and CHANGES, and the changed files of all 410 open apache/lucene PRs; plus the finder's and the intended-behaviour check's scripted prior-report searches); no hit is the same bug. Nearest: #8999 / LUCENE-7950 (NPE in the same class when no document has the class field, fixed in 7.1.0, different cause), #9030 / LUCENE-7981 (a test-only assertion that classifiers return a non-null result: contract evidence), #9158 / LUCENE-8110 (getClasses(doc, max) bound), #5992 / LUCENE-4927 (underflow in the text classifier, fixed by log likelihood), #7689 / LUCENE-6631 (the introducing issue; no discussion of empty values). Open PR #16847 edits calculateLogLikelihood (int to long) but keeps the division, so a fix touches the same lines. Not fixed upstream (main cf398bfb142c, 2026-10-26, line 222 unchanged).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27; its lucene/classification sources are identical to the hunt's ea57360912ed) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 release jar (assertions on and off) and the 9.12.3 release jar (assertions on; the fact check also ran it with assertions off). The version-history runs ran the finder's test on 10 releases from 6.0.0 to 10.5.2 and on main, and showed that 5.5.0 and 5.5.5 lack the class. Fixes A and B were validated in the scratch worktree: the new test, the closest suites, the whole lucene/classification suite with assertions on and off, and :lucene:classification:check -x test. An independent fact check re-ran the main, 10.5.2 and 9.12.3 runs and both fix validations, with the same results.

Lucene and upstream sources

Finding record
  • lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:77-88 (assignClass, 'c.score() > maxscore'), :121-131 (assignNormClasses), :148-172 (analyzeSeedDocument), :201-223 (calculateLogLikelihood, :222 'return result / (tokenizedText.length);')
  • lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:156 and :294 ('the values transforms to a 0-1 range'), :292-318 (normClassificationResults)
  • lucene/classification/src/java/org/apache/lucene/classification/document/DocumentClassifier.java:31-40 (assignClass javadoc: 'holding assigned class ... and score'); lucene/classification/src/java/org/apache/lucene/classification/ClassificationResult.java:31-32 (compareTo by Double.compare); lucene/classification/src/test/org/apache/lucene/classification/document/DocumentClassificationTestBase.java:61, :67 (non-null class, score in [0, 1])
The finding in 30 seconds
  • lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:44-49, 77-103, 105-136 (110-118, 121-131), 148-172, 181-191, 201-223 (221, 222)
  • lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:156, 292-318 (294)
  • lucene/classification/src/java/org/apache/lucene/classification/ClassificationResult.java:27-33
Who is affected
  • lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:44-49, 148-172
Why it looks unintended
  • lucene/classification/src/java/org/apache/lucene/classification/document/DocumentClassifier.java:31-40, 48-49, 62-63
  • lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:156, 294
  • lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:44-49, 221-222
  • lucene/classification/src/test/org/apache/lucene/classification/document/DocumentClassificationTestBase.java:58-69 (61, 67)
Suggested fix
  • lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:121-131, 221-222

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

Repro files

About the diagrams

A light summary card (the PNG), made in parallel with this text from an executed probe trace on main a9779ef3c193 (assertions on and off, and with the fix as a control). The diagram uses StandardAnalyzer and the repro EnglishAnalyzer; the title "apple" gives the same numbers with both.

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.