The naive Bayes document classifier scores each class by adding, for every configured field, how likely the document's words are under that class, multiplied by the field's boost. Its constructor javadoc says the field names "can contain boosting indication e.g. title^10".
{"title^10", "body"} across two calls: call 1 reads the boost 10, then writes title back into the array; call 2 reads title and uses boost 1. The class flips from fruit (0.99994) to vehicle (0.55373), the unboosted answer. Executed on main with the six-document index. Select the still to open it at full size. Generated with AI, checked against the code.- Index six documents: three fruit (titles such as "apple banana", bodies such as "sweet juice tree") and three vehicle (titles such as "car truck", bodies such as "wheel engine road fuel"). The new document has a fruit title, "apple banana cherry", and a vehicle body, "engine wheel road fuel".
- Build the classifier with
{"title^10", "body"}, as its javadoc allows, and classify that document twice. You expect fruit both times: with the title's word log-likelihood weighted by 10, the title wins (fruit 0.99994); with equal weight, the body wins narrowly (vehicle 0.55373). - Call 1 returns fruit 0.99994. While reading
title^10it keeps the 10 only for this call, and then writes the bare nametitleback into the field-name array, which is the caller's own array. - Call 2 finds no
^left, uses boost 1 and returns vehicle 0.55373, digit for digit the unboosted answer. So does every later call, and any new classifier built from the same array after that first call. No error is raised. - On upstream's own boosted-classification test data (
title^100), call 1 says batman and call 2 says the video-game class. Parsing the boost on every call, without the write-back, fixes it.
Applications using the opt-in classification module's naive Bayes document classifier with a field^boost that classify more than one document per classifier, or build classifiers from one shared array. In sequential use, from the second call on, every result is silently the unboosted one, with plausible scores; no special data or scale is needed.
Mechanism, terms and numbers731 words
SimpleNaiveBayesDocumentClassifier documents that its field names "can contain boosting indication e.g. title^10" (SimpleNaiveBayesDocumentClassifier.java:63-64). On every assignClass(Document) or getClasses(Document[, max]) call, analyzeSeedDocument splits "title^10" into the name and the boost, keeps the boost in a map made for that call, and then writes the bare name back with textFieldNames[i] = fieldName (:170). That array is the caller's own varargs array, which the parent class keeps by reference (SimpleNaiveBayesClassifier.java:95). From the second call on it holds "title", no ^ is left, and every boost is 1: the classifier silently returns exactly the unboosted scores and classes. On a six-document index the same document is fruit (0.99994) on call 1 and vehicle (0.55373) on call 2; on upstream's testBoostedDocumentClassification data (title^100) the class flips from batman to the video-game class. A second classifier built from the same String[] after that is unboosted even on its own first call. The write-back came with the class in LUCENE-6631, so every release from 6.0.0 to 10.5.2 and main is affected. Parsing each configured entry per call into local arrays, without writing back, fixes it; the whole lucene/classification test suite passes with that change.
SimpleNaiveBayesDocumentClassifier(lucene/classification,@lucene.experimental): classifies aDocumentagainst an index whose documents carry a class field. For each class it adds, per configured field and per value of that field, the class's log prior plus the field's average word log-likelihood times the field's boost (SimpleNaiveBayesDocumentClassifier.java:118-131), then normalizes the scores to sum to 1.- Field boost: the constructor's
textFieldNames"can contain boosting indication e.g. title^10" (:63-64);title^10multiplies the title's word log-likelihood by 10 (the class log-prior, added once per field value, is not boosted). textFieldNames: the varargs array passed to the constructor; the parentSimpleNaiveBayesClassifierkeeps it as aprotected final String[]without copying (SimpleNaiveBayesClassifier.java:55,:95).
- Every
assignClass(Document)and bothgetClasses(Document[, max])overloads callassignNormClasses(:77-103), which creates two fresh maps for the call, tokens per field and boost per field (:108-109), and callsanalyzeSeedDocument(:115). analyzeSeedDocument(:148-172) readstextFieldNames[i](:154); if it contains^, it splits it into the name and the boost (:157-161); it stores tokens and boost under the bare name (:168-169) and then writes the bare name back:textFieldNames[i] = fieldName(:170). The write-back exists so that the scoring loop, which iteratestextFieldNamesagain (:121), finds the bare name in the maps (:122,:128).- On the next call the array holds
title: no^, so the boost stays 1 (:155), and the title's log-likelihood is multiplied by 1 (:125-128). The result is exactly the unboosted classifier's. - The array is the caller's, so once the first classifier has classified a document, a second one built from the same
String[]starts unboosted, and the caller sees its own configuration rewritten.
Index: three fruit documents (titles "apple banana", "apple cherry", "banana cherry"; bodies "sweet juice tree", "sweet garden tree", "juice garden") and three vehicle documents (titles "car truck", "truck bus", "bus car"; bodies "wheel engine road fuel", "engine wheel fuel road", "road engine wheel"). New document: title "apple banana cherry", body "engine wheel road fuel".
Per field (average word log-likelihood): title, fruit -1.386 vs vehicle -2.485 (fruit ahead by 1.099); body, fruit -2.741 vs vehicle -1.426 (vehicle ahead by 1.314); equal priors. Times 10 the title wins by 9.67, times 1 the body wins by 0.22.
| what the code does | result |
|---|---|
unboosted {"title", "body"} | vehicle 0.55373, fruit 0.44627 |
{"title^10", "body"}, call 1 | fruit 0.99994, vehicle 6.3E-5 (right) |
| same classifier, call 2 and call 3 | vehicle 0.55373, fruit 0.44627 (the unboosted result, digit for digit) |
| the caller's array after call 1 | [title, body] |
| a new classifier built from that array, call 1 | vehicle |
upstream fixture {text, title^100, author}, ambiguous Batman document | call 1 batman (1.0); call 2 videogam (0.88291), the unboosted result |
Rows 1, 2 and 5 come from this doc's test on main and 10.5.2, assertions on and off. Call 3 and the array come from this doc's configuration probe on main and the independent reproducer's test on main, 10.5.2 and 9.12.3 (the array after one call). The upstream-fixture row ran on main (and earlier on 10.5.2).
Exposure and likelihood270 words
- Who is exposed. Applications that use
SimpleNaiveBayesDocumentClassifierwith the documentedfield^boostsyntax and classify more than one document with one instance, or build several classifiers from oneString[]. Only a fresh classifier with a fresh array per document escapes, which is what the upstream tests do. - Default vs opt-in. It needs the opt-in
lucene-classificationmodule, this one@lucene.experimentalclassifier and a configured boost; codec,IndexWriterConfigand index size do not matter (the state is in the classifier object).ConfusionMatrixGeneratoris not affected: it calls the text pathassignClass(String), which documents no boost support. - Deterministic and silent. In sequential use, once a first call has completed on an index with class terms, the second call and every later one are unboosted (an independent reproducer: 30 of 30 failures over 10 iterations with a random seed). No exception, no log line, normalized and plausible scores.
- Concurrent use (executed). With 8 threads sharing one array or one classifier, every one of 96,000 results was either the boosted or the unboosted result (1 to 8 boosted per round of 1,200 calls, depending on timing), with no exception or hang (a probe of those runs, not a thread-safety proof; nothing promises thread safety).
- Possible downstream reach (code reading only, not executed). Solr's classification update processor with
algorithm=bayessplitsinputFieldsonce and passes the same array to every newSimpleNaiveBayesDocumentClassifier; if that reading is right, in sequential use boostedinputFields(documented by Solr, e.g.title^1.5) apply only to the first document classified through that shared array. The factory's lifetime and concurrent first calls were not checked, and nothing was run in Solr.
The test shows that SimpleNaiveBayesDocumentClassifier built with the documented field boost "title^10" applies the boost only on its first call; the same document classified a second time gets the unboosted result.
/*
* 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.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.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestSimpleNaiveBayesDocumentClassifierBoostReuse extends LuceneTestCase {
private static Document doc(String title, String body) {
Document doc = new Document();
doc.add(new TextField("title", title, Field.Store.NO));
doc.add(new TextField("body", body, Field.Store.NO));
return doc;
}
private static Document doc(String title, String body, String cls) {
Document doc = doc(title, body);
doc.add(new StringField("cls", cls, Field.Store.NO));
return doc;
}
public void testFieldBoostAppliesOnEveryCall() throws Exception {
Analyzer analyzer = new StandardAnalyzer();
try (Directory dir = newDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
w.addDocument(doc("apple", "seed", "fruit"));
w.addDocument(doc("car", "wheel", "vehicle"));
w.addDocument(doc("bus", "wheel", "vehicle"));
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
SimpleNaiveBayesDocumentClassifier classifier =
new SimpleNaiveBayesDocumentClassifier(
reader, null, "cls", Map.of("title", analyzer, "body", analyzer), "title^10", "body");
ClassificationResult<BytesRef> first = classifier.assignClass(doc("apple", "wheel"));
ClassificationResult<BytesRef> second = classifier.assignClass(doc("apple", "wheel"));
// expected: the title^10 boost applies on every call (same class and score); actual: the second call is unboosted
assertEquals(
first.assignedClass().utf8ToString() + " " + first.score(),
second.assignedClass().utf8ToString() + " " + second.score());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-382/, 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.TestSimpleNaiveBayesDocumentClassifierBoostReuse
- Expected vs actual
- Expected: both assignClass calls return the boosted result
fruit 0.9989943793309103; actual: the second returnsvehicle 0.7933884297520658(the unboosted answer).
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> org.junit.ComparisonFailure: expected:<[fruit 0.9989943793309103]> but was:<[vehicle 0.7933884297520658]>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.classification.document.TestSimpleNaiveBayesDocumentClassifierBoostReuse.testFieldBoostAppliesOnEveryCall(TestSimpleNaiveBayesDocumentClassifierBoostReuse.java:64)- The constructor javadoc allows
title^10, and nothing limits a classifier to one call. - The sibling KNN document classifier parses the same syntax on every call; a stale-boost bug there was fixed as a bug (LUCENE-7991).
- Upstream's boosted test uses each classifier once; on its data, the second call flips the class back.
The full argument225 words
- Contract. Constructor javadoc (
SimpleNaiveBayesDocumentClassifier.java:63-64): the field names "can contain boosting indication e.g. title^10". No single-use limit in the constructor,DocumentClassifier(DocumentClassifier.java:24-66),package-info.java,MIGRATE.mdorCHANGES.txt; the interface classifies "the given Document" and is shaped for repeated calls. - Tests.
TestSimpleNaiveBayesDocumentClassifier.testBoostedDocumentClassification(:114-138) asserts thattitle^100gives batman and that without the boost "wrong classification will appear"; it builds a new classifier per assertion, so the second-call path has been untested since 2015.TestKNearestNeighborDocumentClassifierreuses one instance for two documents (:33-45), andClassificationTestBase.checkOnlineClassificationcallsassignClasstwice on one classifier (withupdateSampleIndex()between the calls) and asserts the same class and score (:121-144). - Sibling and precedent.
KNearestNeighborDocumentClassifier.knnSearchparsesfield^boostinto locals on every call and never writes back (:126-146). LUCENE-7991 (#9039, 7.2.0,CHANGES.txt:7763-7765): "KNearestNeighborDocumentClassifier.knnSearch no longer applies a previous boosted field's factor to subsequent unboosted fields", a stale-boost bug in the sibling, fixed as a bug. - History. The class, the write-back and the boosted test all came in LUCENE-6631 (2015); the private comment "This is done only one time for the Seed Document" (
:139-140) comes from a patch revision described as "improved seed document analysis to happen only one time", that is once per call instead of once per class; nothing in that thread discusses reusing a classifier or rewriting the array.
The devil's-advocate check195 words
The strongest "intended" case: the classifier and the DocumentClassifier interface are @lucene.experimental; the private javadoc says the analysis "is done only one time"; every upstream test builds a new classifier per call, so instances could be read as single-use; the parent class documents "NO boosting supported per field"; and no javadoc promises that the caller's array is left alone.
Why it fails: @lucene.experimental is about API stability, not an option that silently stops working; "only one time" means once per call, since the maps are rebuilt on every call (:108-109), so the code re-derives the boost per call by design and only the write-back defeats it; the parent's "NO boosting" covers the parent's text path, while this class's constructor allows title^10; the sibling classifier and the shared test base reuse instances, and LUCENE-7991 treated stale boost state as a bug. A reusable classifier that gives another class for the same document on the second call is not a plausible design. Verdict: a defect (adjudication: confirmed, high; the independent reproducer and the devil's advocate: real; the other-model-family intended-lens review: real, medium, intended argument weak). No javadoc promises the array is left unchanged.
Recommended Parse per call into per-entry local arrays, never write back
Parse each configured entry on every call into local arrays (name, boost, tokens), score over those, and never write into textFieldNames (+21/-16 in SimpleNaiveBayesDocumentClassifier.java). The repro passes, and so do the whole classification test suite (45 tests, assertions on and off) and the format, lint and forbidden-API checks.
String[] fieldNames = new String[textFieldNames.length];
float[] fieldBoosts = new float[textFieldNames.length];
List<List<String[]>> fieldTokensArrays = new ArrayList<>(textFieldNames.length);
...
for (int i = 0; i < fieldNames.length; i++) {
String fieldName = fieldNames[i];
List<String[]> tokensArrays = fieldTokensArrays.get(i);
... calculateLogLikelihood(fieldTokensArray, fieldName, term, docsWithClassSize)
* fieldBoosts[i];
// analyzeSeedDocument, instead of the map puts and textFieldNames[i] = fieldName:
fieldNames[i] = fieldName;
fieldBoosts[i] = boost;
fieldTokensArrays.add(tokenizedValues);All options and trade-offs4 options · 834 words
Each call then parses the configured strings afresh, scores one entry per configured field with its own boost, and leaves the caller's array alone. Configurations without a repeated field name give results bit-identical to today's first call (run for {title, body} and {title^10, body}; the general case follows from the code). The verification's smaller sketch (loop over the parsed map's keys, drop the write-back) also fixes the reported bug and passes the suite, but it scores a repeated field name only once, which changes the answer of an unboosted configuration such as {"title", "title", "body"} (fruit 0.70741 today, vehicle 0.55373 with it). Parsing once in the constructor into private arrays would work too, and maintainers may prefer it or an immutable internal (name, boost) list: this doc's fix is one validated option, not the only answer. Workaround until a fix ships: build a new classifier with a new array for every call (inline varargs or names.clone()), as the upstream tests do.
Parse per call into per-entry local arrays, never write back (validated by the checks) (recommended)
assignNormClasses allocates String[] fieldNames, float[] fieldBoosts and List<List<String[]>> fieldTokensArrays sized like textFieldNames; analyzeSeedDocument parses as today and fills entry i instead of the two maps and the write-back; the scoring loop runs over i. The unused LinkedHashMap import goes and the private javadoc says textFieldNames is left unchanged. +21/-16 in SimpleNaiveBayesDocumentClassifier.java; private methods only.
String[] fieldNames = new String[textFieldNames.length];
float[] fieldBoosts = new float[textFieldNames.length];
List<List<String[]>> fieldTokensArrays = new ArrayList<>(textFieldNames.length);
...
for (int i = 0; i < fieldNames.length; i++) {
String fieldName = fieldNames[i];
List<String[]> tokensArrays = fieldTokensArrays.get(i);
... calculateLogLikelihood(fieldTokensArray, fieldName, term, docsWithClassSize)
* fieldBoosts[i];
// analyzeSeedDocument, instead of the map puts and textFieldNames[i] = fieldName:
fieldNames[i] = fieldName;
fieldBoosts[i] = boost;
fieldTokensArrays.add(tokenizedValues);Trade-offs
- Repeated field names change meaning. Today every copy of a repeated name gets the boost of its last entry, even on call 1 (
{"title^10", "title", "body"}scores like{"title", "title", "body"}, fruit 0.70741); with this fix each entry keeps its own boost, and both tested orders give the same score (fruit 0.99998); in larger configurations another order could still change low bits (floating-point sums). No upstream test or javadoc covers repeated names. - Parsing (
split,Float.parseFloat) still runs on every call, as today. A malformed boost throws the same exception as today on the first call that parses it; unlike stock, which may already have rewritten earlier entries, the array stays untouched.title^10^2is still read astitle^10, as today. As today, the boost multiplies only the log-likelihood, not the class log-prior added per field value. - No API change; configurations without repeated names are bit-identical to today's first call (executed for
{title, body}and{title^10, body}; the general case follows from the code).
What the checks validated
- Scratch worktree of
a9779ef3c193, gradle, JDK 25: the new test fails 2 of 2 on stock (assertions on and off) and passes with the fix; with the fix the new test,TestSimpleNaiveBayesDocumentClassifierandTestKNearestNeighborDocumentClassifier: 3 suites, 9 tests, 0 failures, assertions on and off; the whole:lucene:classification:test, 13 suites and 45 tests, 0 failures, 1@Monsterskip, assertions on and off;:lucene:classification:check -x test(google-java-format, ecj lint, forbidden APIs) passes. - Standalone runner with the fix shadowed: the new test (
-ea,-da), the finder's test (2 of 2), the independent reproducer's test (3 of 3) and the upstream-data test (3 of 3, call 2 = batman, 1.0) pass; a configuration probe shows the per-entry behaviour above.
Loop over the parsed map's keys and drop the write-back (the verification's sketch) (validated by the checks)
for (String fieldName : fieldName2tokensArray.keySet()) at :121 and no textFieldNames[i] = fieldName (+1/-2).
Trade-offs
Fixes the reported bug, but a repeated field name is scored once: {"title", "title", "body"}, which has no boost, changes from fruit 0.70741 to vehicle 0.55373, and it keeps the position of a name's first occurrence but the tokens and boost of its last: {"title^10", "title", "body"} loses its boost (vehicle 0.55373), {"title", "title^10", "body"} keeps it (fruit 0.99994). Acceptable only with a documented "field names must be unique".
What the checks validated
The new test passes (standalone, -ea); the whole :lucene:classification:test (45 tests, assertions on) passes; the configuration probe shows the changed results above.
Parse textFieldNames into private final name and boost arrays in the constructor (a defensive copy) and use them on every call.
Trade-offs
Same scores as the recommended option, and later changes to the caller's array no longer reach the classifier. A malformed boost (title^x, or title^ with nothing after it) then fails at construction instead of the first call. Not run.
Build new SimpleNaiveBayesDocumentClassifier(..., "title^10", "body") (inline varargs make a new array each time) or pass names.clone() for every classification, as the upstream tests do.
Trade-offs
Application-side only; costs a classifier per call; not a fix.
From reading Solr's source only (not run): Solr's classification update processor with algorithm=bayes builds every classifier from one shared inputFields array, so in sequential use configured boosts there would apply only to the first document classified through that array (factory lifetime and concurrency not checked).
An AI tool was transcribing the classifier's scoring code for an SMT (Z3) model of the classification module's arithmetic and noticed the write-back; the model itself does not cover it. A public-API JUnit test confirmed it on the stock jars, and independent AI tools then reproduced, reviewed and judged it.
The method in full118 words
Method: code reading (the hunt's "non-formal" tier), a hunt unit (classification), 2026-10-26, stock main jars.
- The finder was transcribing
SimpleNaiveBayesDocumentClassifier's score pipeline into a Z3 inventory of the classifiers' division and log sites, and readtextFieldNames[i] = fieldNameinanalyzeSeedDocument. The model covers a zero-token division with a boost factor, not the write-back. - It wrote a 2-test public-API JUnit test (
TestSimpleNaiveBayesDocumentClassifierBoostReuse), which failed on main with assertions on and off and on 10.5.2 and 9.12.3, and a two-line fix sketch that passed it and the upstream classification tests. - Then independent AI tools reproduced, reviewed and judged it (see the checks), and the history step ran the test on eight selected releases back to 6.0.0.
No prior report found (best-effort search, 2026-10-26): the scripted prior-report search's 51 queries over GitHub, commits, downstream projects, four mailing lists, JIRA and the web, plus manual GitHub, JIRA and list searches and the full git history. Nearest: #9039 (LUCENE-7991), a different boost bug in the sibling KNN classifier, fixed in 2017.
Searches4 searches
No issue, PR, review comment, JIRA entry, list thread, commit or web page describes the field boost being written back into textFieldNames, or boosts lost after the first call. Upstream main cf398bfb142c, branch_10x, 10.5.2 and 9.12.3 still have the write-back (read 2026-10-26); upstream main f7bcc0abd816 still has it (re-read 2026-10-27). One open draft PR touches SimpleNaiveBayesDocumentClassifier.java: #16847 ("Support more than 2^31 documents per index (long global doc ids)", opened 2026-10-15, last updated 2026-10-20); it widens document counts from int to long and leaves the scoring loop and the write-back unchanged, so it neither fixes nor reports this bug.
Related, not this bug: #9039 (LUCENE-7991, fixed 2017) fixed a boost leaking onto later fields within one call in the sibling KNearestNeighborDocumentClassifier: precedent. #7689 (LUCENE-6631) introduced the class; all 20 comments were read and none discusses reuse or the write-back. #8999 (LUCENE-7950) is an unrelated NPE in this class; #7494 (LUCENE-6435) an unrelated query-cache exception in the text classifier. SOLR-8871, SOLR-7739 and the Solr classification documentation add and document boosted inputFields for the bayes algorithm, with no report of lost boosts. Gaps: token-based search (title^10 may tokenize differently), one GitHub quota error (retried), private reports not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/solr, all of GitHub) | 2026-10-26 | the scripted prior-report search's 16 issue/PR and 3 commit queries (class names, analyzeSeedDocument, assignNormClasses, textFieldNames, boost, title^10, "boost ignored", "second call") and 10 manual searches; commit history of the touched files on main; open PRs | no report; the related items above; one open draft PR (#16847, int to long document counts) touches the file but not the write-back (found by a fact check's scan of every open PR's changed files, 2026-10-27); a re-check on 2026-10-27 found no new issue or PR |
| JIRA archive | 2026-10-26 | 4 tool queries; LUCENE: class name, textFieldNames, classifier AND boost, DocumentClassifier, and all 47 classification-component issues by title; SOLR: ClassificationUpdateProcessor, classification AND boost, inputFields AND bayes | no report |
| Mailing lists (lists.apache.org) | 2026-10-26 | 20 tool queries over dev@, java-user@, issues@ and commits@; manual searches over the Lucene and Solr user, dev and issues lists | no report |
| Downstream repositories, web and git history | 2026-10-26 | 2 downstream-repo queries (Elasticsearch, OpenSearch, Solr); 6 tool and 8 manual web queries; git log -S 'textFieldNames[i] = fieldName' over the full history | no report; the line was added in 71cea88773e8 and never changed on main |
Present since the class was added by LUCENE-6631 (71cea88773e8, 2015-10-20) and first released in 6.0.0; no later commit touches the write-back. The same test fails on 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main; 5.x has no document classifiers.
Versions tested6 rows
Introduced by 71cea88773e8 "LUCENE-6631 - added document classification api and impls" (2015-10-20, committed by @tteofili, patch from @alessandrobenedetti on JIRA; SVN trunk r1709522; GitHub mirror issue #7689; no PR). The class came with the write-back, the scoring loop over textFieldNames, the title^10 javadoc and testBoostedDocumentClassification; not a regression. The parent has kept the caller's array by reference since LUCENE-5311 (b4a343d6ba9, 2013-11-02, then in train(...)); LUCENE-6045 (92842e7c348, 2015-04-30) moved the assignment into the varargs constructor. That store is not itself the defect. First release 6.0.0 (CHANGES.txt:9314). git log -S finds no later commit on main that touches the write-back; later commits changed only API shape (IndexReader instead of LeafReader in 6.4, test-framework packages in 9.1, ClassificationResult as a record in 10.0). All 83 release tags from 6.0.0 to 10.5.2 in the history clone have the write-back (8.8.2-8.11.3 inferred: not tagged in the clone, bracketed by 8.8.1, the old branch_8x tip and 8.11.4). All release runs used OpenJDK 25.0.3, newer than the JDKs the 6.x-8.x lines targeted; a 6.0.0 run with the fix shadowed passes, so the old-jar failures are not a JDK artifact.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test: 2 of 2 fail (gradle and the standalone runner, assertions on and off): fruit then vehicle, second classifier vehicle |
| 10.5.2 | affected | this doc's test: 2 of 2 fail, the same numbers (assertions on and off) |
| 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 | release sweep | the finder's test (API-adapted copies where needed): 2 of 2 fail, call 1 fruit and call 2 vehicle in every version |
| 6.0.0 + the fix | control | the same test passes with the two-line fix shadowed |
| 5.3.1, 5.5.5 | API absent | no document classifiers (compile error) |
| main + the recommended fix | not affected | every repro passes; the whole lucene/classification suite passes |
An independent reproducer and a devil's advocate found it real with their own executed tests; an adjudication confirmed it with a fresh seed. Read-only reviews by another model family agreed on the root cause (one gave a procedural "not real": it could not run code). An audit and this doc's gradle runs reproduced it on main.
Checks and verdicts424 words
Every check on main ran on the stock jars with assertions on and off (the fix checks with a shadowed class); the finder's release runs used assertions on; this doc's 10.5.2 runs used both. The reviews by the other model family ran read-only (they could not execute code), and a separate run re-ran the finder's test each time, which failed.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; the two-line fix sketch passes and keeps the upstream classification tests green. |
| cross-model review, refute lens | not real (low, procedural) | An independent reviewer from another model family (OpenAI GPT-5.6), read-only: could not run Java, so it voted not real on procedure only; root cause agreed, inputs in contract, intended argument weak; a separate re-run failed as claimed. |
| Independent reproducer | real | Own 3-test JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3, and 30 of 30 with 10 iterations; call 2 equals the unboosted classifier bit for bit; its own fix passes. |
| Cross-model review, intended lens | real (medium) | OpenAI GPT-5.6, read-only: intended argument weak; flagged that the sketch merges repeated names. Executed checks filed with it: on the upstream test data call 2 flips batman to the video-game class; the KNN control keeps its answer. |
| Devil's advocate | real | No intended-behaviour case survives (experimental means API stability; "only one time" means once per call; sibling tests reuse instances; LUCENE-7991). Own test with inline varargs fails on main and 10.5.2. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's test with seed C0FFEE: 3 of 3 fail with -ea and -da; not a duplicate; corrected the reach (ConfusionMatrixGenerator not affected) and asked for a fix that keeps repeated names. |
| security assessment | none | Configuration-triggered, scores only; a concurrency probe (96,000 calls) saw only the boosted or the unboosted result; out of scope under SECURITY.md. |
| registry re-runs and audit | reproduced, keep | Fresh seeds on main: the finder's test fails with -ea and -da, the independent reproduction's fails 3 of 3, the shadow fix passes; a fourth-round audit re-ran it and kept the row; it still reproduces on a9779ef3c193. |
| this doc's validation | reproduced, fix validated | New test: 2 of 2 fail on main (gradle and standalone, -ea and -da) and 10.5.2; with the recommended fix it passes, and so do the whole lucene/classification suite (45 tests) with assertions on and off and check. |
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.
- PNG 476 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,372 words
- Registry title
- SimpleNaiveBayesDocumentClassifier applies a documented 'field^boost' (e.g. 'title^10') only on its first call: it writes the stripped field name back into the caller's textFieldNames array, so every later classification silently returns the unboosted result (released since 6.0.0)
- Area
- classification / document classifiers (SimpleNaiveBayesDocumentClassifier field boosts)
- Priority
- Medium, rank 182: Once a documented field^boost is configured, the experimental naive Bayes document classifier silently drops the boost on every call after the first, a common-trigger silent wrong result in a niche module like LFV-155, and as a new bug it heads the classification rows.
- Impact class
- silent wrong results
- Impact note
- Applications that use the opt-in lucene/classification module's SimpleNaiveBayesDocumentClassifier (@lucene.experimental) with the field^boost syntax its constructor javadoc documents get the boost only on the first assignClass(Document) or getClasses(Document[, max]) call; every later call on the same instance, and every call of a second classifier built from the same String[], silently returns exactly the unboosted scores and classes, with no error, no special data and no scale needed, in every release from 6.0.0 to 10.5.2 and on main.
- Summary
- You build a SimpleNaiveBayesDocumentClassifier with textFieldNames {"title^10", "body"}, as its constructor javadoc allows, and classify documents with it one after another, expecting the title boost on every call. The first call applies the boost but also overwrites "title^10" with "title" in textFieldNames, which is the caller's own array kept by reference, so from the second call on every boost is 1: classifying the same document twice gives fruit (0.99994) and then vehicle (0.55373), exactly the unboosted classifier's answer, and on the upstream test's own fixture ('title^100') the class flips from batman to the video-game class. No error is raised, and a second classifier built from the same String[] is unboosted even on its first call.
- Introduced
- 71cea88773e8fe1086d0f2d7829b17b9c23bcc63 ('LUCENE-6631 - added document classification api and impls', SVN trunk r1709522; JIRA LUCENE-6631 'Lucene Document Classification', GitHub mirror apache/lucene#7689; patch by GitHub alessandrobenedetti, committed by tteofili; no PR, SVN era) [read: git blame on main gives this commit for the scoring loop (:121), the boost split (:154, :158) and the write-back (:170); git log --all -S 'textFieldNames[i] = fieldName' finds no other commit on main] [exec: 6.0.0, the first release, fails; the same test passes on 6.0.0 with the two-line fix shadowed]. The defect came with the class, so it is not a regression; the parent has stored the caller's array by reference since LUCENE-5311 (b4a343d6ba9, 2013-11-02, then in train(...); LUCENE-6045, 92842e7c348, 2015-04-30, moved the assignment into the varargs constructor) and that store is not itself the defect (2015-10-20)
- First affected version
- 6.0.0 [exec] (the first release with SimpleNaiveBayesDocumentClassifier; CHANGES.txt 6.0.0 New Features 'LUCENE-6631: Lucene Document classification'; 71cea88773e is an ancestor of the 6.0.0 tag); 5.3.1 and 5.5.5 [exec]: API absent (compile error; the release jars have no classification/document package)
- Affected versions
- Every release from 6.0.0 to 10.5.2 and main ea57360912ed; not fixed upstream (upstream main cf398bfb142c, branch_10x, releases/lucene/10.5.2 and releases/lucene/9.12.3 still have the write-back, read 2026-10-26). [exec] the finder's JUnit (follow-up, seed DEADBEEF) fails 2 of 2 on 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (main with -ea and -da), always with call 1 boosted and call 2 unboosted; the independent reproduction's JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check's fails on main (-ea, -da) and 10.5.2; the upstream-fixture check (verify-v2) fails on main (-ea, -da) and 10.5.2. [read] all 83 release tags in the history clone that contain 71cea88773e (6.0.0 ... 10.5.2) have the write-back, the scoring loop over textFieldNames, the 'title^10' javadoc and the parent's by-reference store; 8.8.2-8.11.3 are inferred (not tagged in the clone; bracketed by 8.8.1 [read], the old branch_8x tip d76dfaf68a8 [read] and 8.11.4 [exec]). Not applicable to 5.x and earlier (API absent [exec: 5.3.1, 5.5.5]). All runs on JDK 25.0.3; the 6.0.0 fix control rules out a JDK artifact on the old jars
- 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
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record, verdict new: a scripted prior-report search (16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web queries; 99 hits, every high and medium hit read), 10 manual GitHub searches on apache/lucene, apache/solr and all of GitHub, the commit history of the touched files on upstream main, git log -S over the full history, JIRA LUCENE and SOLR text and component searches (all 47 classification-component issues), lists.apache.org over the Lucene and Solr lists, and 8 web searches; the finder's and the intended-behaviour check's searches agree; the adjudication cited them and asked for one more targeted search, which this follow-up search is). Context, not reports: LUCENE-7991 / #9039 (2017, a different boost-state bug in the sibling KNearestNeighborDocumentClassifier, fixed as a bug in different code: precedent), LUCENE-7950 / #8999 (an NPE in this class when no document has the class field; unrelated), LUCENE-6631 / #7689 (the introducing issue; nothing on reuse or the write-back), LUCENE-6435 / #7494 (a query-cache ConcurrentModificationException; unrelated), SOLR-8871 and SOLR-7739 (Solr's classification update processor added 'field^boost' inputFields for the bayes algorithm; no report of lost boosts). One open draft PR touches SimpleNaiveBayesDocumentClassifier.java: #16847 ('Support more than 2^31 documents per index (long global doc ids)', opened 2026-10-15, last updated 2026-10-20); it widens document counts from int to long and leaves the scoring loop (:121) and the write-back (:170) unchanged, so it neither fixes nor reports this bug (found by the fact check's scan of every open PR's changed files, 2026-10-27; a re-check that day found no new issue or PR). Gaps: GitHub search is token-based and hit GitHub's search rate limit once (retried); list and JIRA search may tokenize 'title^10' differently; private reports are not visible
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 release jars (this doc's test, assertions on and off). Earlier checks ran on mainea57360912ed, whose classification module is identical, and the finder's test ran on the releases 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (assertions on). Every run used OpenJDK 25.0.3. The fix was validated in the scratch worktree: the new test,TestSimpleNaiveBayesDocumentClassifier,TestKNearestNeighborDocumentClassifier, the wholelucene/classificationsuite (13 suites, 45 tests) with assertions on and off, and:lucene:classification:check -x test.
- Finding record
lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:48 (@lucene.experimental), :63-64 (constructor javadoc: textFieldNames 'can contain boosting indication e.g. title^10'), :105-136 (assignNormClasses: :109 per-call fieldName2boost, :121 loop over textFieldNames, :128 multiply by fieldName2boost.get(fieldName)), :139-140 ('This is done only one time for the Seed Document'), :148-172 (analyzeSeedDocument: :158 split on '^', :170 textFieldNames[i] = fieldName)lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:55 (protected final String[] textFieldNames), :84 (text path javadoc: 'NO boosting'), :95 (this.textFieldNames = textFieldNames, stored by reference)lucene/classification/src/java/org/apache/lucene/classification/document/KNearestNeighborDocumentClassifier.java:127-146 (the sibling parses the boost into a local on every call and does not write back)lucene/classification/src/test/org/apache/lucene/classification/document/TestSimpleNaiveBayesDocumentClassifier.java:114-137 (testBoostedDocumentClassification: a fresh classifier per call, '^100' changes the class); lucene/classification/src/test/org/apache/lucene/classification/ClassificationTestBase.java:121-145 (checkOnlineClassification: a second assignClass on one instance must agree)
- The finding in 30 seconds
lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:48, :63-64, :66-74, :77-103, :105-136, :138-172lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:55, :84-85, :93-99
- Who is affected
lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:48 (@lucene.experimental), :63-64; lucene/classification/src/java/org/apache/lucene/classification/SimpleNaiveBayesClassifier.java:84-85 (text path: NO boosting supported per field)
- Why it looks unintended
lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:63-64, :105-136, :138-172lucene/classification/src/java/org/apache/lucene/classification/document/DocumentClassifier.java:24-66lucene/classification/src/test/org/apache/lucene/classification/document/TestSimpleNaiveBayesDocumentClassifier.java:114-138 (testBoostedDocumentClassification)lucene/classification/src/test/org/apache/lucene/classification/document/TestKNearestNeighborDocumentClassifier.java:29-45 (one instance, two documents)lucene/classification/src/test/org/apache/lucene/classification/ClassificationTestBase.java:121-144 (checkOnlineClassification: second assignClass agrees)lucene/classification/src/java/org/apache/lucene/classification/document/KNearestNeighborDocumentClassifier.java:126-146lucene/CHANGES.txt:7763-7765 (LUCENE-7991), :9314 (LUCENE-6631)
- Suggested fix
lucene/classification/src/java/org/apache/lucene/classification/document/SimpleNaiveBayesDocumentClassifier.java:105-172; lucene/classification/src/java/org/apache/lucene/classification/document/KNearestNeighborDocumentClassifier.java:126-146
- History
lucene/CHANGES.txt:9314
- TestSimpleNaiveBayesDocumentClassifierBoostReuse.java (JUnit test):
repro/LFV-382/src/org/apache/lucene/classification/document/TestSimpleNaiveBayesDocumentClassifierBoostReuse.java(70 lines)
A still, made in parallel with this doc from an executed per-step probe on main a9779ef3c193 with the six-document index of the repro (stock, and the verification's smaller fix sketch as the control).
assets/LFV-382/bug.png(476 KB)
