The constructor javadoc says the text field names "can contain boosting indication e.g. title^10". The class never strips the ^10: it searches a field named title^10, which does not exist.
text^2 is looked up as a field of that name, so the fuzzy query gets no terms and assignClass returns null (plain text: fruit). Two fields: {"title^10", "body"} drops the title and answers vehicle (0.971), the body-only answer, where {"title", "body"} gives fruit. Select the still to open it at full size. Generated with AI, checked against the code.- Index four documents in one field
text: fruit "apple banana cherry" and "apple pear", vehicle "car truck bus" and "bus train". Build aKNearestFuzzyClassifier(k = 3) with the field writtentext^2, as the javadoc'stitle^10example allows, and classify "apple banana". - You expect fruit, the answer for plain
text(score 0.848): with one field, a boost cannot change which documents are nearest. - You get
null, andgetClassesreturns an empty list. The classifier looks for a field literally namedtext^2; there is none, so its fuzzy query has no terms, and because that query is required, no document matches. - With two fields it is silent. Titles point to fruit, bodies to vehicle. For "apple wheel",
{"title", "body"}gives fruit (0.667), but{"title^10", "body"}gives vehicle (0.971), exactly the body-only answer. Even the no-optitle^1gives vehicle. - The sibling
KNearestNeighborClassifier, whose javadoc has the same sentence, parses the boost and answers fruit. Parsingfield^boosthere fixes both cases.
Applications that use the experimental KNearestFuzzyClassifier and write a boosted field name, the javadoc's own example. Then every call ignores that field, whatever the index settings: one boosted field returns null; with several, the answer comes from the other fields only, with no error. Plain field names are not affected.
Mechanism, terms and numbers540 words
The KNearestFuzzyClassifier constructor documents that its text field names "can contain boosting indication e.g. title^10" (KNearestFuzzyClassifier.java:83-84). The class never parses that syntax: knnSearch passes every entry unchanged to NearestFuzzyQuery.addTerms(text, fieldName) (:137-140), and NearestFuzzyQuery looks up a field literally named title^10, finds none and adds no terms for it (NearestFuzzyQuery.java:133-136). The fuzzy query is a required clause (:141). With one boosted text field nothing matches, so assignClass returns null and getClasses an empty list on every call; with several fields the boosted one is silently left out and the answer comes from the others: on a six-document index, {"title", "body"} classifies "apple wheel" as fruit (0.667) and {"title^10", "body"} as vehicle (0.971), the body-only answer. The sibling KNearestNeighborClassifier carries the same javadoc sentence and parses it. The gap dates from the class's introduction (LUCENE-7838, first released in 7.0.0), so every release from 7.0.0 to 10.6.0 (10.6.0 by reading) and main is affected. Passing the parsed boost into the one NearestFuzzyQuery the class already builds fixes it; the whole lucene/classification test suite passes with that change.
- KNearestFuzzyClassifier: a k-nearest-neighbour text classifier in the opt-in
lucene-classificationmodule (@lucene.experimental). It turns the input text into aNearestFuzzyQueryover the configured text fields, takes the topkdocuments that have a class, and votes. - NearestFuzzyQuery: a trimmed copy of the sandbox
FuzzyLikeThisQuery. For every field it was given, it analyses the text, collects fuzzy variants of each term (one edit; at most 50 per term and field), keeps the best 300 terms over all fields and rewrites to aBooleanQueryofSHOULDterm clauses. field^boost: the syntax the constructor javadoc documents for weighting a field, as intitle^10. The siblingKNearestNeighborClassifierand both document classifiers parse it.
- The constructor stores the caller's
textFieldNamesunchanged (KNearestFuzzyClassifier.java:94); nothing parses or validates it. knnSearchcallsnearestFuzzyQuery.addTerms(text, fieldName)with each entry as given (:137-140).NearestFuzzyQueryasks the index for the terms of the fieldtitle^10, getsnulland returns: that field adds no terms (NearestFuzzyQuery.java:133-136). An index with a field literally namedtitle^5is read from that field, which shows the entry is used verbatim.- The fuzzy query is added as
MUST(KNearestFuzzyClassifier.java:141). With one text field it has no clauses and matches nothing:assignClassreturnsnull(:104-117),getClassesan empty list. With several fields the other fields still match, so the topkcome from them alone.
| index | textFieldNames, input | result |
|---|---|---|
| one field | {"text"}, "apple banana" | fruit 0.8476 |
| one field | {"text^2"}, "apple banana" | null; getClasses [] |
| one field | {"text^2"}, "bus train" | null; getClasses [] (plain text: vehicle 0.7899) |
| two fields | {"title", "body"}, "apple wheel" | fruit 0.6667, vehicle 0.3092 |
| two fields | {"title^10", "body"}, "apple wheel" | vehicle 0.9712 (= {"body"}) |
| two fields | {"title^1", "body"}, "apple wheel" | vehicle 0.9712 |
| two fields | sibling KNearestNeighborClassifier, {"title^10", "body"} | fruit 0.6667, vehicle 0.0778 |
NearestFuzzyQuery.addTerms("apple banana", "text") rewrites to (text:apple)^1.5108256 (text:banana)^1.9162908; with "text^2" it rewrites to an empty query. With either fix, {"text^2"} gives fruit 0.8135 and {"title^10", "body"} fruit 0.6667 (vehicle 0.0509).
Exposure and likelihood207 words
- Who is hit. Users of
KNearestFuzzyClassifier(opt-inlucene-classificationmodule,@lucene.experimental) who write a text field name with a boost, as the constructor javadoc shows. Nothing else is needed: the lookup of the literal name does not depend on the index settings, the codec, the similarity or the index size; the outcomes shown here were run only on the small indexes of this doc. - Every call, deterministic. The lookup does not depend on data or seeds: the test fails 40 of 40 over 10 iterations.
- One boosted field is loud:
nullon every call, so a user would probably notice and drop the boost. - Several fields are silent: the answer is plausible but comes from the other fields only, the opposite of what the user asked for (the field they weighted most counts least). Even
^1changes the answer. - Reach. No downstream user is known: a GitHub-wide issue and PR search for the class name found no user report, and the searches found no public report of the bug since 7.0.0 (2017).
- Not a resource problem. The faulty path does less work than the correct one.
- Not counted here:
getClasses(text, max)on the empty list throwsIndexOutOfBoundsException(KNearestFuzzyClassifier.java:132), the known LUCENE-8110 / #9158.
The test shows that KNearestFuzzyClassifier reads a documented boosted field name such as text^2 as a literal field name, so the field contributes nothing.
/*
* 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 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 TestKNearestFuzzyClassifierBoostedField extends LuceneTestCase {
public void testBoostedFieldName() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
Analyzer analyzer = new StandardAnalyzer()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
Document doc = new Document();
doc.add(new TextField("text", "apple", Field.Store.NO));
doc.add(new StringField("cls", "fruit", Field.Store.YES));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
ClassificationResult<BytesRef> plain =
new KNearestFuzzyClassifier(reader, null, analyzer, null, 1, "cls", "text")
.assignClass("apple");
ClassificationResult<BytesRef> boosted =
new KNearestFuzzyClassifier(reader, null, analyzer, null, 1, "cls", "text^2")
.assignClass("apple");
// expected: documented "text^2" assigns the same class as "text" (fruit); actual: null
assertEquals(
plain.assignedClass().utf8ToString(),
boosted == null ? null : boosted.assignedClass().utf8ToString());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-801/, 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.TestKNearestFuzzyClassifierBoostedField
- Expected vs actual
- Expected
assignClass("apple")with"text^2"to give the same class as with"text"(fruit); actual:null(-ea and -da).
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
=> java.lang.AssertionError: expected:<fruit> but was:<null>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.classification.TestKNearestFuzzyClassifierBoostedField.testBoostedFieldName(TestKNearestFuzzyClassifierBoostedField.java:52)- The constructor javadoc promises the syntax: the text field names "can contain boosting indication e.g. title^10".
- The module writes "NO boosting supported per field" where boosts are unsupported; the three other classifiers with this sentence parse it.
- Following the documented example gives
nullor another class, silently: the contract and the code disagree, so one of them must change.
The full argument183 words
- Contract.
KNearestFuzzyClassifier.java:83-84: "@param textFieldNames the name of the fields used as the inputs for the classifier, they can contain boosting indication e.g. title^10". It is the only documentation of the parameter; no test, CHANGES entry or MIGRATE note says the syntax is unsupported here, and the searches found no issue or list thread that does. - Module convention.
BM25NBClassifier.java:76-77andSimpleNaiveBayesClassifier.java:84-85say "NO boosting supported per field".KNearestNeighborClassifier(:161-168),KNearestNeighborDocumentClassifier(:128-129) andSimpleNaiveBayesDocumentClassifier(:157-158) carry the boost sentence and parse it; the last one writes the stripped name back into the caller's array (:170), so only its first call is boosted (a separate bug, LFV-382). - Tests. The upstream tests build this class with plain field names only, so nothing caught it; the document classifiers' tests use
title^100. - Precedent. LUCENE-7991 (7.2.0) fixed a field-boost bug in
KNearestNeighborDocumentClassifieras a bug. - History. The sentence and its parsing came to
KNearestNeighborClassifiertogether (LUCENE-6654, 2015). LUCENE-7838 (2017) added this class with the same javadoc block but passed the raw names to the query; it never parsed^.
The devil's-advocate check183 words
The strongest case for "not a bug" (the intended-behaviour checker and the cross-model reviews): the whole constructor javadoc was copied from KNearestNeighborClassifier when the class was created (the neighbouring "@param k ... in the MLT results" names MoreLikeThis, which this class never used), so the boost sentence may be a copy too; the class is @lucene.experimental; and ^ is legal in a field name, so a field literally named text^2 works today.
Why it fails: the copy explains where the sentence came from, not that the behaviour is wanted; the published contract says the syntax works, and the module labels the classes without boost support. @lucene.experimental allows API changes, not a documented input that silently returns no class. The javadoc reserves ^ for boosts, as the siblings do. Whether the code or the sentence is the wrong half is for the maintainers to decide; either way the two disagree. Verdict: a defect, at the least a contract mismatch; the copy-paste origin, the experimental status and the absence of any public report since 2017 make the javadoc-only fix a reasonable maintainer choice.
Recommended Pass the boost into the one shared NearestFuzzyQuery (recommended)
Parse field^boost in knnSearch and pass the boost into the one NearestFuzzyQuery the class already builds, through a new addTerms(text, field, boost) that scales that field's term scores. Unboosted configurations rewrite to the same query as today. The new test, the whole classification test suite and the module's checks pass.
// KNearestFuzzyClassifier.knnSearch
for (String fieldName : textFieldNames) {
int caret = fieldName.indexOf('^');
if (caret >= 0) { // "field^boost", e.g. title^10
float boost = Float.parseFloat(fieldName.substring(caret + 1));
nearestFuzzyQuery.addTerms(text, fieldName.substring(0, caret), boost);
} else {
nearestFuzzyQuery.addTerms(text, fieldName);
}
}
// NearestFuzzyQuery: new addTerms(String, String, float); FieldVals keeps the boost
st.score = (st.score * st.score) * idf(df, corpusNumDocs) * f.boost;All options and trade-offs3 options · 782 words
Two code fixes were built and validated; they give the same classes and scores on every probed configuration and, by the code (not run), can differ only when an input yields more than 300 candidate terms. A javadoc-only fix is the third option.
+29/-4 in KNearestFuzzyClassifier.java and utils/NearestFuzzyQuery.java. knnSearch splits field^boost and calls a new public overload NearestFuzzyQuery.addTerms(String, String, float boost); the old two-argument method delegates with 1f. The new method rejects a negative or non-finite boost; FieldVals keeps the boost (also in equals/hashCode), and each term's score is multiplied by it.
// KNearestFuzzyClassifier.knnSearch
for (String fieldName : textFieldNames) {
int caret = fieldName.indexOf('^');
if (caret >= 0) { // "field^boost", e.g. title^10
float boost = Float.parseFloat(fieldName.substring(caret + 1));
nearestFuzzyQuery.addTerms(text, fieldName.substring(0, caret), boost);
} else {
nearestFuzzyQuery.addTerms(text, fieldName);
}
}
// NearestFuzzyQuery: new addTerms(String, String, float); FieldVals keeps the boost
st.score = (st.score * st.score) * idf(df, corpusNumDocs) * f.boost;Trade-offs
- One query and one shared 300-term budget, as today; the boost scales the field's term weights, like
mlt.setBoostFactorin the sibling. Without^the only scoring change is a multiplication by1f, which is exact, so the rewritten query is unchanged (FieldValsalso carries the boost, inequalsandhashCode, so its hash values change);title^1equalstitle. - The scaled score is also used for the 300-term cut, so a boosted field wins more of the budget when an input has more candidate terms than that. The earlier cap of 50 variants per term and field is applied before the boost, as today. Neither cap was reached in any run (the probe indexes have at most 8 distinct terms per field), neither was tested with a fix, and the new overload has no direct unit test.
- Adds one public overload to
NearestFuzzyQuery(an exported package of the module). - It parses on every call, like
KNearestNeighborClassifier(SimpleNaiveBayesDocumentClassifierparses on its first call only), and a field literally namedtitle^5can no longer be addressed (the mechanism probe now gets fruit instead of tool). A malformed boost (title^x,title^) throwsNumberFormatExceptionon every call; parsing once in the constructor would fail fast instead (not run). - The accepted grammar is that of
Float.parseFloat:title^1e1is accepted,title^2^3throws (the sibling reads it as boost 2), and a literal^cannot be escaped. The new overload rejects negative (also-0.0) and non-finite boosts but accepts0, while its message says "positive": reword the message, or reject 0, in the PR. None of these inputs was run.
What the checks validated
- stock: the new test fails 4 of 4 through gradle with assertions on (seed 5EED0811) and off (5EED0812), and again with the final test file (5EED0816);
- fix B: the whole
:lucene:classification:testpasses, 13 suites, 47 tests (the new class's 4 included), 1 skipped (@Monster20 Newsgroups), 0 failures, with assertions on (5EED0813) and off (5EED0814); - fix B:
:lucene:classification:checkpasses (google-java-format, ecj lint, forbidden APIs and the tests, 5EED0817); - standalone runner with fix B shadowed: the new test (assertions on and off), the independent reproduction's test (4 of 4), the finder's test, and the probe (the tables above) pass.
One boosted NearestFuzzyQuery per field when any entry has a boost (the hunt's sketch) (validated by the checks)
+26/-3 in KNearestFuzzyClassifier.java only. Without ^ today's code runs unchanged; otherwise each field gets its own NearestFuzzyQuery, wrapped in a BoostQuery, as a SHOULD clause of a required BooleanQuery. No API change; it mirrors the sibling, which builds one MoreLikeThis query per field.
Trade-offs
Two code paths. With any boost, each field gets its own 300-term budget, so the query can grow to 300 terms per field; and title^1 takes the boosted path, so for long inputs it can differ from title. Raised by the reviewers; not measured.
What the checks validated
- the whole
:lucene:classification:testpasses, 13 suites, 47 tests, 1 skipped, 0 failures (assertions on, 5EED0815), and so does:lucene:classification:check(5EED0818); - standalone runner with fix A shadowed: the new test passes with assertions on and off; earlier runs of the hunt: the finder's test, the 3 upstream
TestKNearestFuzzyClassifiertests, the independent reproduction's test (4 of 4) and the portable history probe pass.
Replace "they can contain boosting indication e.g. title^10" with the module's wording for classes without boost support, "NO boosting supported per field".
Trade-offs
Not run. Removes the broken promise but keeps the silent result for anyone who already writes a boosted name; rejecting entries with ^ would make it loud but would break a field literally named with ^.
A product that labels or routes content with this classifier and a boosted field list silently gets wrong labels; no product that uses this class was found.
An AI tool building a test baseline for the classification module compared the constructor javadoc's field^boost example with the code and saw that the boost is never parsed. A public-API JUnit test confirmed it on the stock jars; independent AI tools then reproduced, reviewed and judged it.
The method in full127 words
Method: a documented-syntax check (the hunt's "non-formal" tier), 2026-10-26, stock main jars.
- While a hunt unit built a property-based baseline for the classification module, it checked the constructor javadoc's documented
field^boostsyntax againstknnSearch. Its fuzz harness never passes a boosted field name, so the harness itself could not find it. The lead was left unfiled. - On 2026-10-27 a later unit turned the lead into a public-API JUnit test with a differential oracle (the unboosted call and the sibling classifier) and re-ran it on main
a9779ef3c193: it failed with assertions on and off and on 10.5.2. - Then independent AI tools reproduced, reviewed and judged it (see the checks), and the history step ran one portable probe on eight releases from 7.0.0 to 10.5.2 plus main.
No prior report found (best-effort search, 2026-10-27): the scripted prior-report search's queries over GitHub, commits, downstream projects, four mailing lists, JIRA and the web, plus manual searches. Nearest: #9039 (LUCENE-7991), a different boost bug in a sibling classifier, fixed in 7.2.0.
Searches4 searches
The searches below found no issue, PR, review comment, JIRA entry, list thread, commit or web page that says that KNearestFuzzyClassifier does not parse the field^boost syntax, or reports its symptom (a null class or a dropped field for a boosted name). It is not fixed upstream: KNearestFuzzyClassifier.java last changed on 2024-09-11 (#13762) on main and branch_10_6, and no open PR touches it.
Related, not this bug: #9039 / LUCENE-7991 (a boost leak in KNearestNeighborDocumentClassifier, fixed in 7.2.0: precedent); #8889 / LUCENE-7838 (introduced the class; the thread is about the sandbox dependency); #7689 / LUCENE-6631 and #7712 / LUCENE-6654 (added field boosting, with parsing, to the siblings). Gaps: token-based search may not match title^10 literally; part of the tool's GitHub results came from a cache written earlier the same day (the key class-name queries were rerun live by hand); list and JIRA search is keyword-based; private reports are not visible. These searches cannot prove that nobody ever reported it.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/solr, all of GitHub) | 2026-10-27 | the scripted prior-report search's 24 issue/PR, 3 commit and 2 downstream queries; by hand: the class names in titles, bodies and comments, all 68 module:classification items, everything created in the last 48 hours, the class name across all of GitHub, the changed files of every open PR touching lucene/classification and of every PR opened 2026-10-26/27 | no report; the related items above; a refresh found no new item touching lucene/classification |
| JIRA archive (LUCENE, SOLR) | 2026-10-27 | 4 tool queries (1 failed on quoting and was rerun by hand) and 8 by hand (class names, fuzzy classifier, classification boosts, all classification-component issues since 2017) | no report |
| Mailing lists (lists.apache.org) | 2026-10-27 | 20 tool queries over dev@, java-user@, issues@ and commits@, plus the Lucene and Solr lists by hand | no report |
| Web | 2026-10-27 | 9 tool queries planned (7 run; the 2 left pending were searched by hand with other wording) and 7 by hand | nothing relevant |
Present since the class was added by LUCENE-7838 (bd9e32d3583, 2017-05-18) and first released in 7.0.0; never changed in this respect. One portable probe fails on 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; 6.6.6 has no such class.
Versions tested6 rows
Introduced by bd9e32d358399af7c31e732314e1ef1dd89bcfa1 "LUCENE-7838 - added knn classifier based on flt" (2017-05-18, @tteofili; JIRA LUCENE-7838 = apache/lucene#8889; committed directly, no PR). It added the javadoc sentence and passed each name unchanged to FuzzyLikeThisQuery.addTerms(text, fieldName, 1f, 2). 92e460389dc (2017-06-29, same issue) replaced that with NearestFuzzyQuery and kept the raw lookup. The sentence and its parsing had come to KNearestNeighborClassifier together in 35810f4c77d (LUCENE-6654, 2015); this class copied the sentence only. Not a regression. First release 7.0.0 (CHANGES.txt:8003). All 65 release tags from 7.0.0 to 10.5.2 in the history clone have the sentence and no ^ parsing. The releases 8.8.2 to 8.11.4 have no tag there (the last 8.x tag is 8.8.1): 8.11.4 was run from its release jars and fails, and 8.8.2-8.11.3, between the two, are inferred, not checked. KNearestFuzzyClassifier.java last changed in f0fd51c4d5c4 (#13762, 2024-09-11) on main and on branch_10_6 (checked read-only on 2026-10-27 and 2026-10-28), so 10.6.0, released from that branch on 2026-10-29, is affected. All release runs used OpenJDK 25.0.3; the bug is a field-name lookup and does not depend on the JVM.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test: 4 of 4 fail (gradle and the standalone runner, assertions on and off); the portable probe fails |
| 10.5.2 | affected | this doc's test: 4 of 4 fail (assertions on and off); the portable probe fails |
| 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 | release sweep | the portable probe fails in every version: text = fruit, text^2 = null; [title, body] = tool, [title^10, body] = null |
| 6.6.6 | API absent | no KNearestFuzzyClassifier (compile error) |
| main + fix A | control | the portable probe passes |
| main + fix B | 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 and an audit re-ran it with fresh seeds. Read-only reviews by another model family agreed on the root cause (one gave a procedural "not real": it could not run code). This doc's gradle runs reproduced it on main.
Checks and verdicts385 words
Every reproducing test (finder, the independent reproduction, the cross-model review stage, adjudication, audit, this doc) failed on the stock main jars with assertions on and off; the mechanism probes and the two separate runs ran with assertions on only. Fix checks shadowed the patched class (standalone runner) or applied the diff (gradle); fix A's gradle suite and check ran with assertions on only. 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 on main (assertions on and off) and 10.5.2: text^2 gives null where text gives fruit; fix A passes it and the 3 upstream TestKNearestFuzzyClassifier tests. |
| 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 moderate (a javadoc-only fix is plausible); flagged fix A's per-field term budget; a separate re-run failed as claimed. |
| Independent reproducer | real | Own 4-test JUnit fails 3 of 4 on main (assertions on and off, 20 iterations), 10.5.2 and 9.12.3; a mechanism probe shows a field literally named title^5 is read; fix A passes 4 of 4. |
| Cross-model review, intended lens | real (medium) | OpenAI GPT-5.6, read-only: intended argument weak, root cause agreed, inputs in contract; strongest counter-case @lucene.experimental and a copied javadoc. A separate run re-ran the finder's test on main and 10.5.2: fails. |
| Devil's advocate | real | No intended-behaviour case survives (copied javadoc, experimental, literal ^ names); found that the finder's sibling assertion does not discriminate and that multi-field configurations drop the boosted field. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's test with seed 1A2B3C4D, assertions on and off: 3 of 4 fail. |
| credibility audit | keep | Re-ran the independent reproduction's test on main (fresh seeds, assertions on and off) and on 10.5.2: 3 of 4 fail; mechanism probe passes; score and dedup checked. |
| this doc | reproduced | New 4-test JUnit: 4 of 4 fail through gradle and the standalone runner on main (assertions on and off) and on 10.5.2; fix B and fix A pass it and the whole classification suite. |
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 468 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,199 words
- Registry title
- KNearestFuzzyClassifier ignores its documented 'field^boost' syntax: an entry such as 'title^10' in textFieldNames is looked up as a literal field name, so that field contributes nothing, and with a single boosted field assignClass returns null
- Area
- classification / KNearestFuzzyClassifier (textFieldNames 'field^boost' syntax; NearestFuzzyQuery field lookup)
- Priority
- Low, rank 339: In the experimental, little-used KNearestFuzzyClassifier, and the single-field case fails visibly (null).
- Impact class
- silent wrong results
- Impact note
- Hits only applications that use the experimental KNearestFuzzyClassifier and follow its constructor javadoc by passing a boosted field name such as 'title^10'; then on every assignClass/getClasses call, with no error, at any scale and with the default IndexWriterConfig, codec and searcher: a single boosted field gives null (getClasses an empty list), and in a multi-field configuration the boosted field is silently dropped while the other fields still work; every release from 7.0.0 to 10.6.0 (10.6.0 by reading) and main.
- Summary
- You build a KNearestFuzzyClassifier with a text field written as 'text^2' or 'title^10', as its constructor javadoc says you may, and expect the same classes as with the plain field name (with that field weighted more). Instead the entry is used verbatim as the name of a field called 'text^2', which does not exist, so that field adds no terms to the query: with one text field assignClass returns null and getClasses an empty list, and with several fields the boosted one is silently ignored. The sibling KNearestNeighborClassifier, whose javadoc has the same sentence, parses the boost and classifies normally.
- Introduced
- bd9e32d358399af7c31e732314e1ef1dd89bcfa1 ('LUCENE-7838 - added knn classifier based on flt', by @tteofili; JIRA LUCENE-7838 = apache/lucene#8889; committed directly, no pull request): the commit that added the class already had the javadoc sentence 'they can contain boosting indication e.g. title^10' and passed each name unchanged to FuzzyLikeThisQuery.addTerms [read: git show]; 92e460389dc (2017-06-29, same issue, in 7.0.0) replaced it with NearestFuzzyQuery and kept the raw-name lookup [read]. The sentence and its parsing came together to KNearestNeighborClassifier in 35810f4c77d (LUCENE-6654, 2015); this class copied the sentence but not the parsing [read: git log -S]. Not a regression. (2017-05-18 (bd9e32d3583); 2017-06-29 (92e460389dc, NearestFuzzyQuery form))
- First affected version
- 7.0.0 [exec: the version-portable history probe fails]; 6.x is not affected because it has no KNearestFuzzyClassifier [exec: 6.6.6 compile error, class absent]
- Affected versions
- every release that has the class: 7.0.0 to 10.5.2, 10.6.0 (by reading) and main a9779ef3c193 (unreleased 11.0) [exec: the history probe fails ('text^2' -> null vs 'text' -> fruit; ['title^10','body'] -> null vs ['title','body'] -> tool) on 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; the finder's JUnit fails on 9.12.3, 10.0.0, 10.5.2 and main (-ea; main also -da); the independent reproduction's JUnit fails 3 of 4 on main, 10.5.2 and 9.12.3 (-ea, -da)]; the releases in between by inference: all 65 release tags 7.0.0..10.5.2 carry the javadoc sentence and no '^' parsing [read]. branch_10_6 and upstream main head have no change to KNearestFuzzyClassifier.java since f0fd51c4d5c4 (2024-09-11) [read], so 10.6.0 (released 2026-10-29) ships it. Executed on Linux aarch64 with JDK 25 only (pre-10 releases too).
- 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 (the prior-report record verdict new, confidence high, searched 2026-10-27, read-only; the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches found nothing either): scripted prior-report search (24 GitHub issue/PR queries on apache/lucene, 3 commit, 2 downstream (Elasticsearch, OpenSearch, Solr), 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA and 9 web queries; 116 distinct hits, 0 high), manual GitHub searches (class names in title/body/comments, all 68 module:classification items, everything created in the last 48 h, GitHub-wide class-name searches, Solr classification boosts), the changed files of every open PR that touches lucene/classification (#16847, #681, #12295) and of every PR opened 2026-10-26/27, 8 JIRA queries (LUCENE and SOLR), lists.apache.org and 7 web searches. Nearest items, none the same bug: #9039 / LUCENE-7991 (2017, fixed: a field-boost leak in KNearestNeighborDocumentClassifier, another class and mechanism), #8889 / LUCENE-7838 (introduced the class; nothing on boosts), #7689 / LUCENE-6631 and #7712 / LUCENE-6654 (added field boosting with parsing to the siblings). Refresh (2026-10-27, read-only): GitHub GraphQL search on apache/lucene for 'KNearestFuzzyClassifier', 'KNearestFuzzy boost', '"boosting indication"' and 'classification created:>=2026-10-25' gives 0 hits, 'NearestFuzzyQuery' only #8889; none of the 19 issues and PRs created since 2026-10-25 (#16905-#16923, incl. #16913) touches lucene/classification; KNearestFuzzyClassifier.java last changed in f0fd51c4d5c4 (#13762, 2024-09-11) on upstream main and branch_10_6. Not fixed upstream. Gaps: GitHub search is token-based and may not match 'title^10' literally; list and JIRA search is keyword-based; private reports are not visible.
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27/28) 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: the finder's and the independent reproduction's tests on main, 10.5.2 and 9.12.3 (the adjudication re-ran the independent reproduction's test on main, the audit on main and 10.5.2), and one portable history probe on the releases 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (6.6.6 has no such class). Every run used OpenJDK 25.0.3 on Linux aarch64. Both fixes were validated in the scratch worktree: the new test, the wholelucene/classificationsuite (13 suites, 47 tests) and:lucene:classification:check; fix B also with assertions off.
- Finding record
lucene/classification/src/java/org/apache/lucene/classification/KNearestFuzzyClassifier.java:44-48 (at a9779ef3c193) (class javadoc, @lucene.experimental), :83-84 (constructor javadoc '@param textFieldNames the name of the fields used as the inputs for the classifier, they can contain boosting indication e.g. title^10'), :135-148 (knnSearch passes each name unchanged to NearestFuzzyQuery.addTerms and adds the query as MUST; fix site)lucene/classification/src/java/org/apache/lucene/classification/utils/NearestFuzzyQuery.java:130-136 (at a9779ef3c193) (addTerms(IndexReader, FieldVals, PriorityQueue): MultiTerms.getTerms(reader, f.fieldName) == null -> return, no terms)lucene/classification/src/java/org/apache/lucene/classification/KNearestNeighborClassifier.java:88-89 (at a9779ef3c193) (same javadoc sentence), :157-172 (knnSearch splits 'field^boost' and applies the boost)
- The finding in 30 seconds
lucene/classification/src/java/org/apache/lucene/classification/KNearestFuzzyClassifier.java:45-49 (at a9779ef3c193) (@lucene.experimental), :83-84 (javadoc), :94, :104-125, :135-148 (knnSearch)lucene/classification/src/java/org/apache/lucene/classification/utils/NearestFuzzyQuery.java:58 (at a9779ef3c193) (MAX_NUM_TERMS), :118-136 (addTerms; getTerms null -> return), :217-263 (rewrite)
- Who is affected
lucene/classification/src/java/org/apache/lucene/classification/KNearestFuzzyClassifier.java:48, (at a9779ef3c193) :132
- Why it looks unintended
lucene/classification/src/java/org/apache/lucene/classification/KNearestFuzzyClassifier.java:81, (at a9779ef3c193) :83-84lucene/classification/src/java/org/apache/lucene/classification/BM25NBClassifier.java:76-77; (at a9779ef3c193) SimpleNaiveBayesClassifier.java:84-85; KNearestNeighborClassifier.java:88-89, :161-168; document/KNearestNeighborDocumentClassifier.java:66-67, :128-129; document/SimpleNaiveBayesDocumentClassifier.java:63-64, :157-158, :170 (writes the stripped name back)lucene/classification/src/test/org/apache/lucene/classification/TestKNearestFuzzyClassifier.java (at a9779ef3c193) (plain names only); document/TestKNearestNeighborDocumentClassifier.java:149, document/TestSimpleNaiveBayesDocumentClassifier.java:122 (title^100)lucene/CHANGES.txt:7763-7765 (at a9779ef3c193) (LUCENE-7991, Lucene 7.2.0)
- Suggested fix
lucene/classification/src/java/org/apache/lucene/classification/utils/NearestFuzzyQuery.java:58, (at a9779ef3c193) :217-224lucene/classification/src/java/org/apache/lucene/classification/KNearestNeighborClassifier.java:157-173 (at a9779ef3c193)lucene/classification/src/java/org/apache/lucene/classification/BM25NBClassifier.java:76-77 (at a9779ef3c193) ("NO boosting supported per field")
- History
lucene/CHANGES.txt:8003 (at a9779ef3c193) (LUCENE-7838 under Lucene 7.0.0)
- TestKNearestFuzzyClassifierBoostedField.java (JUnit test):
repro/LFV-801/src/org/apache/lucene/classification/TestKNearestFuzzyClassifierBoostedField.java(58 lines)
A still, made in parallel with this doc from an executed probe on main a9779ef3c193 with the same two indexes (stock, and the per-field fix sketch as the control).
assets/LFV-801/bug.png(468 KB)
