- Normally a term's frequency in a document is how often it occurs there. Since 7.0.0 a
TokenStreamcan set it directly withTermFrequencyAttribute, for examplered|3throughDelimitedTermFrequencyTokenFilter, on a field indexed withDOCS_AND_FREQS. - Lucene's own implementations of that attribute throw for values below 1. An application can register its own implementation with the Expert
AttributeSource.addAttributeImplor anAttributeFactory.
- You index custom term frequencies on a
DOCS_AND_FREQSfield through your ownTermFrequencyAttributeclass, plugged in with the ExpertaddAttributeImpl, that does not copy the stock "1 or greater" check. Doc 0 hasfoowith frequency 0, doc 1 hasfoowith frequency 2. - You expect
addDocumentto reject doc 0 with anIllegalArgumentExceptionand drop only that document, as IndexWriter does for a negative position increment or offsets going backwards. - Instead the indexing chain checks the custom frequency only for overflow and stores the 0 as the posting's frequency. With assertions off, both
addDocumentcalls andcommitreturn normally. - CheckIndex then reports
term [66 6f 6f]: doc 0: freq 0 is out of boundsand 1 broken segment containing 2 documents. - If
foooccurs only in the frequency-0 document, the commit itself throwscannot write negative vLong (got: -1)and closes the writer; a valid document added after the bad one is lost too.
Only applications that index custom term frequencies through their own TermFrequencyAttribute class that skips the stock "1 or greater" check, and emit 0 (for example a weight that rounds down to 0). Stock analysis, including DelimitedTermFrequencyTokenFilter and FeatureField, rejects 0 loudly. Default config and codec otherwise; two one-token documents suffice.
Mechanism, terms and numbers541 words
A field indexed with DOCS_AND_FREQS can take each token's frequency from TermFrequencyAttribute instead of counting occurrences. Lucene's two stock implementations of that attribute reject values below 1, but the interface states no range, and an application can plug in its own implementation through the Expert AttributeSource.addAttributeImpl or an AttributeFactory. IndexingChain.invertTokenStream checks every token's position increment and offsets before adding it, but checks the custom frequency only for overflow (IndexingChain.java:2053-2064 at 5adff78bb168); FreqProxTermsWriterPerField then stores the value as the posting's frequency (:125, :177, :196); on a DOCS_AND_FREQS field the only positivity guard is an assert (:143). With assertions off, a frequency of 0 is committed silently and CheckIndex rejects the segment (freq 0 is out of bounds). When a term's total frequency ends below its document count, the commit instead throws cannot write negative vLong (got: -1), which closes the writer and loses the uncommitted documents. Every release from 7.0.0 to 10.6.0 has it; stock analysis cannot trigger it.
- Only the stock setters check the range.
TermFrequencyAttribute(analysis/tokenattributes/TermFrequencyAttribute.java:23-35) states no range.TermFrequencyAttributeImpl.setTermFrequency(:30-36) andPackedTokenAttributeImpl.setTermFrequency(:157-164) throw "Term frequency must be 1 or greater". A custom class plugged in withaddAttributeImpl("Expert",util/AttributeSource.java:176-188) bypasses both; it has to be registered before the default packed implementation claims the interface, as the repro does. - The indexing chain checks the other token values, not this one.
IndexingChain.invertTokenStreamrejects a bad position increment or position overflow (index/IndexingChain.java:1996-2033) and bad offsets (:2035-2051) with non-abortingIllegalArgumentExceptions before the add. The custom frequency is only added to the field length withMath.addExact, "too many tokens" on overflow (:2053-2064): an upper bound, no lower bound. This is the fix site. - It is stored as is.
termsHashPerField.add(:2075, inside the abortingcatch (Throwable)at:2098-2100) reachesFreqProxTermsWriterPerField.newTerm/addTerm, which storegetTermFreq()as the posting's frequency (index/FreqProxTermsWriterPerField.java:125,:177,:196).getTermFreq()(:208-219) returnstermFreqAtt.getTermFrequency()and rejects onlyfreq != 1when positions are indexed. - On a
DOCS_AND_FREQSfield the only positivity guard is an assert.assert !hasFreq || postings.termFreqs[termID] > 0(:143) runs only with-ea, and on the term's next occurrence, not on the document that carries the 0. Other index options do have checks, inside the add: with positions,getTermFreq()rejects any value other than 1 (:208-219); on a DOCS-only fieldaddTermrejects a custom frequency other than 1 (:147-152). - What the flush writes. The postings get
freq 0(termfoo: docFreq 2, totalTermFreq 2, doc 0 freq 0, doc 1 freq 2) and CheckIndex treatsfreq <= 0as corruption (index/CheckIndex.java:1735-1739). When a term's total frequency ends below its document count (one document with frequency 0: totalTermFreq 0, docFreq 1), the terms dictionary writesttf - dfas a vLong (codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java:528), which throws atstore/DataOutput.java:238during the commit; the exception is tragic and closes the writer.
With assertions on, an internal assert fires instead: FreqProxTermsWriterPerField.addTerm:143 when the frequency-0 document comes first, Lucene104PostingsWriter.startDoc:290 during flush when it comes second (the devil's-advocate verifier's test).
Exposure and likelihood166 words
- Who. Code that writes its own
TermFrequencyAttributeimplementation (an Expert hook) without the stock range check, on a field indexed with frequencies. No public example was found: a GitHub code search forimplements TermFrequencyAttribute(top 30 hits) found only copies of Lucene's own class, so the likelihood is rated contrived. - Not hit. The two stock attributes throw for values below 1;
DelimitedTermFrequencyTokenFiltercalls the stock setter, sofoo|0is rejected;FeatureFieldrejects values belowFloat.MIN_NORMAL. - Effect. Silent in production mode (assertions off): the bad value is committed and only CheckIndex notices. In the single-document case the commit fails and closes the writer: in the probe, after one committed document, a frequency-0 document and a valid one were added; the commit threw, the writer closed, and the index kept only the first document. How search and merge behave on a segment with
freq 0was not run. - Default vs opt-in. Custom term frequencies are opt-in; nothing else is configured.
- Scale. Two one-token documents; deterministic, single thread.
One JUnit test method on the public API: a TermFrequencyAttribute class that stores what it is given, two one-token documents (foo with frequency 0, then 2), commit, CheckIndex. An IllegalArgumentException from addDocument counts as correct, so it doubles as the regression test. Deterministic, single thread.
/*
* 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.index;
import java.util.List;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.analysis.tokenattributes.TermFrequencyAttribute;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.AttributeImpl;
import org.apache.lucene.util.AttributeReflector;
public class TestCustomTermFreqBelowOne extends LuceneTestCase {
public static final class UncheckedFreqAttributeImpl extends AttributeImpl
implements TermFrequencyAttribute {
private int freq = 1;
@Override
public void setTermFrequency(int termFrequency) {
freq = termFrequency;
}
@Override
public int getTermFrequency() {
return freq;
}
@Override
public void clear() {
freq = 1;
}
@Override
public void copyTo(AttributeImpl target) {
((TermFrequencyAttribute) target).setTermFrequency(freq);
}
@Override
public void reflectWith(AttributeReflector reflector) {
reflector.reflect(TermFrequencyAttribute.class, "termFrequency", freq);
}
}
private static final class FooWithFreq extends TokenStream {
private final int freq;
private final TermFrequencyAttribute freqAtt;
private final CharTermAttribute termAtt;
private boolean done;
FooWithFreq(int freq) {
addAttributeImpl(new UncheckedFreqAttributeImpl());
freqAtt = addAttribute(TermFrequencyAttribute.class);
termAtt = addAttribute(CharTermAttribute.class);
this.freq = freq;
}
@Override
public boolean incrementToken() {
if (done) {
return false;
}
clearAttributes();
termAtt.append("foo");
freqAtt.setTermFrequency(freq);
return done = true;
}
@Override
public void reset() {
done = false;
}
}
public void testZeroCustomTermFrequency() throws Exception {
FieldType type = new FieldType();
type.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
// expected: addDocument rejects frequency 0 with IllegalArgumentException; actual: it is
// indexed and the committed segment fails CheckIndex ("freq 0 is out of bounds")
try {
w.addDocument(List.of(new Field("f", new FooWithFreq(0), type)));
} catch (IllegalArgumentException expected) {
}
w.addDocument(List.of(new Field("f", new FooWithFreq(2), type)));
w.commit();
}
TestUtil.checkIndex(dir);
}
}
}
cp $REPRO/TestCustomTermFreqBelowOne.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestCustomTermFreqBelowOne -Ptests.seed=DEADBEEF -Ptests.asserts=false
# without -Ptests.asserts=false: AssertionError at FreqProxTermsWriterPerField.addTerm:143
- Expected
addDocumentrejects the frequency-0 document withIllegalArgumentException, or the committed index passes CheckIndex.- Observed
RuntimeException: CheckIndex failed(term [66 6f 6f]: doc 0: freq 0 is out of bounds) with assertions off;AssertionErroratFreqProxTermsWriterPerField.addTerm:143with them on.
More files, environment, full output333 words
TestCustomTermFreqBelowOne (one method, extends LuceneTestCase, package org.apache.lucene.index of the lucene/core tests, Apache license header). It passes checkGoogleJavaFormat. The test method draws no random values, though the test framework still picks locale, time zone and the default similarity from the seed; this test was run with seed DEADBEEF, and the hunt's longer version of it also failed with four other seeds (a separate re-run and the audit). Run it with -Ptests.asserts=false for the production-mode failure (CheckIndex); with assertions on, an internal AssertionError fires on the second addDocument instead. With fix A it passes unchanged, assertions on and off. As written it accepts any IllegalArgumentException from the first addDocument; a stricter version would also assert the message and that the index then holds 1 document (not run).
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT): fails; 7.0.0 to 10.6.0 (9 releases run): fail; 6.6.x: no API |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (only this platform was run) |
cp $REPRO/TestCustomTermFreqBelowOne.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestCustomTermFreqBelowOne -Ptests.seed=DEADBEEF -Ptests.asserts=false
# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF -Ptests.asserts=false
TestCustomTermFreqBelowOne > testZeroCustomTermFrequency FAILED
java.lang.RuntimeException: CheckIndex failed
at __randomizedtesting.SeedInfo.seed([DEADBEEF:BAD1EBD1119B1DB4]:0)
at org.apache.lucene.tests.util.TestUtil.checkIndex(TestUtil.java:337)
...
1> test: terms, freq, prox...ERROR: org.apache.lucene.index.CheckIndex$CheckIndexException: term [66 6f 6f]: doc 0: freq 0 is out of bounds
...
1> WARNING: 1 broken segments (containing 2 documents) detected
# gradle, same, assertions on
TestCustomTermFreqBelowOne > testZeroCustomTermFrequency FAILED
java.lang.AssertionError
at __randomizedtesting.SeedInfo.seed([DEADBEEF:BAD1EBD1119B1DB4]:0)
at org.apache.lucene.index.FreqProxTermsWriterPerField.addTerm(FreqProxTermsWriterPerField.java:143)
at org.apache.lucene.index.TermsHashPerField.positionStreamSlice(TermsHashPerField.java:216)
at org.apache.lucene.index.TermsHashPerField.add(TermsHashPerField.java:201)
at org.apache.lucene.index.IndexingChain$PerField.invertTokenStream(IndexingChain.java:2075)
...
# with fix A: PASS with assertions on and off
# commit-failure probe (a separate main(), not the repro), assertions off, no fix:
# bar freq 1 committed; then foo freq 0 and baz freq 1 added; commit
second commit: java.lang.IllegalArgumentException: cannot write negative vLong (got: -1)
writer open: false
tragic exception: java.lang.IllegalArgumentException: cannot write negative vLong (got: -1)
documents in the index: 1
# with fix A: addDocument(foo, freq 0) throws IllegalArgumentException: term frequency must be 1 or greater (got 0) for field 'f'; commit ok; 2 documents- Nothing documents 0 as valid: both stock implementations throw "Term frequency must be 1 or greater", and CheckIndex calls a 0 out of bounds.
- IndexWriter already re-checks a negative position increment, which also only a custom attribute can produce, and this frequency's upper bound.
- The
>= 1check went only into the setters when the feature was added (LUCENE-7854).
The full argument193 words
- Contract.
TermFrequencyAttributestates no range; both stock implementations reject values below 1 (TermFrequencyAttributeImpl.java:30-36,PackedTokenAttributeImpl.java:157-164);FreqProxTermsWriterPerFieldassertstermFreqs > 0(:143); CheckIndex reportsfreq <= 0as out of bounds (CheckIndex.java:1735-1739). Michael McCandless on dev@ (2020-11-06, "Payloads for each term"): "term frequency 0 is not allowed". - IndexWriter guards the index against bad token values.
PositionIncrementAttributeImpl(:32-35) andPackedTokenAttributeImpl(:57-62) reject a negative increment, andIndexingChainstill checks "position increment must be >= 0" (:2002-2008). Precedents: LUCENE-3874 (#4947, "don't let bogus positions corrumpt [sic] the index"), LUCENE-4127 ("don't let bogus offsets corrupt the index"), LUCENE-5977 (#7039, token stream safety checks in IndexWriter). The custom frequency's upper bound is checked too (6c3ece2b9f9, 2017). - Tests.
TestCustomTermFreq(15 tests) covers positions, DOCS-only fields, overflow and term vectors, but not a value below 1, because its streams use the stock attribute. - History. LUCENE-7854 (
d276acfbbcb, 2017) put the>= 1check only into the setters; none of the 34 comments on #8905 discusses values below 1. The assert at:143dates from 2012, when a frequency could only be counted up from 1.
The devil's-advocate check131 words
The strongest case for "not a bug": the attribute is an Expert hook, an implementation that accepts 0 is broken, and Robert Muir wrote on #11086 (2021): "If you use custom term frequencies (expert feature), then it is your responsibility to have reasonable values. Otherwise, things can easily overflow, break scoring, cause failures from checkindex."
Why it fails: the interface documents no range; IndexWriter already re-checks the negative position increment, which only the same kind of custom code can produce; and in the same thread (2021-08-13) they wrote "It isn't viable to have an option that says 'allow me to corrupt my index'". The fix is one comparison on a value the chain already reads, and it rejects only the bad document. The argument does bear on likelihood, hence priority Low.
Recommended Reject a frequency below 1 in IndexingChain before the add (fix A)
Check the lower bound in IndexingChain.invertTokenStream next to the position and offset checks, before the aborting add: throw a non-aborting IllegalArgumentException("term frequency must be 1 or greater (got 0) for field 'f'") (+10 lines). Only the bad document is rejected. The repro passes with assertions on and off; 105 tests of the 11 closest suites pass.
final int termFreq = invertState.termFreqAttribute.getTermFrequency();
if (termFreq < 1) {
throw new IllegalArgumentException(
"term frequency must be 1 or greater (got "
+ termFreq
+ ") for field '"
+ field.name()
+ "'");
}All options and trade-offs4 options ยท 601 words
Option A was built and checked through gradle on 5adff78bb168; B to D were not run. A and B change behaviour only for a custom frequency below 1, which every stock producer already rejects; A applies to every inverted field, including DOCS_AND_CUSTOM_FREQS (see its trade-offs). If LFV-281 is fixed at the same time, B is the natural form.
Reject a frequency below 1 in IndexingChain before the add (fix A) (validated by the checks) (recommended)
Right after the offsets check in IndexingChain.invertTokenStream (IndexingChain.java:2051), read the custom frequency and throw an IllegalArgumentException when it is below 1. Like the position and offset checks above it, this runs before termsHashPerField.add, so it is non-aborting: the document is marked deleted, as for any analysis error, and the writer stays open. The message mirrors the stock setters.
final int termFreq = invertState.termFreqAttribute.getTermFrequency();
if (termFreq < 1) {
throw new IllegalArgumentException(
"term frequency must be 1 or greater (got "
+ termFreq
+ ") for field '"
+ field.name()
+ "'");
}Trade-offs
One more comparison per token on the indexing hot path (Lucene's SECURITY.md asks that robustness changes not slow hot paths). The value is read again a few lines below (IndexingChain.java:2059, on the branch for fields other than DOCS_AND_CUSTOM_FREQS, which adds it to the field length), so a final version can reuse the local there (not run). No benchmark was run. Scope: as written the check runs before the index-option branches, so it applies to every inverted field, not only DOCS_AND_FREQS. On DOCS-only and position-indexed fields the existing checks inside the aborting add (FreqProxTermsWriterPerField.java:147-152, :208-219) reject a frequency other than 1; the fix would reject a 0 before them, non-aborting. On DOCS_AND_CUSTOM_FREQS (10.5.0) custom frequencies are opaque scores (IndexOptions.java:52-55, FieldInfo.java:717-723), so decide whether 0 is a valid score there before rejecting it. With the fix only DOCS_AND_FREQS was run with a 0, and no repro covers a negative value, so this finding is about 0.
What the checks validated
- Gradle in a scratch worktree at
5adff78bb168, seedDEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (on and off). - With the fix: the repro,
TestCustomTermFreq(15),TestFieldInvertState(2),TestTermsHashPerField(3),TestIndexWriterExceptions(40, 3@Nightlyskipped),TestFeatureField(14),TestFeatureSort(6),TestFeatureDoubleValues(6),TestPackedTokenAttributeImpl(4),TestSimilarity(3),TestReqOptSumScorer(8) and analysis-common'sTestDelimitedTermFrequencyTokenFilter(3): 105 tests, 0 failures, 3 skipped.:lucene:core:checkGoogleJavaFormatpasses. The whole core module was not run. - The commit-failure probe with the fix: the frequency-0 document is rejected, the commit succeeds, the writer stays open and the index has 2 documents; CheckIndex is clean.
LFV-281's proposed fix adds a validateCustomTermFrequency step before the add whenever the frequency is not 1. A < 1 check fits there as its first line.
Trade-offs
Same behaviour as A, and no extra work for the common frequency of 1; it depends on LFV-281's change landing first or together with it.
Replace assert !hasFreq || postings.termFreqs[termID] > 0 with an exception.
Trade-offs
It runs inside the aborting termsHashPerField.add, so a bad document would close the writer and roll back everything since the last commit (the LFV-281 problem), and it fires on the term's next occurrence, not on the bad document.
State in the interface javadoc that the frequency must be 1 or greater.
Trade-offs
Cheapest, but IndexWriter would still commit a segment CheckIndex rejects. Worth adding together with A.
Downstream, a product that accepts raw per-token frequencies from users through its own non-checking attribute would let a frequency of 0 break a segment or close the writer; Lucene's stock analysis rejects such input.
A proof-obligation audit of FreqProxTermsWriterPerField's contract: every posting frequency it stores must be at least 1 (its own assert, and CheckIndex). Tracing where the value comes from showed that only the stock attribute setters enforce it; a one-test public-API JUnit then witnessed a frequency of 0 in a committed segment.
The method in full131 words
Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).
- The finder read
FreqProxTermsWriterPerField's obligation that a stored frequency is above 0 (:143, and CheckIndex atCheckIndex.java:1735-1739) and traced each place the value is set (:125,:177,:196) back toTermFrequencyAttribute.getTermFrequency(). The indexing chain discharges the obligation for positions and offsets with explicit checks, but for the frequency it relies on the stock setters, which an application's own implementation does not run. - The finder then wrote a public-API JUnit test that fails on main with assertions on and off, and on 10.6.0 and 9.12.3.
- An independent reproducer, two executed cross-model reviews, a devil's-advocate verifier and an adjudication reproduced it with their own tests ("Prior reports, history and checks").
No prior report found in apache/lucene issues, PRs and commits (with the JIRA archive), the dev@, java-user@, issues@ and commits@ lists, downstream trackers or the web (searched 2026-11-02). Nearest items: #11086 (a maintainer's view that custom-frequency values are the user's responsibility, about huge values) and the 2020 dev@ remark that a frequency of 0 is not allowed.
Searches3 searches
No matching public report was found in the documented searches. Related, not reports: #11086 (LUCENE-10048), #8905 (LUCENE-7854, the origin; nothing on values below 1), the dev@ thread "Payloads for each term" (2020), #4947 and #7039 (precedents for IndexWriter checks), #15584 (DOCS_AND_CUSTOM_FREQS, nothing on values below 1). #13338 (open) shares only the low-level "cannot write negative vLong" symptom at merge, without custom frequencies, and is not linked. Limits: keyword search; GitHub search returns at most 50 items per query; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-02, re-checked | three scripted prior-report searches (45 queries each: identifiers and error messages; trigger and symptom; generic wording), 20 own queries, issues and PRs updated since 2026-10-31 | no report; #11086, #8905, #15584 are the nearest items |
| Open PRs | 2026-11-02 | the changed files of all 408 open apache/lucene PRs | five touch IndexingChain.java or FreqProxTermsWriterPerField.java (#16269, #16116, #15330, #13525, #13342); none changes term-frequency handling |
| Mailing lists, JIRA, downstream, web | 2026-11-02 | dev@, java-user@, issues@ and commits@ (the threads found read in full), JIRA full-text queries, Solr, Elasticsearch, OpenSearch and Lucene.NET trackers, 9 web queries | nothing about this bug; the 2020 dev@ remark "term frequency 0 is not allowed" |
Present since custom term frequencies were added (d276acfbbcb, LUCENE-7854, Mike McCandless, 2017-06-06; first release 7.0.0). Every release run, 7.0.0 to 10.6.0, and main fail; 6.6.x lacks the API. Not a regression and not fixed upstream.
Versions tested6 rows
Introduced by d276acfbbcb "LUCENE-7854: enable indexing custom term frequencies" (Mike McCandless, 2017-06-06; JIRA patch, issue #8905, no PR). It added TermFrequencyAttribute, put the >= 1 check only into the two stock setters, and made FreqProxTermsWriterPerField store whatever getTermFrequency() returns. Before it every occurrence counted 1, so a frequency of 0 could not occur. First release 7.0.0 (2017-09-20).
Later changes near the fix site kept the gap: 6c3ece2b9f9 ("too many tokens", 7.0.0), #12139 (null guard, 9.6.0) and #15584 (DOCS_AND_CUSTOM_FREQS, 10.5.0, which uses the same getTermFreq() path; not run). Not fixed upstream: upstream main 1c2072f02843 (2026-11-02) has no commit since 2026-10-25 on FreqProxTermsWriterPerField.java or TermFrequencyAttribute.java, and on IndexingChain.java only #16734 (columnar vectors). Releases before 9.1.0 ran a main() with the same two documents and CheckIndex, because they lack today's test framework.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle: fails with assertions on and off; passes with fix A |
| 10.6.0 | affected | the JUnit unchanged: CheckIndex freq 0 is out of bounds (-da); assert at addTerm:143 (-ea) |
| 9.1.0, 9.12.3, 10.0.0 | affected | the JUnit unchanged (-da): the same CheckIndex error |
| 10.5.2 | reproducer's test | the independent reproducer's test (AttributeFactory route): the same failure |
| 7.0.0, 7.7.3, 8.11.4, 9.0.0 | main() form | the same two documents and check: the same CheckIndex error (7.0.0 also -ea: assert) |
| 6.6.0, 6.6.6 | no API | TermFrequencyAttribute does not exist |
A finder, an independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, intended and refute lenses), a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced it and agree on the root cause. This doc's gradle runs reproduced it on main and validated the fix.
Checks and verdicts312 words
The cross-model reviews ran on main ea57360912ed, where FreqProxTermsWriterPerField.java and the attribute classes are identical to 5adff78bb168 and IndexingChain.java differs only by #16734 (columnar vectors); their fix checks did not compile there. The other checks on main ran on 5adff78bb168; the release runs used each release's own jars.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | The repro test fails on main (assertions on and off), 10.6.0 and 9.12.3; its fix passes the test and TestCustomTermFreq (15 of 15). |
| cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (medium) | Own test: frequency 0 read back from the committed postings, then CheckIndex fails; the stock-attribute control passes; intended argument moderate. |
| independent reproducer | real | Own test through AttributeFactory.getStaticImplementation (a second public route) fails on main, 10.6.0, 10.5.2 and 9.12.3; confirmed the single-document commit failure. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (medium) | Own test fails (two seeds, assertions off; and on); a positive-frequency control passes; inputs in contract, root cause agrees. |
| devil's-advocate verifier | real (medium) | The misuse argument rated moderate and found not to hold; own test with the 0 in the second document fails (with assertions on, at Lucene104PostingsWriter.startDoc:290). |
| adjudication | confirmed, Low to Medium | Re-ran the reproducer's test on main with assertions off and a fresh seed: fail. |
| separate re-run, audit and final repro check | confirmed (fail, fail) | The re-run and the audit ran the hunt's test on main with assertions on and off: fail. The final repro check's runs of the one-test repro fail on main and pass with the fix; it set the impact class to minor. |
| this doc | reproduced; fix A validated | Gradle: fails without the fix; with it 105 tests of the closest suites run, 0 failures, 3 @Nightly skipped; the commit-failure probe confirms the tragic close and that the fix prevents it. |
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 503 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,511 words
- Registry title
- IndexWriter accepts a custom term frequency of 0 without any check: the frequency goes into the postings and the committed segment fails CheckIndex ('freq 0 is out of bounds'), or the commit throws and closes the writer
- Area
- index / IndexingChain inversion with custom term frequencies (TermFrequencyAttribute, FreqProxTermsWriterPerField)
- Priority
- Low, rank 677: A custom term frequency of 0 is indexed without a check, so the committed segment fails CheckIndex or the commit throws and closes the writer, in every release since 7.0.0. It needs the opt-in custom-frequency feature with an application-written attribute that skips the stock check, since every stock producer rejects 0, so it is contrived.
- Impact class
- minor
- Impact note
- Hits only applications that index custom term frequencies (an opt-in feature) through their own TermFrequencyAttribute implementation, plugged in with the Expert AttributeSource.addAttributeImpl or an AttributeFactory, that skips the stock '1 or greater' check and reports 0 on a field indexed with frequencies; otherwise default IndexWriterConfig and codec at ordinary scale: with -da nothing fails at index time and the committed segment fails CheckIndex, or, when a term's total frequency ends below its document count, the commit throws and closes the writer so uncommitted documents are lost; with -ea an internal assert fires instead; stock analysis (the stock attributes, DelimitedTermFrequencyTokenFilter, FeatureField) cannot reach it; every release from 7.0.0 to 10.6.0 and main. Minor rather than data corruption: the effect is a committed segment that CheckIndex rejects, but it is reachable only through an application-written TermFrequencyAttribute implementation that skips the stock '1 or greater' check, and a maintainer has called sane custom-frequency values the user's responsibility (#11086), so it is a Low missing-input-check bug.
- Summary
- You index a document through a TokenStream whose TermFrequencyAttribute is your own implementation (plugged in with AttributeSource.addAttributeImpl or an AttributeFactory) and that reports a frequency of 0 for a token, on a field indexed with DOCS_AND_FREQS. You expect IndexWriter to reject that document with an IllegalArgumentException, as it does for a negative position increment, backwards offsets or too many tokens, or at least never to write an index that its own CheckIndex calls broken. Instead the 0 goes into the postings unchecked: with assertions off addDocument and commit succeed and CheckIndex then reports 'term [66 6f 6f]: doc 0: freq 0 is out of bounds' and 1 broken segment, and if the term's only document has frequency 0, the commit instead throws 'cannot write negative vLong (got: -1)', which closes the writer and loses every uncommitted document.
- Introduced
- d276acfbbcb8ea6124fe486e35cea9eb928e4702 'LUCENE-7854: enable indexing custom term frequencies' by mikemccand (JIRA-patch workflow, issue migrated as #8905; no GitHub PR) [read: git blame on main, git log -G 'termFreqAttribute|getTermFrequency|TermFrequencyAttribute' over IndexingChain, DefaultIndexingChain, FreqProxTermsWriterPerField and FieldInvertState; exec: 7.0.0 fails, 6.6.0 and 6.6.6 lack the API]. It added TermFrequencyAttribute (its javadoc states no range), put the only lower-bound check into the two stock setters (TermFrequencyAttributeImpl and PackedTokenAttributeImpl: 'Term frequency must be 1 or greater') and made FreqProxTermsWriterPerField store whatever getTermFrequency() returns as the posting frequency; the indexing chain only adds the value to the field length (upper bound only; the follow-up 6c3ece2b9f9, 2017-06-07, turned the overflow into 'too many tokens'). Before it every occurrence counted 1, so a frequency of 0 could not occur. Not a regression: the -ea-only assert at FreqProxTermsWriterPerField:143 (c40b0375603b, rmuir, 2012) predates the feature, and #12139 (cce33b07e4f, 9.6.0, null guard) and #15584 (5158a52a082, 10.5.0, DOCS_AND_CUSTOM_FREQS length branch) changed the code nearby without adding a lower bound. (2017-06-06 (author and committer date of d276acfbbcb); first release 7.0.0 (tag 2017-09-08))
- First affected version
- 7.0.0 [exec: the history variant fails with -da and -ea]; 6.6.6 and older lack the API [exec: the variant does not compile on 6.6.0 and 6.6.6, TermFrequencyAttribute missing; git tag --contains d276acfbbcb lists 7.0.0 first]
- Affected versions
- Every release from 7.0.0 (2017) through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT); never fixed. [exec, follow-up history runs, seed DEADBEEF, JDK 25] a core-only main() variant (same two documents, commit, CheckIndex) fails on 7.0.0 (-da and -ea), 7.7.3, 8.11.4 and 9.0.0 (-da), because releases before 9.1.0 lack today's test framework; the one-test JUnit unchanged fails on 9.1.0, 9.12.3 and 10.0.0 (-da) and on 10.6.0 and main (-ea and -da); the independent reproduction's own test fails on main, 10.6.0, 10.5.2 and 9.12.3 (-ea and -da); the intended-behaviour check's own test fails on main (-ea and -da) and 10.6.0 (-da); re-run on main 2026-11-02 fails with -ea and -da. Not new in 10.6 (10.5.2 and every earlier executed release fail). Not fixed upstream: upstream main 1c2072f02843 (2026-11-02) has no commit since 2026-10-25 on FreqProxTermsWriterPerField.java or TermFrequencyAttribute.java and only a9779ef3c19 (#16734, columnar vectors) on IndexingChain.java [read through the GitHub API, 2026-11-02]. DOCS_AND_CUSTOM_FREQS (10.5.0 and later) uses the same getTermFreq() path [read], not executed.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: proof-obligation audit
- Upstream and prior reports
- none found (2026-11-02, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches; rechecked the same day): three scripted prior-report searches of 45 queries each (identifiers and error messages; trigger and symptom; generic wording) over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, plus the changed-file lists of all 408 open apache/lucene PRs (five touch IndexingChain.java or FreqProxTermsWriterPerField.java: #16269, #16116, #15330, #13525, #13342; none changes term-frequency handling) and the threads found read in full: no report of IndexWriter accepting a custom term frequency below 1 (tracking issue #16937 does not mention it). Closest context, not reports: #11086 (LUCENE-10048, 2021: rmuir wrote that users of custom frequencies are responsible for 'reasonable values', and in the same thread that an option to 'allow me to corrupt my index' is not viable; about huge values, which IndexWriter rejects), #8905 (LUCENE-7854, origin; 34 comments, none about values below 1), a 2020-11-06 dev@ mail by Michael McCandless ('term frequency 0 is not allowed'), #15584 (DOCS_AND_CUSTOM_FREQS, nothing on values below 1), and the precedents #4947 (LUCENE-3874) and #7039 (LUCENE-5977) for IndexWriter re-validating TokenStream values. #13338 (open, 2024) shares only the low-level 'cannot write negative vLong' symptom at merge on a deployment without custom frequencies and is a different problem. Same-day recheck: GitHub issue search on apache/lucene for 'TermFrequencyAttribute', 'custom term frequency', 'freq 0 is out of bounds' and 'term frequency must be 1 or greater', and PR search for 'TermFrequencyAttribute', 'custom term frequency' and 'getTermFrequency', updated since 2026-10-31: 0; the 9 PRs created since 2026-10-31 (#16956-#16965) are unrelated; upstream commits since 2026-10-25: none on FreqProxTermsWriterPerField.java or TermFrequencyAttribute.java, only a9779ef3c19 (#16734, author date 2026-10-26, columnar vectors) on IndexingChain.java; upstream main head 1c2072f02843. Not fixed upstream.
- Lucene commit tested
- main
5adff78bb168(2026-11-02) through gradle in a scratch worktree with assertions on and off, without and with fix A (the repro, 11 closest suites,checkGoogleJavaFormat), and a standalone commit-failure probe on the main jars (without and with fix A). The hunt ran the same two documents on 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (all fail); 6.6.0 and 6.6.6 lack the API. Only Linux aarch64 with OpenJDK 25.0.3 was run.
- Finding record
lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1996-2050 (at 5adff78bb168) (per-token checks before the add: position increment >= 0, position overflow, offsets, all non-aborting IllegalArgumentExceptions), :2052-2063 (custom frequency only added to invertState.length with Math.addExact; 'too many tokens' on overflow; no lower bound: fix site), :2068-2075 and :2098-2100 (termsHashPerField.add, the aborting step)lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:125, (at 5adff78bb168) :177, :196 (termFreqs set from or summed with getTermFreq()), :143 ('assert !hasFreq || postings.termFreqs[termID] > 0', -ea only), :208-219 (getTermFreq returns termFreqAtt.getTermFrequency(); only checks freq != 1 with positions)lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/TermFrequencyAttribute.java:22-35 (at 5adff78bb168) (interface, no range stated), TermFrequencyAttributeImpl.java:30-36 and PackedTokenAttributeImpl.java:157-164 ('Term frequency must be 1 or greater'); lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1735-1739 (freq <= 0 reported as 'freq N is out of bounds')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/TermFrequencyAttribute.java:23-35, (at 5adff78bb168) TermFrequencyAttributeImpl.java:30-36, PackedTokenAttributeImpl.java:157-164lucene/core/src/java/org/apache/lucene/util/AttributeSource.java:176-188 (at 5adff78bb168) (addAttributeImpl, Expert)lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1996-2033, (at 5adff78bb168) :2035-2051, :2053-2064, :2075, :2098-2100lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:125, (at 5adff78bb168) :143, :147-152, :177, :196, :208-219lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1735-1739; (at 5adff78bb168) codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java:528; store/DataOutput.java:238
- Who is affected
lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/TermFrequencyAttributeImpl.java:30-36, (at 5adff78bb168) PackedTokenAttributeImpl.java:157-164; document/FeatureField.java:162lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/DelimitedTermFrequencyTokenFilter.java:67 (at 5adff78bb168)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/TermFrequencyAttribute.java:23-35, (at 5adff78bb168) TermFrequencyAttributeImpl.java:30-36, PackedTokenAttributeImpl.java:57-62, :157-164, PositionIncrementAttributeImpl.java:32-35lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2002-2008, (at 5adff78bb168) :2053-2064; index/FreqProxTermsWriterPerField.java:143; index/CheckIndex.java:1735-1739lucene/core/src/test/org/apache/lucene/index/TestCustomTermFreq.java (at 5adff78bb168) (15 tests)https://github.com/apache/lucene/issues/11086https://lists.apache.org/thread/dcwm71rnjz7mz69ntwy04co3615zdl6w
- Suggested fix
Lucene main 5adff78bb168 SECURITY.md:46 (robustness changes and hot paths)lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2053-2064 (at 5adff78bb168) (length update, :2059 reads the frequency); index/FreqProxTermsWriterPerField.java:147-152, :208-219; index/IndexOptions.java:52-56; index/FieldInfo.java:717-723 (DOCS_AND_CUSTOM_FREQS)
- History
Lucene main 5adff78bb168 dev-tools/doap/lucene.rdf (lucene-7.0.0 created 2017-09-20)
- Diagrams
lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1996-2075; (at 5adff78bb168) index/FreqProxTermsWriterPerField.java:125-219
- TestCustomTermFreqBelowOne.java (lucene/core, package org.apache.lucene.index, 1 test):
repro/LFV-1135/TestCustomTermFreqBelowOne.java(112 lines)
The diagram illustrates the repro's two documents (main 5adff78bb168, IndexingChain.java:1996-2075, FreqProxTermsWriterPerField.java:125-219).
assets/LFV-1135/bug.png(503 KB)
