All findings · Lucene finding · LFV-763 · nb-variants-treat-empty-class-term-as-class

Two Naive Bayes classifiers treat an empty class value as a class; their parent skips it

When unlabelled training documents store an empty class value (StringField("cat", "")), CachingNaiveBayesClassifier and SimpleNaiveBayesDocumentClassifier offer '' as a class and can assign it, while their parent SimpleNaiveBayesClassifier and BM25NBClassifier skip it: on a three-document index they classify "car bus" as '' instead of vehicle. Every release since 6.0.0 is affected.

Summary

Priority
Low, rank 495Two experimental Naive Bayes variants offer and assign an empty class '' that their parent skips, but only under the rare habit of storing empty labels, the audit rated it minor and which side is right is open.
Impact
minor
Since
6.0.0 (2016), two 2015 commitsintroduced 2015-09-02 · over 11 years
Affected
6.0.0 to 10.5.2 and main (10 releases run)
Status
documented
Found by
bounded-exhaustive comparison with a reference model

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

Each Naive Bayes classifier builds its class list from the terms of the class field. Two of the four skip the empty term and two do not, so a blank label stored as "" becomes an extra class '' in the caching and the document classifier.

Training documents apple banana = fruit, car truck = vehicle and car bus with an empty class value. SimpleNaiveBayesClassifier and BM25NBClassifier skip the empty term and offer fruit and vehicle; CachingNaiveBayesClassifier and SimpleNaiveBayesDocumentClassifier keep it and offer '', fruit and vehicle, and assign '' to car bus where the parent assigns vehicle.
The same three training documents seen by the four Naive Bayes classifiers: the parent and BM25NB skip the empty class term and see {fruit, vehicle}, the caching and document classifiers see {'', fruit, vehicle} and classify "car bus" as ''. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index three training documents: "apple banana" = fruit, "car truck" = vehicle, and an unlabelled "car bus" whose blank label is stored as StringField("cat", ""). Build the four Naive Bayes classifiers on the same reader.
  2. You expect them to agree. SimpleNaiveBayesClassifier and BM25NBClassifier offer [vehicle, fruit] for "car" (the parent scores vehicle 0.67, fruit 0.33) and assign "car bus" to vehicle.
  3. Instead CachingNaiveBayesClassifier and SimpleNaiveBayesDocumentClassifier offer ['', vehicle, fruit] for "car" ('' 0.40, vehicle 0.40, fruit 0.20) and assign "car bus" to '' (0.57 and 0.45). No error; a second blank-labelled document gives the same classes and assignments (only the scores change).
  4. Why: the parent's class loop skips a zero-length term (if (next.length > 0)), and BM25NB has the same check. The caching class's reInitCache and the document class's assignNormClasses loop over every class term, the empty one included.
  5. Control: leave the class field out of the unlabelled documents and all of them agree. Whether '' should be skipped or be a class everywhere is for maintainers; today the family disagrees on one index.

Who is affected

Users of CachingNaiveBayesClassifier or SimpleNaiveBayesDocumentClassifier (opt-in, experimental module) whose training documents store blank labels as "" instead of leaving the class field out. Then every call can list or return ''. No error, no scale needed, assertions on and off; rare, since Lucene's own fixtures omit the field.

Mechanism, terms and numbers651 words

Lucene's classification module has four Naive Bayes classifiers, and each reads the list of classes from the terms of the class field. SimpleNaiveBayesClassifier and BM25NBClassifier skip a term of length 0; their sibling implementations CachingNaiveBayesClassifier and SimpleNaiveBayesDocumentClassifier, both subclasses of SimpleNaiveBayesClassifier, do not. So on a training index where some documents store an empty string as their class value (a blank label column indexed as it is), the two subclasses list '' as a class and can assign it, while their parent returns a real class: with fruit "apple banana", vehicle "car truck" and one blank-labelled "car bus" document, assignClass("car bus") is vehicle from the parent and BM25NB and '' from both subclasses. There is no error, and leaving the class field out instead makes all of them agree. The divergence appeared in 6.0.0: the 2015 commit that added the parent's check also made the caching class's own, unguarded code path live, and the document classifier was added seven weeks later with a copy of the loop without the check. The module is @lucene.experimental and the trigger is rare. Which direction is right (skip '' everywhere or treat it as a class everywhere) is open; adding the parent's check to the two subclasses (+7/-1 lines) was validated against the whole classification test task.

Terms

  • Class field: the field whose indexed terms are the classes (classFieldName). A StringField("cat", "") is indexed as a term of length 0, the empty class term '' (the independent reproducer checks, on its own index with two blank-labelled documents, that the empty term has docFreq 2 in its class field).
  • The Naive Bayes family: SimpleNaiveBayesClassifier (the parent), its subclasses CachingNaiveBayesClassifier ("with caching feature") and SimpleNaiveBayesDocumentClassifier (classifies a Document), and the separate BM25NBClassifier. All four are @lucene.experimental.

The bug

  1. SimpleNaiveBayesClassifier.assignClassNormalizedList (SimpleNaiveBayesClassifier.java:136-158) enumerates the class-field terms and scores each as a class; at :147 it skips a zero-length term with if (next.length > 0). BM25NBClassifier does the same (BM25NBClassifier.java:128-129).
  2. CachingNaiveBayesClassifier overrides assignClassNormalizedList (CachingNaiveBayesClassifier.java:83-93) and scores the classes in its list cclasses (:99-101, :108). reInitCache, called from the constructor (:76), fills that list with every class term and no length check (:236-241).
  3. SimpleNaiveBayesDocumentClassifier.assignNormClasses (document/SimpleNaiveBayesDocumentClassifier.java:105-136) loops over every class term (:118) and adds a result for each (:132), with no length check.
  4. So the parent and BM25NB see the classes {fruit, vehicle}; the two subclasses see {'', fruit, vehicle}. "car" occurs under vehicle and under '', so '' ties with vehicle; "bus" occurs only under '', so for "car bus" '' wins. A second blank-labelled document keeps these class sets and winners; only the scores change.

The numbers

Training documents: "apple banana" = fruit, "car truck" = vehicle, plus one or two "car bus" with class ""; StandardAnalyzer, default IndexWriterConfig.

callparentBM25NBcachingdocument
getClasses("car"), one blank-labelled docvehicle 0.67, fruit 0.33vehicle 0.60, fruit 0.40'' 0.40, vehicle 0.40, fruit 0.20'' 0.40, vehicle 0.40, fruit 0.20
assignClass("car bus"), one blank-labelled docvehicle 0.67vehicle 0.60'' 0.57'' 0.45
assignClass("car bus"), two blank-labelled docsvehiclevehicle''''
both calls, the "car bus" docs without a class fieldagreeagreeagreeagree

KNearestNeighborClassifier also returns '' on the same training documents when the class field is stored (['', 'vehicle'], assigns ''); it reads stored values, not the Naive Bayes class loop, and is not part of this bug.

A side note that fix A leaves as is: the parent's countDocsWithClass (SimpleNaiveBayesClassifier.java:166-184) uses Terms.getDocCount() of the class field, which counts blank-labelled documents too, so even in the parent a '' document is not equivalent to one without a class field (its assigned class differed in 1,387 of 55,500 seeded cases in the finder's runs).

Exposure and likelihood228 words
  • Who. Applications that classify with CachingNaiveBayesClassifier or SimpleNaiveBayesDocumentClassifier (per the prior-report search, Solr's classification update processor uses the latter for its "bayes" algorithm, SOLR-7739 / SOLR-8871) on a training index in which some documents carry an empty string as their class value: a blank label column indexed as it is, StringField(cls, ""), or an empty value under KeywordAnalyzer.
  • Effect. getClasses returns an extra class '' and assignClass can return '' where the parent returns a real class. Under the reading that '' means "unlabelled" that is a wrong label; under the reading that '' is a class, the parent and BM25NB are the ones dropping it. Either way, swapping one Naive Bayes classifier for another (for example the caching one, for speed) changes the class set. Nothing fails or is logged.
  • Default vs opt-in. The classification module is opt-in and @lucene.experimental; nothing else needs configuring (default IndexWriterConfig). No codec dependency was seen or is expected: the class loops use only the generic Terms/TermsEnum API (the gradle runs used the test framework's Asserting(Lucene104) codec; no codec matrix was run).
  • Scale. None: three documents show it, deterministically.
  • How likely. Rare: Lucene's own fixtures mark unlabelled documents by leaving the class field out (ClassificationTestBase.java:227, :333), and nobody has reported it in about ten years. When it happens, it happens on every call.

Minimal repro

The test shows that on an index where one training document has an empty class value (StringField("cat", "")), CachingNaiveBayesClassifier.getClasses offers the empty class '', which its parent SimpleNaiveBayesClassifier skips.

TestCachingNaiveBayesEmptyClassValue.java (JUnit test)73 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;

import java.util.List;
import java.util.TreeSet;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
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 TestCachingNaiveBayesEmptyClassValue extends LuceneTestCase {

  public void testSameClassesAsSimpleNaiveBayes() throws Exception {
    Analyzer analyzer = new StandardAnalyzer();
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
        w.addDocument(doc("apple banana", "fruit"));
        w.addDocument(doc("car truck", "vehicle"));
        w.addDocument(doc("car bus", ""));
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Classifier<BytesRef> parent =
            new SimpleNaiveBayesClassifier(reader, analyzer, null, "cat", "text");
        Classifier<BytesRef> caching =
            new CachingNaiveBayesClassifier(reader, analyzer, null, "cat", "text");
        // expected the parent's classes ['fruit', 'vehicle'], actual ['', 'fruit', 'vehicle']
        assertEquals(
            "CachingNaiveBayesClassifier.getClasses(\"car\")",
            classes(parent.getClasses("car")),
            classes(caching.getClasses("car")));
      }
    }
  }

  private static Document doc(String text, String cls) {
    Document doc = new Document();
    doc.add(new TextField("text", text, Field.Store.NO));
    doc.add(new StringField("cat", cls, Field.Store.NO));
    return doc;
  }

  private static TreeSet<String> classes(List<ClassificationResult<BytesRef>> results) {
    TreeSet<String> classes = new TreeSet<>();
    for (ClassificationResult<BytesRef> r : results) {
      classes.add("'" + r.assignedClass().utf8ToString() + "'");
    }
    return classes;
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-763/, 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.TestCachingNaiveBayesEmptyClassValue
Expected vs actual
Expected: the parent's classes ['fruit', 'vehicle']. Actual: ['', 'fruit', 'vehicle'].

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

More files, environment, full output37 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: CachingNaiveBayesClassifier.getClasses("car") expected:<['fruit', 'vehicle']> but was:<['', 'fruit', 'vehicle']>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.apache.lucene.classification.TestCachingNaiveBayesEmptyClassValue.testSameClassesAsSimpleNaiveBayes(TestCachingNaiveBayesEmptyClassValue.java:51)

Why it looks unintended

  • Both extend SimpleNaiveBayesClassifier and call themselves a "simplistic Lucene based NaiveBayes classifier"; nothing documents a different class set.
  • Before 6.0.0 the caching classifier gave its parent's classes; the commit adding the parent's check also made the caching class's unguarded path live.
  • Whether '' is a class is open, but the family cannot be right both ways on one index.
The full argument218 words
  • Documentation. CachingNaiveBayesClassifier is "A simplistic Lucene based NaiveBayes classifier, with caching feature" (CachingNaiveBayesClassifier.java:38) and SimpleNaiveBayesDocumentClassifier "A simplistic Lucene based NaiveBayes classifier" (document/SimpleNaiveBayesDocumentClassifier.java:45); both extend SimpleNaiveBayesClassifier. LUCENE-5736 (#6798), which added the caching class, calls it "a cache included version of the SimpleNaiveBayes classifier". The open #8723 treats a caching-versus-parent difference as a bug (another cause: no log prior).
  • Tests. No upstream classification test indexes an empty class value; the fixtures leave the class field out for unlabelled documents.
  • History. In every 5.x release the caching class's own assignClassNormalizedList was private and never called, so it returned its parent's results (5.0.0, 5.3.2, 5.4.0, 5.5.5 executed: consistent). 8b2e0d937d5 (LUCENE-6479, 2015-09-02, "improved cm testing, added stats, minor fixes") added if (next.length > 0) to the parent and, in the same diff, changed both assignClassNormalizedList methods (the parent's and the caching class's) from private to protected, so the caching one overrides the parent's and its class list, filled without the check since 2014, went live. 71cea88773e (LUCENE-6631, 2015-10-20) added the document classifier with the loop but not the check; two later commits rewrote that loop and kept it unguarded (9534904c250, c2c2e8a85e9), and 8808cf53735 edited the class without adding it. BM25NBClassifier (LUCENE-7823, 2017) copied the check. No commit, CHANGES entry or discussion explains the difference.
The devil's-advocate check237 words

The strongest works-as-designed case (the refute-lens cross-model reviewer and the devil's advocate): getClasses promises "all the classes" (Classifier.java:40, document/DocumentClassifier.java:43); the classFieldName javadoc says "the returned class will be a token indexed for this field" (SimpleNaiveBayesClassifier.java:82-83), and '' is such a token, which IndexWriter accepts. KNearestNeighborClassifier returns and assigns '' on the same training documents with the class field stored, and ConfusionMatrixGenerator accepts '' as a correct answer (utils/ConfusionMatrixGenerator.java:80, :92-94). The parent's check is undocumented, the caching classifier already differs from its parent (#8723), and the module is experimental. So the subclasses may be right and the parent's check the odd one out.

What it shows, and what it does not: it is a strong case that '' can be a legitimate class (so fix B is a real option) and a moderate case that blank labels stored as "" are the application's data-quality problem. It does not show that the subclasses are meant to see a different class set from their parent on one index: before 6.0.0 they did not, and the change arrived as a side effect of a "minor fixes" commit. Between 2015 and 2017 the module's author treated '' as "no class" in three places: ConfusionMatrixGenerator (2015-05 to 2015-10), the parent (2015-09) and the new BM25NB (2017-05). Verdict: the divergence is not intended; whether '' is a class, and so the fix direction, is open.

Suggested fix

Recommended Fix A: add the parent's check to the two subclasses

Add the parent's empty-term check to the two subclasses (CachingNaiveBayesClassifier.reInitCache and SimpleNaiveBayesDocumentClassifier.assignNormClasses, +7/-1 lines). The whole classification test task passes with the repros (51 tests), assertions on and off, and the module's static checks pass. The other direction, removing the check from the parent and BM25NB so '' is a class everywhere, is equally consistent.

// CachingNaiveBayesClassifier.reInitCache
while ((termsEnum.next()) != null) {
  // like SimpleNaiveBayesClassifier, skip the empty term: it is not a class
  if (termsEnum.term().length > 0) {
    cclasses.add(BytesRef.deepCopyOf(termsEnum.term()));
  }
}
// SimpleNaiveBayesDocumentClassifier.assignNormClasses
while ((c = classesEnum.next()) != null) {
  if (c.length == 0) {
    continue; // like SimpleNaiveBayesClassifier, skip the empty term: it is not a class
  }
All options and trade-offs3 options ยท 712 words

Two directions make the family consistent; both were built and run in a scratch worktree at a9779ef3c193. Fix A is recommended: it is the smallest change, it touches only the two classes that differ, and it matches the behaviour of SimpleNaiveBayesClassifier and BM25NBClassifier. A third idea, also leaving blank-labelled documents out of the document count, was not written or run.

Fix A: add the parent's check to the two subclasses (validated by the checks) (recommended)

reInitCache adds a class term to cclasses only when its length is above 0, and assignNormClasses skips a zero-length class term, as SimpleNaiveBayesClassifier.java:147 does. +7/-1 lines in CachingNaiveBayesClassifier.java and document/SimpleNaiveBayesDocumentClassifier.java.

// CachingNaiveBayesClassifier.reInitCache
while ((termsEnum.next()) != null) {
  // like SimpleNaiveBayesClassifier, skip the empty term: it is not a class
  if (termsEnum.term().length > 0) {
    cclasses.add(BytesRef.deepCopyOf(termsEnum.term()));
  }
}
// SimpleNaiveBayesDocumentClassifier.assignNormClasses
while ((c = classesEnum.next()) != null) {
  if (c.length == 0) {
    continue; // like SimpleNaiveBayesClassifier, skip the empty term: it is not a class
  }

Trade-offs

  • Changes results only on indexes with '' class values, and only for the two subclasses; restores the caching classifier's 5.4.0-5.5.5 class set (the empty-term filtering only; in 5.x the parent's own scoring, with its prior, ran) and matches the parent and BM25NB.
  • The scores are normalized over the class list, so dropping '' also changes the scores of the remaining classes (for "car" the subclasses' 0.40/0.20 become the parent's 0.67/0.33, executed) and can change the assigned class on any index with '' values.
  • Leaves countDocsWithClass alone: blank-labelled documents still count in the document total, as in the parent, so a '' document is still not exactly the same as one without a class field.
  • The caching classifier's scores still differ from the parent's for the separate #8723 (no log prior); only the class set is aligned.
  • Anyone who relied on '' as a class from these two classes loses it (no such use was found).

What the checks validated

Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the whole :lucene:classification:test task with the three repro classes added, 15 classes, 51 tests, 0 failures, 1 skipped (the @Monster 20-newsgroups test, Test20NewsgroupsClassification.java:71), with assertions on and off; the 6 repro tests that fail on stock pass. :lucene:classification:check -x test with the fix and the primary test (google-java-format, ecj lint, forbidden APIs): BUILD SUCCESSFUL. The repro runner: the primary test passes 3 of 3 with the finder's equivalent fix classes (the same two checks) shadowed; the finder's equivalent fix also makes the finder's and the independent reproducer's tests pass.

Fix B: remove the check from the parent and BM25NB, so '' is a class everywhere (validated by the checks)

Drop the if (next.length > 0) wrapper at SimpleNaiveBayesClassifier.java:147 and BM25NBClassifier.java:129 (+9/-14 lines, 2 files).

Trade-offs

  • Consistent with KNearestNeighborClassifier, ConfusionMatrixGenerator, the getClasses javadoc ("all the classes") and the 5.0.0-5.3.2 behaviour.
  • Changes the results of the two classifiers that agree today, for every index with blank labels: their getClasses gains '' and assignClass can return it. Users who index blank labels meaning "unlabelled" keep getting '' back.

What the checks validated

Gradle, same worktree, assertions on only: the primary test passes 3 of 3 and the 42 upstream tests that run pass (51 tests, 4 failures: the finder's and the independent reproducer's tests, which hard-code fix A's direction, now fail at the parent's own assertions: two because the parent now offers '', two because it now assigns '' instead of vehicle). On the repro index the parent and both subclasses then assign '' to "car bus", while BM25NBClassifier, which scores differently, still assigns vehicle. Not run with assertions off; not lint-checked.

Fix C (extension of A, not validated): also leave blank-labelled documents out of the document count

Under the reading "'' = unlabelled", countDocsWithClass (SimpleNaiveBayesClassifier.java:166-184, inherited by the parent and both subclasses) could also exclude documents whose only class value is '', so a blank label behaves exactly like a missing class field.

Trade-offs

  • Changes the parent's results too (its assigned class differed between a '' document and a document without a class field in 1,387 of 55,500 seeded cases in the finder's runs).
  • Needs a per-document check that Terms.getDocCount() cannot give. Not written or run: raise it as a follow-up question.

Downstream note

Only products that act on the predicted label notice it, and only when their own training data stores blank labels as ""; text submitted for classification cannot add the '' class.

How it was found

The finder compared each Naive Bayes classifier with an exact reference model over every small training set with one blank-labelled document. Each variant matched its own reference, but the caching classifier's classes differed from the parent's in all 420,616 cases; the finder then reduced it to a JUnit test.

The method in full166 words

Method: bounded-exhaustive translation validation against reference models (the hunt's "semi-formal" tier).

  • The harness built every training set of 1-2 small documents plus one document whose class value is '', and compared each classifier's getClasses and assignClass with an exact reference model of the same formula.
  • Each variant equals its own reference (the caching one: no prior, every class term). The caching classifier's class set differed from SimpleNaiveBayesClassifier's in all 420,616 such cases and it assigned '' in 249,898; the document classifier assigned '' in 554,608 of its 1,682,464 cases (which also vary boosts and 1-2 values per field), and in the 56,840 one-field, one-value, boost-1, one-token cases where it is directly comparable its class set differed from the parent's.
  • A metamorphic run (55,500 seeded cases) also showed that the parent answers differently for a '' document than for the same document without a class field in 1,387 cases (the document-count side note).
  • The finder then wrote the public-API JUnit test; independent checkers reproduced it.

Prior reports, history and checks

Prior reports

No prior report found (three tool runs of 51 queries plus the finder's 45, about 30 manual GitHub searches, every classification-labelled issue and PR, the file lists of all 408 open PRs, JIRA, the mailing lists and the web, 2026-10-27). Nearest: the open #8723, a caching-versus-parent difference from another cause.

Searches3 searches

Registry verdict new: no report, PR, commit or discussion of this bug by anyone. Nearest items, none a report: #8723 / LUCENE-7672 (open since 2017; "CachingNaiveBayesClassifier gives different results than the SimpleNaiveBayesClassifier", from the missing log prior; its unmerged patch keeps '' as a class and does not touch the document classifier), #7538 / LUCENE-6479 (added the parent's check; commit notices only), #7689 / LUCENE-6631 (added the document classifier), #8874 / LUCENE-7823 (BM25NB copied the check), #6402 / LUCENE-5338 (labelled documents are those "with 'class' field"), #6798, #8999, #9158, #8357, and the open PR #16847 (int to long in these files; the class loops untouched). Limits: GitHub's search rate limit (each covered by an equivalent tool query or the label and open-PR scans), two throttled web queries, one Stack Overflow page readable by title only, private channels not searchable.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE and SOLR JIRA archive; downstream repositories; web2026-10-27a scripted prior-report search, three runs of 51 queries each (identifiers, symptoms, history) plus the finder's run of 45no report; no high-overlap hit
apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET, all of GitHub2026-10-27about 30 manual searches; all 68 module:classification issues and PRs (47 threads grepped for empty, unlabel, length, blank); the changed files of all 408 open PRsno report; #8723 (other cause), #6402 (context)
JIRA (LUCENE, SOLR), mailing lists, git history2026-10-2722 JIRA queries (SOLR-7739, SOLR-8871, SOLR-13903 read); about 90 list queries; git log -S 'next.length > 0' and blame of the class loopsno report; the check was added only in LUCENE-6479 and LUCENE-7823

History

Since 6.0.0 (2016): 8b2e0d937d5 (LUCENE-6479, 2015-09-02) added the parent's check and made the caching override reachable; 71cea88773e (LUCENE-6631, 2015-10-20) added the document classifier without it. A probe fails on the 10 releases run from 6.0.0 to 10.5.2 and on main; 5.0.0 to 5.5.5 are consistent.

Versions tested7 rows

Introduced by two trunk commits (svn/JIRA era, no GitHub PR): 8b2e0d937d58470edac86135c602ef70356c68b3 (2015-09-02, LUCENE-6479 / #7538, "improved cm testing, added stats, minor fixes"): in one diff it adds if (next.length > 0) to SimpleNaiveBayesClassifier and changes both assignClassNormalizedList methods, SimpleNaiveBayesClassifier's and CachingNaiveBayesClassifier's, from private to protected, so the caching override becomes reachable and the caching class's own class list (filled without the check since LUCENE-5736, 11a24cfbb86, 2014) goes live; and 71cea88773e8fe1086d0f2d7829b17b9c23bcc63 (2015-10-20, LUCENE-6631 / #7689), which adds SimpleNaiveBayesDocumentClassifier with a class loop without the check (9534904c250, LUCENE-6821, and c2c2e8a85e9, LUCENE-7950, later rewrote that loop and kept it unguarded; 8808cf53735, LUCENE-7303, edited the class without adding the check). Both are first released in 6.0.0. On branch_5x the check was backported alone (2c733b4c377, 5.4.0), without the visibility change, so 5.x stayed consistent. BM25NBClassifier (LUCENE-7823, 89905001831, 7.0.0) has the check. All 83 release tags from 6.0.0 in the history clone carry the guarded parent loop and the two unguarded loops; upstream main f7bcc0abd816 and branch_10x are unchanged (read 2026-10-27). Not executed: trunk builds just before and after the two commits.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 2 of 3 through gradle, assertions on and off; the finder's 2 of 2, the independent reproducer's 2 of 3
10.5.2 affectedthis doc's test fails 2 of 3; the finder's and the independent reproducer's tests fail
6.0.0 to 10.5.2 (10 releases run) affectedthe cross-version probe fails on 6.0.0, 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, assertions on and off; BM25NB agrees with the parent from 7.0.0
6.0.0 first releasethe parent offers [fruit, vehicle] and assigns vehicle; caching and document offer ['', fruit, vehicle] and assign ''
5.4.0, 5.5.5 last unaffectedparent and caching agree on [fruit, vehicle] and vehicle (the caching method is private and never called); no document classifier
5.0.0, 5.3.2 not affectedconsistent the other way: the parent has no check yet, so parent and caching both offer and assign ''
fix A on a9779ef3c193 not affectedthe whole classification test task with the repros: 51 tests, 0 failures, assertions on and off

Independent checks

An independent reproducer, a devil's advocate and an adjudication reproduced it on main with assertions on and off, and it passes with fix A. Reviews by another model family could only read code: the intended lens said real, the refute lens not real because '' may legitimately be a class.

Checks and verdicts403 words

Each check ran or read on its own; the registration check and a credibility audit re-ran the tests with fresh seeds, and this doc re-ran them through gradle on a9779ef3c193. All executed checks are by AI tools of one model family (Anthropic Claude); the other-family reviewer (OpenAI GPT-5.6) could not run code, and separate re-runs of the finder's test failed 2 of 2.

Each independent check and its verdict
CheckVerdictWhat it found
finder (bounded-exhaustive comparison, JUnit) candidateJUnit fails 2 of 2 on main (assertions on and off) and 10.5.2; passes with the finder's two-check fix, and the 42 upstream classification tests stay green.
cross-model review, refute lens not real, medium (reading only); root cause agreesAn independent reviewer from another model family (OpenAI GPT-5.6) read the code only. It agreed with the root cause and that the input is legal, but rated the intended-behaviour case strong: '' is an indexed token and getClasses promises "all the classes", so the parent's check may be the odd one out. That argument decides the fix direction, not whether the family disagrees.
independent reproducer realOwn test from the claim alone, checks that '' is indexed twice; fails 2 of 3 on main (assertions on and off), 10.5.2 and 9.12.3; 10 of 10 random seeds; the no-class-field control passes; passes 3 of 3 with the fix.
cross-model review, intended lens real, medium (reading only)The same model family, asked whether it is intended: real; the intended argument is moderate, since it does not explain why two Naive Bayes paths skip '' and two keep it.
intended-behaviour devil's advocate real, mediumBuilt the strongest works-as-designed case and found it only argues the direction. Ran 5.5.4 (caching and parent agree) and 6.0.0 (they diverge); its probe shows KNearestNeighborClassifier also offers ''.
adjudication confirmed, medium; impact minorRe-ran the independent reproducer's test with a new seed: 2 of 3 fail with assertions on and off. Medium because which side is right is for maintainers.
this doc's gradle runs stock fails, fix A passesStock main: 6 of the 8 repro tests fail with assertions on and off, and both controls pass. Fix A: the whole classification module, 51 tests, 0 failures, on and off; static checks pass. Fix B: this doc's test and the upstream tests pass (assertions on). The repro runner gives the same failures on 10.5.2.

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

Finding record

LFV-763, snapshot of 2026-11-06

Registry title
CachingNaiveBayesClassifier and SimpleNaiveBayesDocumentClassifier offer and assign the empty class term '', which SimpleNaiveBayesClassifier and BM25NBClassifier skip, so the Naive Bayes classifiers disagree on the same index
Area
classification / Naive Bayes classifiers (CachingNaiveBayesClassifier, SimpleNaiveBayesDocumentClassifier vs SimpleNaiveBayesClassifier, BM25NBClassifier)
Priority
Low, rank 495: Two experimental Naive Bayes variants offer and assign an empty class '' that their parent skips, but only under the rare habit of storing empty labels, the audit rated it minor and which side is right is open.
Impact class
minor
Impact note
Hits applications of the opt-in, @lucene.experimental classification module that use CachingNaiveBayesClassifier or SimpleNaiveBayesDocumentClassifier on a training index where some documents carry an empty string as their class value (StringField(cls, ""), or an empty value under KeywordAnalyzer) instead of leaving the class field out: getClasses lists an extra class '' and assignClass can return '' where SimpleNaiveBayesClassifier and BM25NBClassifier return a real class; no exception, default IndexWriterConfig, no scale needed, -ea and -da alike [exec: main and 10 releases from 6.0.0 to 10.5.2]; recorded as minor (adjudication) because which side is right, skipping '' or treating it as a class, is for maintainers to decide.
Summary
You index training documents where some carry an empty class value (for example StringField("cat", "") for unlabelled rows) and build SimpleNaiveBayesClassifier, CachingNaiveBayesClassifier and SimpleNaiveBayesDocumentClassifier on the same reader, expecting the two subclasses to offer the same classes as their parent, which (like BM25NBClassifier) skips the empty class term with 'if (next.length > 0)'. Instead the caching and document classifiers list '' as a class: getClasses('car') gives ['', 'fruit', 'vehicle'] against the parent's ['fruit', 'vehicle'], and with two empty-class 'car bus' documents both assign '' to 'car bus' where the parent assigns 'vehicle'. Their class loops (CachingNaiveBayesClassifier.reInitCache and SimpleNaiveBayesDocumentClassifier.assignNormClasses) lack the parent's length check; whether the fix adds it there or removes it from the parent and BM25NBClassifier is open.
Introduced
8b2e0d937d58470edac86135c602ef70356c68b3 (caching variant: LUCENE-6479 / GitHub issue #7538 'improved cm testing, added stats, minor fixes', trunk; in one diff it adds the 'next.length > 0' guard to SimpleNaiveBayesClassifier.assignClassNormalizedList and changes CachingNaiveBayesClassifier.assignClassNormalizedList from private to protected, so the caching class's own class list, which reInitCache fills with every class term since LUCENE-5736 / 11a24cfbb86, goes live) and 71cea88773e8fe1086d0f2d7829b17b9c23bcc63 (document variant: LUCENE-6631 / #7689, trunk, adds SimpleNaiveBayesDocumentClassifier with a class loop without the guard; three later commits, 9534904c250, 8808cf53735 and c2c2e8a85e9, edited that loop and kept it unguarded) [read: git log -S, blame and diffs in the history clone; exec: release-level runs, 5.5.5 consistent and 6.0.0 divergent; trunk builds just before and after the commits not executed]; no GitHub PR (svn/JIRA era) (2015-09-02 (caching variant); 2015-10-20 (document variant))
First affected version
6.0.0 [exec: both variants differ from SimpleNaiveBayesClassifier; the first release containing either commit]; last unaffected release 5.5.5 [exec]
Affected versions
Every release from 6.0.0 to 10.5.2, and main a9779ef3c193; never fixed (the class loops are unchanged at upstream main f7bcc0abd816 and branch_10x, and no open PR changes them; read 2026-10-27). [exec] a cross-version probe fails on 6.0.0, 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 and main, each with -ea and -da, and the finder's JUnit fails on 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails on 10.5.2 and 9.12.3, the intended-behaviour check's history check on 6.0.0. [read] all 83 release tags from 6.0.0 on in the history clone carry the guarded parent loop and the two unguarded variant loops; 8.8.2-8.11.4 are not tagged there (8.11.4 executed from the released jars). Not affected [exec]: 5.0.0, 5.3.2, 5.4.0, 5.5.5 (and 5.5.4, the intended-behaviour check): in 5.x the caching classifier's own path was unreachable, so it returned SimpleNaiveBayesClassifier's results (in 5.0.0-5.3.2 the parent itself still offered ''), and the document classifier did not exist. A regression in 6.0.0 relative to 5.x, released for about ten years.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new): three scripted prior-report searches (51 queries each: identifiers, symptoms and history, over GitHub issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive) plus the finder's run (45 queries); about 30 manual GitHub searches over apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET and all of GitHub; all 68 module:classification issues and PRs (the 47 issue threads grepped for empty, unlabel, length and blank); the changed files of all 408 open apache/lucene PRs; 22 JIRA queries over LUCENE and SOLR (SOLR-7739, SOLR-8871 and SOLR-13903 read); about 90 mailing-list queries; the git history of the class loops; web searches. Every hit read, none the same bug. Closest item: #8723 / LUCENE-7672 (open since 2017; patch lucene-solr#152 never merged) reports that CachingNaiveBayesClassifier 'gives different results than the SimpleNaiveBayesClassifier' for a different cause, the missing log prior; its patch leaves '' in the class list, and it does not concern the document classifier. Context only: #7538 (LUCENE-6479, added the guard, commit notices only), #7689 (LUCENE-6631), #8874 (LUCENE-7823, BM25NBClassifier copied the guard), #6402 (LUCENE-5338, labelled documents are those 'with class field'), #6798, #8999, #9158, #8357; #16847 (open PR) widens int to long in these files and leaves the class loops alone. Gaps: GitHub's search rate limit (each covered by an equivalent tool query or by the label and open-PR scans), two web queries throttled, one Stack Overflow page readable by title only, the file list of #16911 capped at 3000 files, private channels not searchable
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: the 3 repro classes on stock with assertions on and off; fix A on the whole classification module with assertions on and off, and the module's static checks; fix B on the whole module with assertions on; the repro runner: this doc's test on main with assertions on and off and with the finder's equivalent fix classes shadowed, and on the 10.5.2 release jars; the finder's test, the independent reproducer's, the devil's-advocate probe, the adjudication's runs, the registry re-runs and the audit, several seeds, assertions on and off); the release jars of 14 releases from 5.0.0 to 10.5.2 (a cross-version probe with assertions on and off; the finder's test on 10.0.0 and 10.5.2; the independent reproducer's on 10.5.2 and 9.12.3). JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:44 (at a9779ef3c193) (@lucene.experimental), :82-83 (classFieldName javadoc: 'the returned class will be a token indexed for this field'), :136-153 (assignClassNormalizedList: class loop, :147 'if (next.length > 0)' skips the empty term)
  • lucene/classification/src/java/org/apache/lucene/classification/BM25NBClassifier.java:124-135 (at a9779ef3c193) (class loop, :129 'if (next.length > 0)')
  • lucene/classification/src/java/org/apache/lucene/classification/CachingNaiveBayesClassifier.java:38 (at a9779ef3c193) ('A simplistic Lucene based NaiveBayes classifier, with caching feature'), :43 (@lucene.experimental), :45 (extends SimpleNaiveBayesClassifier), :84 (protected assignClassNormalizedList override), :236-241 (reInitCache: 'fill the class list', cclasses.add for every class term, no length check)
  • lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:44-50 (at a9779ef3c193) ('A simplistic Lucene based NaiveBayes classifier', @lucene.experimental, extends SimpleNaiveBayesClassifier), :105-132 (assignNormClasses: :118-131 scores every class term, no length check), :72 (constructor passes a null analyzer to super)
  • lucene/classification/src/java/org/apache/lucene/classification/Classifier.java:40 (at a9779ef3c193) and document/DocumentClassifier.java:43 (getClasses javadoc: 'Get all the classes (sorted by score, descending)')
The finding in 30 seconds
  • lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:44, (at a9779ef3c193) 136-158 (:147 the length check), 166-184
  • lucene/classification/src/java/org/apache/lucene/classification/BM25NBClassifier.java:120-139 (at a9779ef3c193) (:129 the length check)
  • lucene/classification/src/java/org/apache/lucene/classification/CachingNaiveBayesClassifier.java:45, (at a9779ef3c193) 76, 83-101, 108, 236-241
  • lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:50, (at a9779ef3c193) 105-136
Who is affected
  • lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:44; (at a9779ef3c193) CachingNaiveBayesClassifier.java:43; document/SimpleNaiveBayesDocumentClassifier.java:48 (@lucene.experimental)
  • lucene/classification/src/test/org/apache/lucene/classification/ClassificationTestBase.java:227, (at a9779ef3c193) 333
Why it looks unintended
  • lucene/classification/src/java/org/apache/lucene/classification/CachingNaiveBayesClassifier.java:38-45; (at a9779ef3c193) document/SimpleNaiveBayesDocumentClassifier.java:44-50; Classifier.java:40; document/DocumentClassifier.java:43; SimpleNaiveBayesClassifier.java:82-83; utils/ConfusionMatrixGenerator.java:80, 92-94
Suggested fix
  • lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:147, (at a9779ef3c193) 166-184; BM25NBClassifier.java:129

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

Repro files

About the diagrams

A step-by-step picture of the repro's example; the PNG is its summary still.

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.