All findings · Lucene finding · LFV-682 · datasetsplitter-test-ratio-capped-at-half

DatasetSplitter puts at most about half of each class into the test index, whatever testRatio is

DatasetSplitter lets a document into the test index only at every other position, so a test share above one half is silently cut to about half: testRatio 0.8 on 10 documents gives 5 test documents, not 8, while crossValidationRatio 0.8 gives 8. Every release since 4.2.0 (2013) is affected.

Summary

Priority
Low, rank 343In the little-used offline DatasetSplitter utility, and only for a testRatio above 0.5.
Impact
silent wrong results
Since
4.2.0 (LUCENE-4345, 2012)introduced 2012-11-29 · over 13 years
Affected
4.2.0 to 10.5.2 and main
Status
documented
Found by
bounded check of the split-size rule while modelling the class

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

DatasetSplitter copies an index into training, test and cross-validation indexes, class by class, for classifier experiments. Both ratios are documented as the share of the original index for that output, "between 0.0 and 1.0".

DatasetSplitter on one class of 10 documents with testRatio 0.8, from an executed trace on Lucene main: 10 x 0.8 = 8 test documents are expected, but the split loop admits a document to the test index only when the running counter b is even (if (b % 2 == 0 && tc < testSize)), so d0, d2, d4, d6 and d8 go to test and d1, d3, d5, d7 and d9 to training: 5/0/5. The cross-validation branch has no such check, so crossValidationRatio 0.8 gives 0/8/2. Affected 4.2.0 to 10.5.2 and main; fix: keep the interleave and send every remaining document to test once it is needed to reach testSize, giving 8/0/2.
One class of 10 documents, testRatio 0.8: only the even positions d0, d2, d4, d6 and d8 can enter the test index, so it gets 5 instead of 8 and training gets the other 5. The fix keeps the interleave and fills up to 8 (training keeps d1 and d3). Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 10 documents of one class and split them with new DatasetSplitter(0.8, 0).split(...): 80% for the test index, none for cross-validation, the rest for training.
  2. You expect 8 test and 2 training documents, just as new DatasetSplitter(0, 0.8) puts 8 of the 10 into the cross-validation index.
  3. You get 5 test and 5 training documents, with no error. testRatio 1.0 also gives 5; 0.6 with cross-validation 0.2 gives 5/2/3 instead of 6/2/2.
  4. Why: a document may go to test only when a running counter is even, if (b % 2 == 0 && tc < testSize); the cross-validation branch has no such check. So each class gives the test index at most about half its documents.
  5. On 1,000 documents in 10 random classes, shaped like Lucene's own TestDataSplitter fixture, every testRatio from 0.5 to 1.0 gives exactly 500 test documents. Up to 0.5 the sizes are right, up to per-class rounding.

Who is affected

Anyone who prepares classifier data with DatasetSplitter (the opt-in classification module) and asks for a test share above one half, for example a small-training-set experiment. The split is wrong every time, with no error: they train on more and evaluate on fewer documents than configured. testRatio up to 0.5 is unaffected.

Mechanism, terms and numbers621 words

DatasetSplitter(testRatio, crossValidationRatio).split(...) copies an index into test, cross-validation and training indexes, class by class. Both ratios are documented in the same words, as the share of the original index for that output, "between 0.0 and 1.0". But a document may enter the test index only when a running counter b is even (if (b % 2 == 0 && tc < testSize)), and the cross-validation branch has no such condition. So each class gives the test index at most about half of its documents, whatever testRatio asks for, and the documents it should have taken silently go to training (cross-validation still gets just its own share unless the two ratios together ask for more than the whole class). One class of 10 documents with testRatio 0.8 gives test/cv/training 5/0/5 instead of 8/0/2; crossValidationRatio 0.8 gives 0/8/2 as documented. It is deterministic, the same with assertions on and off, raises no error, and is in every release that has the class (4.2.0 through 10.5.2) and main. testRatio up to 0.5 is honoured up to per-class rounding. Keeping the interleave but sending every remaining document to test once it is needed to reach the test size fixes it (+6/-2 lines) and leaves splits with testRatio up to 0.5 unchanged; the repro and the whole classification test suite pass with it.

Terms

  • DatasetSplitter (lucene/classification, org.apache.lucene.classification.utils): "Utility class for creating training / test / cross validation indexes from the original index" (DatasetSplitter.java:44-46). The constructor takes testRatio and crossValidationRatio, both documented as "the ratio of the original index to be used for the test IDX" ("c.v. IDX") "as a double between 0.0 and 1.0" (:55-58); it checks neither (:60-63). What is neither test nor cross-validation goes to training.
  • split(...) groups the live documents by the class field (sorted doc values, :74-75), groups and documents in index order (:119-126), and sends each document to one of the three new indexes.

The bug

  1. One counter, int b = 0; (:136), counts every document of every class and is never reset (b++, :162).
  2. For each class, testSize = totalHits * testRatio and cvSize = totalHits * crossValidationRatio (:142-146).
  3. A document goes to test only if (b % 2 == 0 && tc < testSize) (:153), else to cross-validation if (cvc < cvSize) (:156), else to training (:159-160). The cross-validation branch has no parity condition.
  4. So a class of n documents gives the test index at most ceil(n/2) documents (floor(n/2) when it starts on an odd b). Below one half, tc < testSize usually stops first and the size is right up to rounding (close to one half, a class that starts on an odd b can get one test document fewer); above one half, the parity check stops first. The odd positions fall through to cross-validation until cvSize is reached, then to training, so with ratios that sum to at most 1 the missing test documents end up in training.

The numbers (one class of 10 documents, executed on main, assertions on and off)

testRatio, crossValidationRatioexpected test/cv/trainingstockwith the fix
0, 0.8 (control)0/8/20/8/20/8/2
0.2, 0 (control)2/0/82/0/82/0/8
0.5, 0 (control)5/0/55/0/55/0/5
0.8, 08/0/25/0/58/0/2
1.0, 010/0/05/0/510/0/0
0.6, 0.26/2/25/2/36/2/2

With testRatio 0.8 the test index holds d0, d2, d4, d6 and d8, and training d1, d3, d5, d7 and d9. On 1,000 documents in 10 random classes, shaped like Lucene's own TestDataSplitter fixture, every testRatio from 0.5 to 1.0 gives 500 test documents, and testRatio 0.7 with cross-validation 0.1 gives 500/106/394 instead of about 700/100/200.

Exposure and likelihood286 words
  • Who is exposed. Applications that build training, test and cross-validation indexes with DatasetSplitter (opt-in lucene/classification module; the class is not marked experimental) and pass a testRatio above 0.5, which the javadoc allows, for example a learning-curve or small-training-set experiment (70% test, 10% cross-validation, 20% training). Nothing in Lucene calls the class outside tests; the in-tree callers use testRatio 0.1, 0.2 and 0.2.
  • Default vs opt-in. There is no default ratio: the caller passes both. testRatio up to 0.5 is unaffected (up to per-class rounding). No index-size condition: 10 documents are enough.
  • Silent and deterministic. The same wrong split every time, with assertions on and off, and no error or log line. In a probe on a fully labelled index the outputs were well-formed (CheckIndex clean), every live document landed in exactly one of them, and the source index was untouched, so nothing looks wrong. (With unlabelled documents a labelled class can vanish from all outputs: LFV-683, a separate bug.)
  • Scale. At most about half of the index can ever be test data (per class ceil(n/2) or floor(n/2), depending on where the shared counter starts); the shortfall grows with the ratio (testRatio 1.0: about half of every class is missing from the test index).
  • Bounded. No crash and no corruption in the probe, and no cost that grows with the ratio: a grouped search over the live documents (a first and a second pass) and one copy loop, 8-115 ms per split whatever the ratio (main only, one synthetic 400-document index, one host, so the timing is directional).
  • How likely. Rare: test sets larger than training sets are unusual, and no public report was found in the sources searched, although the class has shipped since 4.2.0 (2013).

Minimal repro

The test shows that new DatasetSplitter(0.8, 0).split(...) on one class of 10 documents puts only 5 documents into the test index.

TestDatasetSplitterTestRatio.java (JUnit test)53 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.classification.utils;

import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.SortedDocValuesField;
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 TestDatasetSplitterTestRatio extends LuceneTestCase {

  public void testTestRatioAboveHalf() throws Exception {
    StandardAnalyzer analyzer = new StandardAnalyzer();
    try (Directory source = new ByteBuffersDirectory();
        Directory training = new ByteBuffersDirectory();
        Directory test = new ByteBuffersDirectory();
        Directory cv = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(source, new IndexWriterConfig(analyzer))) {
        for (int i = 0; i < 10; i++) {
          Document doc = new Document();
          doc.add(new SortedDocValuesField("class", new BytesRef("a")));
          w.addDocument(doc);
        }
      }
      DatasetSplitter splitter = new DatasetSplitter(0.8, 0);
      splitter.split(DirectoryReader.open(source), training, test, cv, analyzer, false, "class");
      try (DirectoryReader testReader = DirectoryReader.open(test)) {
        // expected 8 of the 10 documents in the test index (testRatio 0.8); actual 5
        assertEquals("test index size for testRatio 0.8", 8, testReader.numDocs());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-682/, 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.utils.TestDatasetSplitterTestRatio
Expected vs actual
Expected: 8 (testRatio 0.8 of 10, as documented for the 0.0-1.0 range); actual (-ea and -da): 5, because the guard b % 2 == 0 && tc < testSize (DatasetSplitter.java:153) admits only every other document.

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

More files, environment, full output39 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: test index size for testRatio 0.8 expected:<8> but was:<5>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.classification.utils.TestDatasetSplitterTestRatio.testTestRatioAboveHalf(TestDatasetSplitterTestRatio.java:49)

Why it looks unintended

  • Both ratios have the same javadoc, a share of the original index "between 0.0 and 1.0"; only the cross-validation ratio is honoured over that range.
  • The redesign that introduced today's loop (LUCENE-7196) states the intent "split-ratio * | docs in c | documents for each class c".
  • TestDataSplitter expects maxDoc x testRatio test documents, yet tests only 0.1 and 0.2.
The full argument204 words
  • The contract. "@param testRatio the ratio of the original index to be used for the test IDX as a double between 0.0 and 1.0", and "@param crossValidationRatio the ratio of the original index to be used for the c.v. IDX as a double between 0.0 and 1.0" (DatasetSplitter.java:55-58, wording unchanged since 2012). Only the cross-validation ratio is honoured over that range. No javadoc, package doc, CHANGES or MIGRATE entry mentions a cap, and the constructor checks nothing (:60-63).
  • The author's stated intent. LUCENE-7196 (#8251, 2016), opened by the class's author and the change that introduced today's per-class loop: "the generated index should contain split-ratio * | docs in c | documents for each class c".
  • Lucene's own test oracle. TestDataSplitter.assertSplit expects (int) (originalIndex.maxDoc() * testRatio) test documents within 20 (TestDataSplitter.java:128) but runs only testRatio 0.1 and 0.2 (:91, :95); the 2012 test asserted the exact size. On the same fixture shape, testRatio 0.7 fails that oracle: expected:<700.0> but was:<500.0>.
  • No other reading explains it. The cap does not protect the training share: nothing limits cross-validation, so training can get far less than half (testRatio 0.2 with cross-validation 0.7 leaves 90 of 1,000 documents, 9%, for training).
The devil's-advocate check186 words

The strongest "works as designed" case (the intended-lens review by another model family rated it moderate; the devil's advocate built it in full): the every-other-document interleave is longstanding and appears intentional, in the first version (2012) and kept by the same author through the LUCENE-7196 rewrite, although no comment, issue or commit message says why; every in-tree caller uses test ratios of 0.2 or less; test sets larger than training sets are unusual; and "between 0.0 and 1.0" could be read as the type's range, not a promise for every value.

Why it fails: keeping the interleave suggests intent about which positions are sampled, not about silently cutting the size, and the fix keeps it. The identically documented cross-validation ratio is honoured, the author's stated intent and the project's own size oracle both say size = ratio x class size, and nothing documents or enforces a limit. Verdict: a defect (adjudication: confirmed, high; every verifier: real). The cross-model fact check of this doc rates the "works as designed" case moderate and suggests the most neutral framing: a documentation and validation defect with an apparent behavioural mismatch.

Suggested fix

Recommended Keep the interleave, but take every remaining document once it is needed to reach the test size

Keep the every-other-document interleave, but once the rest of a class is needed to reach the test size, send every remaining document to test (+6/-2 lines). Splits with testRatio up to 0.5 stay exactly as today. The repro, the whole classification test suite (48 tests) and the module's format and lint checks pass.

        int i = 0;
        for (ScoreDoc scoreDoc : group.scoreDocs()) {
          ...
          // add it to one of the IDXs; interleave test documents, but take every remaining
          // document once the rest of the group is needed to reach the test size
          boolean needed = totalHits - i <= testSize - tc;
          i++;
          if ((b % 2 == 0 || needed) && tc < testSize) {
All options and trade-offs4 options ยท 726 words

A maintainer may instead prefer to document the limit or reject testRatio above 0.5 (option C), which would make this a documentation or validation defect. Workaround until a fix ships: for a test share above one half, pass it as crossValidationRatio (honoured over the whole range) and use the cross-validation output as the test index. If a real cross-validation set is needed too, swap both: pass the wanted cross-validation share as testRatio and read the test output as the cross-validation set; that share is then capped the same way, so keep it at or below 0.5 (and the two shares at most 1 together). Separate hardening note, not this bug: the constructor accepts out-of-range ratios (NaN, negative, above 1) without validation; a probe saw bounded, exception-free behaviour.

Keep the interleave, but take every remaining document once it is needed to reach the test size (validated by the checks) (recommended)

In the per-class loop of DatasetSplitter.split, count the documents seen in the class and also admit a document to the test index when the documents left in the class are no more than the test documents still missing. +6/-2 in DatasetSplitter.java.

        int i = 0;
        for (ScoreDoc scoreDoc : group.scoreDocs()) {
          ...
          // add it to one of the IDXs; interleave test documents, but take every remaining
          // document once the rest of the group is needed to reach the test size
          boolean needed = totalHits - i <= testSize - tc;
          i++;
          if ((b % 2 == 0 || needed) && tc < testSize) {

Trade-offs

  • testRatio up to 0.5 gives exactly the same split as today, document by document: a transcription of the loop found no difference in the per-class counts over 92,400 configurations, nor (in the fact check) in any document's destination over 2,325,600 configurations; the fixture sweep agrees for 0.1, 0.3 and 0.5.
  • Above 0.5 each class gets at least floor(n x testRatio) test documents and cross-validation at least min(floor(n x cvRatio), n - test) (same transcription, for ratios in [0, 1] that sum to at most 1, which the constructor does not enforce). So the fix rounds down above 0.5 where the stock loop rounds up below it: on the 1,000-document fixture 595, 695, 795 and 894 rather than about 600, 700, 800 and 900. The cross-validation and training documents then come from the front of each class (10 documents, testRatio 0.8: training gets d1 and d3).
  • Constant extra work per document (an increment, two subtractions and one comparison); the split stays linear.

What the checks validated

  • Scratch worktree of main a9779ef3c193, gradle: the new test passes 5 of 5 with TestDataSplitter; the whole :lucene:classification:test passes with assertions on and off (13 suites, 48 tests, 0 failures, 1 @Monster test skipped).
  • :lucene:classification:check -x test passes (checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest).
  • Standalone runner with the fix shadowed: this doc's test (-ea, -da), the devil's advocate's upstream-oracle test (2 of 2; 0.7 with cross-validation 0.1 gives 695/106/199), the finder's (1 of 1), the independent reproducer's (7 of 7) and the 42 upstream classification tests pass.

Drop the parity guard (validated by the checks)

Change if (b % 2 == 0 && tc < testSize) to if (tc < testSize): each class gives its first test-size documents to test, the next to cross-validation, the rest to training.

Trade-offs

Simplest, one line. It changes which documents are picked at every ratio, including the common 0.1-0.2 (a block instead of every other document), so existing splits are not reproducible across the change.

What the checks validated

Standalone runner only: this doc's test 5 of 5 and TestDataSplitter 2 of 2 pass; the independent reproducer's 7 tests pass.

Document the limit, or reject testRatio above 0.5

A javadoc note on the constructor, or an IllegalArgumentException for testRatio > 0.5; the same change could validate both ratios (in [0, 1], sum at most 1).

Trade-offs

No split changes; the limit becomes explicit instead of silent. Users who want a larger test share must swap the roles of the ratios. Not run.

Spread test documents proportionally over the class

For example, take position i when floor((i + 1) x testRatio) > floor(i x testRatio).

Trade-offs

Honours any ratio and samples the whole class, but changes every split; more than a bug fix needs. Not run.

Downstream note

Downstream relevance is limited to experiment correctness: a classifier evaluated on such a split is trained on more and measured on fewer documents than configured.

How it was found

While writing down DatasetSplitter's assignment rule for a reference model, the finder noticed the parity check and probed the size rule: one class of 100 documents, testRatio 0.6, 0.8 and 1.0 all gave 50 test documents, while cross-validation 0.8 gave 80. Independent AI tools then reproduced, reviewed and judged it.

The method in full120 words

Method: semi-formal (the hunt's tier for tool-guided finds that are not a formal tool's own output): a bounded check of the split-size rule on the real jar, a hunt unit (classification, k-NN and utils against reference semantics), 2026-10-27, stock main jars.

  • The signal came from code reading while modelling: the test branch has a parity condition the cross-validation branch lacks.
  • The probe (SplitterRatio.java) split one class of 100 documents with seven ratio pairs: testRatio 0.5, 0.6, 0.8 and 1.0 all gave 50 test documents; testRatio 0 with cross-validation 0.8 gave 80; 0.1/0.1 gave 10/10 and 0.3/0.6 gave 30/60.
  • It was reduced to a public-API JUnit test, and independent AI tools reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (best-effort search, 2026-10-27): three tool runs (43, 39 and 52 queries) over GitHub, the mailing lists, JIRA and the web, plus all 409 open PRs and every classification-module issue. Closest: LUCENE-7196 (#8251), the 2016 redesign that kept the check.

Searches4 searches

No issue, PR, commit, JIRA entry, list thread, Stack Overflow question or web page reports or discusses DatasetSplitter capping the test index at about half, or testRatio above 0.5 not being honoured, and none calls the cap by design. The identifiers testRatio and crossValidationRatio return no hits on GitHub, JIRA or any list searched. Not fixed: upstream main a95c754ee003 (2026-10-26) and branch_10x still have the check unchanged.

Closest items, none a report of this bug: #8251 (LUCENE-7196) is the per-class redesign; it kept the guard and states the size intent. #5411 (LUCENE-4345) introduced the class; no comment discusses its ratios. #8453 (LUCENE-7400) and #8642 (LUCENE-7591) concern class grouping and counting. #12942 is a different DatasetSplitter failure (topNGroups must be >= 1). #16847 is the only open PR found touching the file and changes one unrelated line. Gaps: GitHub search is token-based and GitHub's search rate limit was reached (throttled queries were retried); the open-PR scan listed only 3000 of the 7630 files of one branch-decommission PR; private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, lucene-solr, solr, lucenenet; two downstream search engines; GitHub-wide)2026-10-27three scripted prior-report search runs: 43 queries (2,646 hits, 3 ranked high, unrelated term overlap), 39 queries (97 hits, 7 ranked high, all related history, none a report) and 52 queries including 7 web queries (347 distinct hits, 0 high); 21 manual queries; the whole module:classification label (68 items)no report; closest #8251, #5411, #8453, #8642, #12942
Open apache/lucene PRs2026-10-27the changed files of all 409 open PRsonly #16847 was found to touch the file (one unrelated line; one PR's file list was truncated at 3000 of 7630 files)
JIRA archive and mailing lists2026-10-278 JQL queries including all 47 classification-component issues; dev@, java-user@, issues@, general@, commits@ and the Solr listsno discussion of the split ratios
Stack Overflow and the web2026-10-274 Stack Overflow and 7 web queriesnothing relevant

History

Introduced with the class itself in LUCENE-4345 (2012, 3459b28334e) and first released in 4.2.0 (2013); the 2016 per-class redesign kept the check. The repro fails on all 14 releases run from 4.2.0 to 10.5.2 and on main; no earlier release has the class.

Versions tested6 rows

Introduced by 3459b28334e (trunk, 2012-11-29, @tteofili, svn r1415060, "[LUCENE-4345] - added utility class for creating training / test / cv index from a single index"; issue LUCENE-4345, now #5411; no pull request). The first version already had if (b % 2 == 0 && testWriter.maxDoc() < size * testRatio) for the test branch and no parity condition for cross-validation; the constructor javadoc came the same day (232ca1503d2). It reached a release through the branch_4x backport 21e86983ed4 (LUCENE-4781, #5846, 2013-02-20): first release 4.2.0 (its tag points to a commit of 2013-03-11). 4.1.0 and earlier have no DatasetSplitter.

Kept in the redesign. LUCENE-7196 (#8251) made the split per class and kept b % 2 == 0. It landed on trunk as 112078eaf909 (2016-04-08) and was cherry-picked to 6.x as ff3661abdaa, which first shipped in 6.1.0; git blame points the line at 112078eaf909 only because the right half of the condition was rewritten. Not a regression: the check is in all 119 release tags of the history clone that contain the class, and upstream main a95c754ee003 still has it.

Executed and read. 14 releases were run (assertions on) and main (on and off); the other releases are read from their tags. The history clone has no tags for 8.8.2 to 8.11.4: 8.8.2 to 8.11.3 were neither run nor read and count as affected because they lie between the executed 8.8.1 and 8.11.4.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test: 3 of 5 fail (gradle and the standalone runner, assertions on and off), controls pass; the finder's test: 1 of 1 fails
10.0.0, 10.5.2, 9.12.3 affectedthis doc's test (assertions on): 3 of 5 fail, the same counts
4.2.0, 4.10.4, 5.0.0, 6.0.1, 6.1.0, 6.3.0, 6.4.0, 7.7.3, 8.8.1, 8.11.4, 9.0.0 release sweepera-specific main() drivers (4.2.0-8.11.4) and the finder's JUnit (9.0.0), assertions on: testRatio 0.8 on 10 documents gives 5/0/5; the cross-validation 0.8 control gives 8 of 10
4.2.0, 6.1.0, 8.8.1 with only b % 2 == 0 && removed shadow control8/0/2: the drivers detect exactly this check
4.1.0 and earlier no APIthe class does not exist
main + the fix not affectedevery repro passes; the whole classification test suite and the module's checks pass

Independent checks

An independent reproducer, two executed reviews by another model family and a devil's advocate each found it real with their own tests; an audit re-ran the finder's test, and an adjudication confirmed it by re-running two of the tests with a fresh seed. This doc's own test reproduced it through gradle and validated the fix.

Checks and verdicts590 words

The checks on main ran on the stock jars (the fix checks with a shadowed class); the finder's, the independent reproducer's, the refute-lens reviewer's and this doc's tests ran with assertions on and off, the other checks with one setting, as listed. The reviews by the other model family (OpenAI GPT-5.6) executed their own tests on main ea57360912ed, whose classification and grouping sources equal a9779ef3c193; the review step re-ran the intended-lens reviewer's test on a9779ef3c193, and the refute-lens reviewer's test ran only on ea57360912ed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateSize-rule probe (100 documents, seven ratio pairs); public-API JUnit fails 1 of 1 on main (-ea, -da), 10.5.2 and 9.12.3; passes with the fix, as do the 42 upstream classification tests.
Independent reproducer realOwn 7-test JUnit fails 4 of 7 on main (-ea, -da), 10.5.2 and 9.12.3 (for example 20 documents, testRatio 1.0: 10 instead of 20); controls pass; removing only the parity check makes all 7 pass, and so does the fix.
Cross-model review, refute lens real (high)Another model family (OpenAI GPT-5.6), executed on main ea57360912ed: two classes of 8, testRatio 0.75 gives 8/0/8 instead of 12/0/4 after a passing 0.5 control, also with -da; passes with the fix. Intended-behaviour argument: moderate.
Cross-model review, intended lens real (high)Own JUnit on 12 documents, assertions on: testRatio 0.75 gives 6 instead of 9 while cross-validation 0.75 gives 9, on main ea57360912ed, 10.5.2 and 9.12.3, and again on a9779ef3c193 when the review step re-ran it; with the fix the 42 non-Monster classification tests pass. A separate sweep on a9779ef3c193 (-ea, -da): 0.55 to 1.0 on 20 documents all give 10.
Devil's advocate realBuilt the strongest "works as designed" case and rejected it; on the upstream fixture shape every testRatio from 0.5 to 1.0 gives 500 of 1,000 test documents, and the upstream oracle fails at 0.7 (assertions on).
adjudication confirmed (high)Re-ran the independent reproduction's JUnit with seed 1234ABCD and -da (4 of 7 fail, controls pass) and the finder's with seed 1234ABCD and -ea (1 of 1 fails), and the independent reproduction's with the fix (7 of 7 pass); no duplicate.
registry re-runs, security probe and audit keepNew seed 5EED7A71: the finder's test fails with -ea and -da. A security probe (one fully labelled index: 16 segments, 4 classes, 57 deletions) found only wrong sizes: every live document in exactly one output, CheckIndex clean. The audit re-ran the finder's test (-ea) and kept the row.
this doc's runs reproduced, fix validatedThe doc's 5-test JUnit fails 3 of 5 through gradle (assertions on and off) and the standalone runner on main, 10.0.0, 10.5.2 and 9.12.3, controls passing; with the fix it and the whole classification test suite pass, and :lucene:classification:check -x test passes.
fact checks of this doc confirmed; wording correctedAn independent fact check re-ran this doc's gradle command on a clean copy of a9779ef3c193 (3 of 5 fail with -ea and with -da), validated the fix (the whole classification suite: 13 suites, 48 tests) and compared every document's destination with and without the fix for testRatio up to 0.5 (2,325,600 configurations, 0 differences). A second fact check by another model family (OpenAI GPT-5.6) re-checked the claims against the code and the run logs. Both found the bug real; their wording and attribution corrections are applied in this doc.

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

Finding record

LFV-682, snapshot of 2026-11-06

Registry title
DatasetSplitter puts at most about half of each class into the test index, whatever testRatio is
Area
classification / utils: DatasetSplitter (split, test/cv/training assignment loop)
Priority
Low, rank 343: In the little-used offline DatasetSplitter utility, and only for a testRatio above 0.5.
Impact class
silent wrong results
Impact note
Anyone who builds training, test and cross-validation indexes with DatasetSplitter and asks for a test share above one half (testRatio 0.6, 0.8 or 1.0, legal under the documented 0.0-1.0 range) gets only about half of each class in the test index, and the rest silently goes to cross-validation or training; there is no error, no scale condition, -ea and -da behave the same, the source index is not changed, testRatio <= 0.5 is honoured up to per-class rounding, and crossValidationRatio, documented in the same words, is honoured over the whole range.
Summary
You split an index with new DatasetSplitter(0.8, 0).split(...) to put 80% of the documents into the test index. You expect about 8 of every 10 documents of each class in the test index, as crossValidationRatio 0.8 gives 8 of 10 in the cross-validation index. Instead the test index gets only 5 of 10 and training gets the other 5, because the loop lets a document into the test index only when its running counter b is even (DatasetSplitter.java:153, 'if (b % 2 == 0 && tc < testSize)'), so at most every other document can be a test document, with no error.
Introduced
3459b28334e2f3103336c0485b3f21da6dbaeeb9 (trunk, 2012-11-29, svn r1415060, '[LUCENE-4345] - added utility class for creating training / test / cv index from a single index', tteofili; no PR, it predates GitHub PRs; JIRA LUCENE-4345 / #5411 is the issue under which the classification module was built). It added 'if (b % 2 == 0 && testWriter.maxDoc() < size * testRatio)' for the test branch and no parity condition on the cross-validation branch. The released code came through the branch_4x backport 21e86983ed42 (2013-02-20, LUCENE-4781 / #5846), with the same guard. Not a regression: the guard is in the first commit of the class. 112078eaf909 (LUCENE-7196 / #8251, 2016-04-08, first released in 6.1.0) rewrote the loop per class and kept 'b % 2 == 0' unchanged; git blame of line 153 points there only because the right half of the condition was rewritten [read: git log --follow, -S 'b % 2 == 0' across all refs and -G 'b % 2 == 0' on main in the history clone]. (2012-11-29 (trunk commit 3459b28334e); release line: backport 21e86983ed4 on 2013-02-20; first released in 4.2.0 (tagged 2013-03-11))
First affected version
4.2.0 [exec] (2013-03-11, the first release that ships the classification module and DatasetSplitter). There is no last unaffected release: DatasetSplitter.java is absent from the 4.0.0-alpha, 4.0.0-beta, 4.0.0 and 4.1.0 tags [read], and Maven Central has no lucene-classification 4.1.0 jar (HTTP 404) [exec].
Affected versions
Every release that has DatasetSplitter, 4.2.0 through 10.5.2, and main a9779ef3c193; not fixed. [exec] the follow-up's history runs (one class of 10 documents, testRatio 0.8, cv 0; each run first checks the control crossValidationRatio 0.8 -> 8 of 10, which passes in every version): 4.2.0, 4.10.4, 5.0.0, 6.0.1, 6.1.0, 6.3.0, 6.4.0, 7.7.3, 8.8.1 and 8.11.4 (version-specific main() drivers for the four API eras) and 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main with -ea and -da (the candidate JUnit) all give test/cv/training 5/0/5 instead of 8 test documents; shadow controls on 4.2.0, 6.1.0 and 8.8.1 (the release's own source with only 'b % 2 == 0 && ' removed) give 8/0/2, so the drivers detect exactly this guard. The finder's JUnit (also re-run by the intended-behaviour check) and the own tests of the independent reproduction also fail on 10.5.2 and 9.12.3. [read] the guard is in all 119 release tags of the history clone (8.8.2 and 8.9.0-8.11.3 are not tagged there and lie between the executed 8.8.1 and 8.11.4); upstream main a95c754ee003 (2026-10-26) and branch_10x still have line 153 unchanged.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (searched 2026-10-27, read-only; the prior-report record, verdict new): scripted prior-report searches by the finder (43 queries), the intended-behaviour check (39) and the follow-up (52 with an expanded spec plus 7 web queries; 347 distinct hits, 0 high), and manual searches: 21 GitHub queries on apache/lucene issues and PRs (the whole module:classification label, 68 items), further queries on apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet and GitHub-wide, full threads of 13 apache/lucene and 2 lucenenet items, the changed files of all 409 open apache/lucene PRs, 8 JIRA queries (LUCENE, SOLR, LUCENENET, including all 47 modules/classification issues), lists.apache.org (Lucene and Solr lists), Stack Overflow and the web. The identifiers testRatio and crossValidationRatio return no hits on GitHub, JIRA or any list searched. Closest items, none the same bug: #8251 / LUCENE-7196 (the 2016 per-class redesign; kept the guard and states the size intent 'split-ratio * |docs in c| documents for each class c'), #5411 / LUCENE-4345 (introduced the class; no comment discusses its ratios), #8453 / LUCENE-7400 and #8642 / LUCENE-7591 (class grouping and counting), #12942 (a different DatasetSplitter failure, 'topNGroups must be >= 1'), #16847 (the only open PR touching the file; one unrelated line). Not fixed: upstream main a95c754ee003 (2026-10-26) still has 'if (b % 2 == 0 && tc < testSize)' at DatasetSplitter.java:153, and no commit message, JIRA thread, CHANGES or MIGRATE entry documents the cap. Gaps: GitHub search is token-based and GitHub's search rate limit was reached (throttled queries were retried); the open-PR scan listed only 3000 of #16911's 7630 files (a branch decommission PR, not a fix); private reports and chat are not searchable. Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
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.0.0, 10.5.2 and 9.12.3 release jars with assertions on (this doc's test). The finder's test and era-specific main() drivers fail on 14 releases from 4.2.0 to 10.5.2 (assertions on) and on main (on and off). The cross-model reviews ran on main ea57360912ed, whose classification and grouping sources equal a9779ef3c193; the review step re-ran the intended-lens reviewer's test on a9779ef3c193 (assertions on), while the refute-lens reviewer's test ran only on ea57360912ed. The fix was validated in the scratch worktree: the new test with TestDataSplitter, the whole :lucene:classification:test (assertions on and off) and :lucene:classification:check -x test.

Lucene and upstream sources

Finding record
  • lucene/classification/src/java/org/apache/lucene/classification/utils/DatasetSplitter.java:44-46 (at a9779ef3c193) (class javadoc 'Utility class for creating training / test / cross validation indexes from the original index'), :55-58 ('@param testRatio the ratio of the original index to be used for the test IDX as a double between 0.0 and 1.0', and the same words for crossValidationRatio), :60-63 (constructor, no range check), :136 (int b = 0, one counter for all classes), :143 (double testSize = totalHits * testRatio), :145 (cvSize), :153 ('if (b % 2 == 0 && tc < testSize)'), :156 ('else if (cvc < cvSize)', no parity condition), :159-160 (training branch), :162 (b++)
  • lucene/classification/src/test/org/apache/lucene/classification/utils/TestDataSplitter.java:91, (at a9779ef3c193) :95 (ratios 0.1/0.1 and 0.2/0.35 only), :128 (size oracle 'assertEquals((int) (originalIndex.maxDoc() * testRatio), testReader.maxDoc(), 20)')
  • GitHub and JIRA, read-only (2026-10-27): apache/lucene #5411, #5846, #8251, #8453, #8642, #12942, #16847; apache/lucenenet #27
The finding in 30 seconds
  • lucene/classification/src/java/org/apache/lucene/classification/utils/DatasetSplitter.java:44-46, :55-58, :60-63, :74-75, :119-126, :136, :142-146, :153, :156, :159-160, :162
Who is affected
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:146-211 (first-pass and second-pass grouped search); DatasetSplitter.java:140-163 (copy loop)
  • lucene/classification/src/java/org/apache/lucene/classification/utils/DatasetSplitter.java:44-47 (no @lucene.experimental), :55-63
  • grep -rln DatasetSplitter lucene --include='*.java' (DatasetSplitter.java, TestDataSplitter.java, Test20NewsgroupsClassification.java only), 2026-10-27
  • lucene/classification/src/test/org/apache/lucene/classification/utils/TestDataSplitter.java:91, :95; lucene/classification/src/test/org/apache/lucene/classification/Test20NewsgroupsClassification.java:149
Why it looks unintended
  • lucene/classification/src/java/org/apache/lucene/classification/utils/DatasetSplitter.java:55-58, :60-63, :153, :156
  • GitHub apache/lucene #8251 (LUCENE-7196 issue text), read-only 2026-10-27
  • lucene/classification/src/test/org/apache/lucene/classification/utils/TestDataSplitter.java:91, :95, :128
Suggested fix
  • lucene/classification/src/java/org/apache/lucene/classification/utils/DatasetSplitter.java:60-63, :144-160

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

Repro files

About the diagrams

A summary still; the PNG is the summary. This caption and alt text follow the traced example as it stood when the doc was written.

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.