MinHashFilter turns a text into a fingerprint for near-duplicate search: per hash it keeps the N lowest hash values of the tokens, usually 5-word shingles. Texts with the same set of shingles should get the same fingerprint.
add calls on the 2-slot set, the repeat of c evicting a, b filling the free slot, and the expected values next to the actual ones. Select the still to open it at full size. Generated with AI, checked against the code.- Run
MinHashFilterwith 1 hash, 1 bucket andhashSetSize2: keep the 2 lowest hash values of the tokens. For the three words used here the hashes order as c < a < b. - You expect the 2 lowest values of the set of words, whatever the repeats and the order: "b a a" keeps a and b like "b a", and "c a c b" and "c a b c" keep c and a like "c a b".
- You get: "b a a" keeps only a; "c a c b" keeps c and b, the largest hash; "c a b c" keeps only c. No error, the same with assertions on and off.
- Why: on a full set,
FixedSizeTreeSet.addremoves the largest value (pollLast()) before callingTreeSet.add, which does nothing for a value already kept. The repeated c evicts a, and the next new word, b, takes the free slot. - In the javadoc's own setting, 1 hash and keep the 100 lowest values of 5-word shingles, a 150-word cycle (154 words, wrapping around) read twice (304 words) has the same 150 shingles as when read once, but keeps 99 values instead of 100.
Applications that run MinHashFilter, or the minHash factory that downstream min_hash filters wrap, with hashSetSize of 2 or more (opt-in; the default 1 is safe), on text where a token or shingle repeats. Only the N-th slot of each (hash, bucket) set can be wrong (missing, or holding a larger value), so similarity estimates shift silently. Every release since 6.2.0.
Mechanism, terms and numbers529 words
MinHashFilter hashes every token (typically 5-word shingles) and keeps, per hash and bucket, the hashSetSize lowest hash values; its javadoc says "You could have 1 hash and keep the 100 lowest values". The kept values live in FixedSizeTreeSet, a TreeSet with a capacity. When that set is full and a value below its largest one arrives, add first removes the largest (pollLast()) and then calls TreeSet.add. If the value is already in the set, because the token repeats, TreeSet.add does nothing: the set has lost its largest value and gained nothing, and the next new value, however large, fills the free slot. Only the N-th slot of a (hash, bucket) set can be wrong: the lowest N-1 values are always kept. No exception; the default hashSetSize of 1 is safe. The code dates from the filter's introduction (6.2.0), and a one-line guard fixes it.
- Min hash, bottom-k. Each token is hashed to a 128-bit value. With one hash the filter keeps the
hashSetSizelowest values (a bottom-k sketch); with several hashes or buckets it keeps that many per hash and bucket. The overlap of two such sets estimates how similar the two texts' token sets are (Jaccard). FixedSizeTreeSet. The package-privateTreeSetsubclass that holds the kept values of one (hash, bucket), with a capacity ofhashSetSize.
- On the first
incrementToken()the filter reads the whole input and adds every token's hash, repeats included, to the set of its hash and bucket (MinHashFilter.java:159-171, the insert at:168). FixedSizeTreeSet.add(:333-345): if the set is full and the new value is not belowlast(), it returnsfalse(:337-338); otherwise it callspollLast()(:340) and thenTreeSet.add(:343).TreeSet.addleaves the set unchanged when the value is already in it. So a repeat of a kept value below the maximum removes the maximum and adds nothing: the set shrinks by one.- The next new value is added without any check because the set is no longer full, however large it is.
- With
hashSetSize1 the only kept value is also the largest, so its repeat compares equal and is rejected at:337: the default is safe.
| token | set before | what add does | set after |
|---|---|---|---|
| c | {} | not full: add | {c} |
| a | {c} | not full: add | {c, a} |
| c (repeat) | {c, a} | full, c < a: pollLast() removes a, TreeSet.add(c) is a no-op | {c} |
| b | {c} | not full: add | {c, b} |
In "c a b c" the b is rejected while the set is full (b > a), and the final c then evicts a: the output is {c}.
| input | keep | expected | actual |
|---|---|---|---|
| "b a" (control) | 2 | a, b | a, b |
| "b a a" | 2 | a, b | a |
| "c a b" (control) | 2 | c, a | c, a |
| "c a c b" | 2 | c, a | c, b |
| "c a b c" | 2 | c, a | c |
| 150-word cycle read once / twice, 5-word shingles | 100 | the same 100 values | 100 / 99 |
| any of these | 1 (default) | correct |
Exposure and likelihood359 words
- Who. Users of
MinHashFilterorMinHashFilterFactory(SPI nameminHash) withhashSetSize>= 2. The javadoc's first example is exactly that: "1 hash and keep the 100 lowest values".hashSetSizeis a public constructor and factory parameter, validated only as > 0. Downstream engines wrap the factory as amin_hashtoken filter and passhash_set_sizethrough; one of them allows up to 10,000. How many applications usehashSetSize>= 2 is not known. - Not affected. The default
hashSetSizeof 1, which published downstream example configurations also use. With many buckets (the default is 512) a bucket must first holdhashSetSizevalues before a repeat can do harm, so short documents in bucketed setups are less exposed (a code argument; not measured). - Trigger. Ordinary text: a token or shingle that repeats while its (hash, bucket) set is full and whose hash is below that set's largest kept value. No crafted input; deterministic.
- Size. Only the N-th slot of each (hash, bucket) set can be wrong: the true lowest N-1 values are always kept, and the N-th slot is right, empty or holds a larger value (a code argument, then 1,464,843 exhaustive set sequences and 20,000 random end-to-end inputs). In the javadoc setting that is at most 1 of 100 values; "b a a" loses 1 of 2. With keep 2 and many buckets up to half the values can change: in a constructed worst case (1 hash, 64 buckets, keep 2, plain whitespace tokens) two texts with the same 2,000 words share 64 of 128 values, an overlap of 0.5 where the same word set should give 1.0 (no downstream similarity estimator was run).
- Effect. The indexed min-hash terms, and every near-duplicate or similarity estimate built on them, depend on how often and in what order shingles occur; a value that is not among the lowest can be indexed. No exception, the same with assertions on and off, and the index is well-formed.
- Not re-measured. The finder's synthetic-corpus rates (5 to 49 of 50 documents with a wrong signature, depending on the configuration) were not re-checked by a verifier, and how often real text hits the trigger was not measured.
The test shows that MinHashFilter with hashSetSize 2 (1 hash, 1 bucket, no rotation) keeps only one hash for b a a: a repeated token while the set is full evicts the set's largest kept value (FixedSizeTreeSet.add calls pollLast() before TreeSet.add finds the value already present).
/*
* 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.analysis.minhash;
import java.io.IOException;
import java.io.StringReader;
import java.util.Set;
import java.util.TreeSet;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.core.WhitespaceTokenizer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestMinHashRepeatedToken extends LuceneTestCase {
public void testRepeatedTokenKeepsBothLowestHashes() throws IOException {
// expected: 'b a a' keeps the same 2 lowest hashes as 'b a'; actual: only 1 hash is kept
assertEquals(minHashes("b a"), minHashes("b a a"));
}
/** The output terms of MinHashFilter with 1 hash, 1 bucket and hashSetSize 2. */
private static Set<String> minHashes(String text) throws IOException {
Tokenizer tokenizer = new WhitespaceTokenizer();
tokenizer.setReader(new StringReader(text));
Set<String> terms = new TreeSet<>();
try (TokenStream ts = new MinHashFilter(tokenizer, 1, 1, 2, false)) {
CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
ts.reset();
while (ts.incrementToken()) {
terms.add(term.toString());
}
ts.end();
}
return terms;
}
}
# in a copy of this finding's repro folder (repro/LFV-649/, 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: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.analysis.minhash.TestMinHashRepeatedToken
- Expected vs actual
- Expected the same 2 lowest hashes as for
b a(the javadoc: keep the N lowest values); actual: 1 hash (the failure prints two raw hash terms expected, one actual), with-eaand-da.
More files, environment, full output35 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:<[␋䯍ᰂ閴ﭯ窨訍, 粌㪥轭炛熥鵴贇]> but was:<[␋䯍ᰂ閴ﭯ窨訍]>
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.analysis.minhash.TestMinHashRepeatedToken.testRepeatedTokenKeepsBothLowestHashes(TestMinHashRepeatedToken.java:33)- The javadoc says the filter can "keep the 100 lowest values"; repeating a token cannot change those.
FixedSizeTreeSetoverridesTreeSet.add, which must leave the set unchanged for an element already present; it evicts the largest instead.- "c a c b" and "c a b c" (same tokens, same counts) give different signatures, which no set or multiset bottom-k allows.
The full argument268 words
- Javadoc (
MinHashFilter.java:38-40): "The number of hashes used and the number of minimum values for each hash can be set. You could have 1 hash and keep the 100 lowest values or 100 hashes and keep the lowest one for each."@param hashSetSize(:112): "the no. of min hashes to keep". TreeSet.add(JDK): "If this set already contains the element, the call leaves the set unchanged and returnsfalse."FixedSizeTreeSetreturnsfalsetoo, although it has removed the largest element, contrary to that inherited contract (the filter's own call sites ignore the return value).- Design discussion of LUCENE-6968 (now #8025), by one of the credited patch authors: "Let's start with 5 word shingles, one hash and keep the minimum 100 hash values. ... For a 100 word doc where all the shingles/words are the same we get one hash.", and the min set of one hash is "a random sample without replacement": bottom-k over distinct values. Nobody discusses a repeat arriving at a full set.
- Downstream documentation of the same parameters states frequency independence, for example: "it doesn't matter how many times a document contains a certain token, only that if it contains it or not."
- Tests. The upstream
TestMinHashFilterfeedsFixedSizeTreeSetonly distinct values (testCollisions,testHashNotRepeated), and its bookkeeping assumes thatadd() == falseremoved nothing;testTokenStream3(hashSetSize10, two distinct shingles) never fills a set. No test asserts repeat-dependent output. - History. No CHANGES or MIGRATE entry, javadoc caveat, issue or list thread documents a distinct-input precondition; the
addbody is unchanged in behaviour since the filter was added.
The devil's-advocate check140 words
The strongest case for "intended": the javadoc never literally says "distinct"; FixedSizeTreeSet is package-private, so its TreeSet contract is internal; the default hashSetSize of 1 is immune and the published examples use 1; MinHash is an approximation anyway.
Why it fails: the output is not a function of the token multiset either. "c a c b" and "c a b c" have the same tokens with the same counts and give {c, b} and {c}; neither a set nor a multiset bottom-k reading lets the output depend on token order or keeps b, which is above the true 2 lowest of either. The design discussion and the downstream documentation state set semantics, and a safe default limits reach, not validity. The verifiers, the cross-model reviews and the adjudication found the intended-behaviour argument weak or unconvincing. Verdict: not intended, a defect.
Recommended Also reject a value the full set already holds (one line)
In FixedSizeTreeSet.add, also return false when the full set already holds the value, before pollLast(): if (toAdd.compareTo(lastElm) >= 0 || contains(toAdd)). The repro then passes with assertions on and off, and so do the whole lucene/analysis/common suite (1,914 tests, 0 failures, 18 skipped; 13 of 13 in TestMinHashFilter), TestRandomChains with one seed and the module's lint and format checks.
public boolean add(final E toAdd) {
if (capacity <= size()) {
final E lastElm = last();
- if (toAdd.compareTo(lastElm) >= 0) {
+ if (toAdd.compareTo(lastElm) >= 0 || contains(toAdd)) {
return false;
} else {
pollLast();All options and trade-offs4 options · 659 words
Two fixes were run. The recommended one went through gradle in a scratch worktree at a9779ef3c193 with the module's tests; the alternative through the classpath runner only.
In FixedSizeTreeSet.add, return false before pollLast() when the full set already contains the value. add then honours the TreeSet.add contract, and each set is the exact bottom-N of the distinct values of its tokens.
public boolean add(final E toAdd) {
if (capacity <= size()) {
final E lastElm = last();
- if (toAdd.compareTo(lastElm) >= 0) {
+ if (toAdd.compareTo(lastElm) >= 0 || contains(toAdd)) {
return false;
} else {
pollLast();Trade-offs
- One extra
O(log hashSetSize)lookup, only when the set is full and the value is below its largest. With the defaulthashSetSize1 that is a lookup in a one-element set, made only for a new value below the kept one (a repeat or a larger value is rejected by the first comparison). Negligible either way. - Signatures can change (to the correct ones) for documents whose repeated tokens left an incorrect final set, once re-analysed (a later token can repair the damage: "c b c a" ends correct, "c a c b" does not); until such documents are re-analysed or reindexed, a field mixes old and new signatures across segments. Worth a CHANGES line.
What the checks validated
- Stock main
a9779ef3c193, gradle, seed DEADBEEF: the repro fails 4 of 5 withtests.asserts=trueand=false(thehashSetSize1 control passes). - With the fix: the repro passes 5 of 5 with
tests.asserts=trueand=false; the wholelucene/analysis/commonsuite passes (340 classes, 1,914 tests, 0 failures, 18 skipped), includingTestMinHashFilter13 of 13;TestRandomChains(inlucene/analysis.tests; it can pick MinHashFilter; whether this seed did was not checked) passes 2 of 2 with one seed;ecjLintandcheckGoogleJavaFormatpass for the module with the repro test in place. - Both diffs, the one-line fix and the fix plus the repro test as a new test class, apply cleanly to a clean
a9779ef3c193tree. - Classpath runner: the repro passes 5 of 5 with the fix shadowed,
-eaand-da. Earlier in the hunt, the same fix shadowed: the finder's, the reproducer's, the devil's advocate's and both reviewers' tests (the reviewers' own runs used mainea57360912ed, whose minhash package is identical) and the 13 upstream tests pass, and the exhaustive model check reports 0 of 524,256 mismatches (92,628 without it). - Not run: a benchmark (one extra lookup on a path that already calls
last(),pollLast()and an insert).
Reject a value that is not below the largest of a full set; otherwise TreeSet.add first, and only when it added something, pollLast() if the set is over capacity.
public boolean add(final E toAdd) {
if (capacity <= size() && toAdd.compareTo(last()) >= 0) {
return false;
}
if (super.add(toAdd) == false) {
return false; // already kept: the set is unchanged
}
if (size() > capacity) {
pollLast();
}
return true;
}Trade-offs
Avoids the extra lookup, but is a larger diff of an internal class, and the set briefly holds capacity + 1 elements.
What the checks validated
- This doc's JUnit 5 of 5, the finder's 3 of 3, the independent reproducer's 6 of 6 and the 13 upstream
TestMinHashFiltertests pass with it shadowed onto the stock jars; the exhaustive model check reports 0 of 524,256 mismatches (92,628 without a fix). - Not run through gradle.
Keep a per-document set of the terms already seen and skip repeats.
Trade-offs
Fixes it too, but costs memory per distinct token and duplicates what the bounded set should do.
Say in the javadoc that hashSetSize > 1 needs distinct tokens.
Trade-offs
Not a fix: the output would still depend on token order, and shingled text repeats by nature.
Downstream engines expose this filter as a min_hash token filter with a user-set hash_set_size and document that repeating a token does not change the hashes; with hash_set_size of 2 or more that does not hold.
Bounded-exhaustive translation validation: an executable model of the javadoc (the lowest distinct hashes per hash and bucket) was compared with the real filter on 524,256 small inputs. 92,628 differed, all with hashSetSize >= 2 and a repeated token; a model of the add rule matched the filter on every input, which pinned the cause.
The method in full166 words
Method: bounded-exhaustive translation validation (the hunt's "semi-formal" tier), a hunt unit (analysis-common filters), 2026-10-26.
- The finder wrote an executable model of the class javadoc: per hash and bucket, the exact
hashSetSizelowest distinct values, with its own murmur3 checked against a known-answer vector and 20,000 random inputs, and the filter's bucketing, rotation and term encoding mapped line by line. - It compared the model with the stock
MinHashFilteron every token sequence of length 0-6 over a 4-token alphabet, for hashCount {1, 2, 3} x bucketCount {1, 2, 3, 5} x hashSetSize {1, 2, 3, 4} x withRotation {false, true}: 524,256 checks, 92,628 mismatches, all withhashSetSize>= 2 and a repeated token. - A second model that uses the
FixedSizeTreeSet.addrule agreed with the filter on all 524,256 inputs, which pinned the root cause; with the one-line fix shadowed there were 0 mismatches. - A public-API JUnit on the stock main jar then reproduced it ("b a a" against "b a", keep 2), with assertions on and off.
No prior report found (searched 2026-10-26 and 2026-10-27) in apache/lucene issues, PRs and commits, all 409 open PRs, the JIRA archive, four mailing lists, downstream repositories or the web. Related but different: #8025 (the origin), #9823, #8494, #8488.
Searches4 searches
No report of this defect was found; the closest items describe other behaviour.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; the JIRA archive; downstream repositories | 2026-10-26, 2026-10-27 | 4 runs of the hunt's prior-report tool (45 + 3 x 51 queries: identifiers such as MinHashFilter, FixedSizeTreeSet, hashSetSize, pollLast; symptoms such as repeated token, duplicate hash, fewer hashes, token order; downstream wording such as min_hash, hash_set_size, jaccard) | no report |
| GitHub by hand (apache/lucene, apache/lucenenet, downstream engines, all of GitHub) | 2026-10-27 | GraphQL searches for MinHashFilter, FixedSizeTreeSet, hashSetSize, minhash, "min hash", LSH, "near duplicate", "bottom-k", TreeSet pollLast; code search for a patched add(); the changed files of all 409 open apache/lucene PRs | no report; open PR #16911 deletes the branch_10x files (not a fix) |
| JIRA LUCENE, SOLR, OAK; lists.apache.org; the web | 2026-10-27 | LUCENE-6968 read in full (43 comments), SOLR and OAK issues, every MinHash list thread, 7 web searches | no report; |
| Related items (read) | 2026-10-27 | the threads of the MinHash issues found | different defects: #8025 / LUCENE-6968 (origin, distinct-value design), #9823 / LUCENE-8779 (unpaired surrogates), #8494 / LUCENE-7442 (argument validation), #8488 / LUCENE-7436 (constructor visibility); downstream factory parameter bugs (fixed) and an open "fewer min hashes" report that a re-computation traced to such a factory bug, unchanged by this fix |
Introduced with the filter in LUCENE-6968 (82a9244193ba, 2016-06-14; #8025), first released in 6.2.0. A probe fails the same way on 9 releases from 6.2.0 to 10.5.2 and on main, and the add body is unchanged in behaviour in all 80 release tags.
Versions tested6 rows
Introduced by 82a9244193ba "LUCENE-6968 - MinHash filter, thanks to Andy Hind and Cao Manh Dat for patches" (committed by @tteofili on 2016-06-14; the 2016 JIRA patch workflow, no PR; the issue is now #8025 "LSH Filter"), backported to branch_6x as 96a5595fffd4. git blame on main puts FixedSizeTreeSet.add and the per-token insert in that commit; the only later edits there, 42fe2d5620ac (> -1 to >= 0; backported to branch_9x as 964006360c4, first released in 9.1.0) and b0ebaaed5650 (a charset constant; backported to branch_10x as b60e6527295, in 10.2.0 and later), do not change behaviour. In 6.2.0 hashSetSize is reachable through the factory only; the public constructor came with LUCENE-7436 (6.2.1, 6.3.0, 7.0.0).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's JUnit 4 of 5 fail through gradle and the classpath runner, assertions on and off; the finder's 3 of 3 and the independent reproducer's 6 of 6 fail |
| 10.5.2, 9.12.3 | affected | this doc's JUnit 4 of 5 fail (-ea); the finder's 3 of 3 fail (-ea); the reproducer's 6 of 6 fail (-ea and -da) |
| 10.0.0, 9.0.0, 8.11.4, 7.7.3, 7.0.0, 6.6.6 | affected | the version-independent probe: "b a" gives 2 values, "b a a" 1, byte-identical output |
| 6.2.0 | first release | the probe fails the same way (factory API) |
| 6.1.0 | no API | the probe does not compile: no org.apache.lucene.analysis.minhash package |
fix (|| contains(toAdd)) | not affected | this doc's JUnit 5 of 5 pass; the exhaustive model check 0 of 524,256 mismatches |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests; two executed reviews by a model from another family (OpenAI GPT-5.6) rated it real with a weak intended argument; an adjudication re-ran it with a new seed (6 of 6 fail, 0 with the fix); this doc's gradle runs reproduce it and validate the fix.
Checks and verdicts411 words
Each check wrote and ran its own test unless noted; every control passed, and every fix check passed.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Exhaustive check: 92,628 of 524,256 inputs differ, all with hashSetSize >= 2 and a repeat; 0 with the fix. JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3. |
| cross-model review, refute lens (OpenAI GPT-5.6) | real, high | Own constructor-level test fails on main with -ea, -da and three seeds, controls pass; also through the minHash factory with repeated 5-word shingles; the fix passes it and the 13 upstream tests. Ran on main ea57360912ed (minhash package unchanged). |
| independent reproducer | real | Own JUnit from the claim only: 6 of 6 fail on main, 10.5.2 and 9.12.3 (-ea and -da), incl. order dependence and a 16,368-input oracle (0 mismatches without repeats or at hashSetSize 1); 6 of 6 pass with its own one-line fix. |
| cross-model review, intended lens (OpenAI GPT-5.6) | real, high; intended argument weak | Own factory-level test fails 2 of 3 on main ea57360912ed (minhash package identical to a9779ef3c193) and 10.5.2 (the hashSetSize 1 control passes), passes with the fix; re-run separately on a9779ef3c193 with -ea and -da, plus an order check: the same multiset in two orders gives two signatures. |
| intended-behaviour devil's advocate | real | Read the javadoc, tests, CHANGES/MIGRATE, the design discussion and downstream docs; its order probe ("lo lo hi" 2 values, "hi lo lo" and "lo hi lo" 1) fails on main (-ea, -da) and 10.5.2 and passes with the fix. |
| adjudication | confirmed, high confidence | Re-ran the reproducer's JUnit with seed 0BADC0DE: 6 of 6 fail with -ea and -da, 0 of 6 with the fix; no convincing not-real argument; duplicate of none. |
| Follow-up and registry re-runs | confirmed | History bracket 6.1.0 / 6.2.0 plus 7 more releases; a security probe bounding the damage to one value per set; re-runs on main: the finder's JUnit 3 of 3 fail, the reproducer's 6 of 6 with -da, 3 of 3 pass with the fix; a credibility audit kept the row. |
| this doc | reproduced; fix validated | This doc's JUnit fails 4 of 5 on stock main through gradle (assertions on and off) and the classpath runner (-ea, -da), and on 10.5.2 and 9.12.3; with the fix it passes, and so do the whole analysis-common suite (1,914 tests, 0 failures, 18 skipped), TestRandomChains with one seed and the lint and format checks. |
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 480 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,492 words
- Registry title
- MinHashFilter with hashSetSize >= 2 drops one of the kept lowest hash values when a token repeats while its (hash, bucket) set is full, so the min-hash signature depends on token repeats and order instead of only on the token set ('b a a' keeps 1 of 2 values; 'c a c b' and 'c a b c' give different signatures)
- Area
- analysis-common / minhash (MinHashFilter.FixedSizeTreeSet bottom-k set; MinHashFilterFactory 'minHash')
- Priority
- Low, rank 385: Needs the opt-in hashSetSize of 2 or more, and at most one value per set is wrong.
- Impact class
- silent wrong results
- Impact note
- Hits applications that run MinHashFilter (directly, or through MinHashFilterFactory 'minHash', which downstream min_hash token filters wrap) with hashSetSize >= 2, such as the javadoc's own '1 hash and keep the 100 lowest values': whenever a token or shingle repeats while its (hash, bucket) set is full and its hash is below that set's largest kept value, the set loses that largest value and may later admit a larger one, so near-duplicate and similarity estimates computed from the indexed signature are skewed (at most one wrong value per set: 1 of 100 for a text read twice in the javadoc setting, up to 1 of 2 per bucket with keep 2); ordinary text, no scale, deterministic, with -ea and -da, no exception; the default hashSetSize = 1 is not affected.
- Summary
- You analyse 'b a a' with MinHashFilter(hashCount 1, bucketCount 1, hashSetSize 2) and expect the same two min hashes as for 'b a', because the filter keeps 'the N lowest values' of the tokens' hashes (MinHashFilter.java:38-40; hashSetSize is 'the no. of min hashes to keep') and the lowest values of a token set do not change when a token repeats. Instead, FixedSizeTreeSet.add removes the full set's largest value (pollLast) before TreeSet.add finds that the repeated value is already there, so 'b a a' gives one value; a later new token, however large its hash, then fills the free slot ('c a c b' keeps the hashes of c and b, not the true lowest two, c and a), and the same tokens in another order ('c a b c') give yet another signature. In the javadoc's '1 hash and keep the 100 lowest values' setting with 5-word shingles, a 150-word text read twice has the same shingle set as the text read once but a signature of 99 values that misses one of the 100 lowest.
- Introduced
- 82a9244193ba948142b834ec08e2de0d98cfba9f (LUCENE-6968 'MinHash filter, thanks to Andy Hind and Cao Manh Dat for patches', committed by GitHub tteofili; JIRA patch workflow, no pull request; the issue is now apache/lucene#8025 'LSH Filter'), backported to branch_6x as 96a5595fffd4a4a1c553a987382697cb8a92b354, which shipped in 6.2.0 [read: git blame -w -M -C on main attributes FixedSizeTreeSet.add (MinHashFilter.java:333-345) and the per-token insert (:168) to 82a9244193ba; the only later edits on those lines, 42fe2d5620ac (LUCENE-10364, '> -1' to '>= 0', first released in 10.0.0) and b0ebaaed5650 (#14216, charset constant on :164, main only), do not change behaviour; exec: 6.1.0 has no minhash package, 6.2.0 fails]. Which of the credited patch authors wrote FixedSizeTreeSet cannot be told from the commit. (2016-06-14 (main line; branch_6x backport committed 2016-06-24))
- First affected version
- 6.2.0 [exec] (the first release with org.apache.lucene.analysis.minhash; on 6.1.0 the repro does not compile because the package does not exist [exec]). In 6.2.0 hashSetSize is reachable through the factory only; the public constructor came with LUCENE-7436 in 6.2.1 / 6.3.0 / 7.0.0.
- Affected versions
- Every release from 6.2.0 through 10.5.2 (the last release before 10.6.0; the latest 9.x is 9.12.3) and main a9779ef3c193, whenever hashSetSize >= 2. [exec] the follow-up's version-independent main() probe (factory API, 1 hash, 1 bucket, hashSetSize 2: 'b a' gives 2 values, 'b a a' gives 1) fails with byte-identical output on 6.2.0, 6.6.6, 7.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main, and the finder's run on 7.7.3; the finder's JUnit fails 3/3 on main -ea/-da, 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 6/6 on main, 10.5.2 and 9.12.3, each with -ea and -da; the intended-behaviour check's probe and the cross-model intended review's test fail on 10.5.2 as on main. [read] the FixedSizeTreeSet.add body (after normalising '> -1' to '>= 0'), LongPair.compareTo and the insert line are identical in all 80 release tags 6.2.0..10.5.2 of the release-tag clone (8.8.2..8.11.4 are not in that clone; 8.11.4 was executed), and upstream main head a95c754ee003 has the same add() (2026-10-26). Not affected: hashSetSize = 1 (the default; 0 mismatches in every exhaustive check) and releases before 6.2.0 (no API).
- 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: exhaustive check
- Upstream and prior reports
- none found (2026-10-27 plus the finder's 2026-10-26 run and a recheck, read-only; the prior-report record, verdict new): four scripted prior-report searches (45 + 3 x 51 queries over apache/lucene GitHub issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and downstream repositories), manual GitHub GraphQL searches (MinHashFilter, FixedSizeTreeSet, hashSetSize, minhash, 'min hash', LSH, 'near duplicate', 'bottom-k', TreeSet pollLast; also apache/lucenenet), the changed files of all 409 open apache/lucene PRs, JIRA LUCENE, SOLR and OAK searches, lists.apache.org counts and threads, downstream Elasticsearch, OpenSearch and Solr searches, GitHub code search for a patched add(), and 7 web searches. Related, not this bug: #8025 / LUCENE-6968 (origin; its design discussion assumes distinct-value bottom-k and never discusses a repeat into a full set), #9823 / LUCENE-8779 (open: unpaired surrogates in emitted terms), #8494 / LUCENE-7442 (argument validation), #8488 / LUCENE-7436 (constructor visibility); downstream elastic/elasticsearch#84578 and opensearch-project/OpenSearch#15183 (their factories ignored bucket_count/hash_set_size; fixed), opensearch-project/OpenSearch#12578 (open: fewer min hashes after an upgrade with hash_set_size 3; re-computed with its own text, the 197 vs 165 counts come from that downstream factory bug and do not change with this fix), elastic/elasticsearch#154480 and opensearch-project/OpenSearch#23362 (parameter bounds against OOM). Open PR #16911 ('Decommission branch 10x') deletes the branch_10x files, MinHashFilter.java included; it is not a fix. Gaps: one blog post could not be read; keyword search only; private reports to security@lucene.apache.org are not visible. 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): this doc's JUnit through gradle in a clean worktree, stock and with the fix, assertions on and off, plus the wholelucene/analysis/commontest suite and its lint and format checks with the fix (2026-10-28); through the hunt's classpath runner, the JUnit on main (-ea,-da, each fix shadowed), 10.5.2 and 9.12.3. Hunt and follow-up runs: maina9779ef3c193and the hunt's earlier mainea57360912ed; a version-independent probe on 6.1.0 (no API), 6.2.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2.
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/minhash/MinHashFilter.java:34-47 (at a9779ef3c193) (class javadoc: 'The incoming tokens would typically be 5 word shingles'; :38-40: 'You could have 1 hash and keep the 100 lowest values or 100 hashes and keep the lowest one for each'), :55 (DEFAULT_HASH_SET_SIZE = 1), :106-129 (constructor; @param hashSetSize 'the no. of min hashes to keep'; validated only as > 0 at :124-126), :159-171 (every token's hash, repeats included, goes to its bucket's FixedSizeTreeSet at :168), :317-345 (FixedSizeTreeSet; add() at :333-345 calls pollLast() before super.add, which is a no-op for a value already present)lucene/analysis/common/src/java/org/apache/lucene/analysis/minhash/MinHashFilterFactory.java (at a9779ef3c193) (SPI name minHash; passes hashSetSize through; defaults from MinHashFilter.DEFAULT_*)registry/bugs.json (645 rows on 2026-10-27: no MinHash or FixedSizeTreeSet entry; LFV-528 mentions MinHash chains only in its likelihood note)SECURITY.md ('In-scope reports'; lines 28, 38, 42, 50 as cited by the security record)registry/the hunt's tier rules sections 5, 6, 8 and 'Likelihood term'
- The finding in 30 seconds
lucene/analysis/common/src/java/org/apache/lucene/analysis/minhash/MinHashFilter.java:34-47 (at a9779ef3c193) (class javadoc), :159-171 and :168 (every token added), :183 (rotation only when hashSetSize == 1), :207-247 (emission by pollFirst), :317-345 (FixedSizeTreeSet; add at :333-345)
- Who is affected
lucene/analysis/common/src/java/org/apache/lucene/analysis/minhash/MinHashFilter.java:38-40 (at a9779ef3c193) (javadoc example), :55 (DEFAULT_HASH_SET_SIZE = 1), :57 (DEFAULT_BUCKET_COUNT = 512), :124-126 (validation > 0)lucene/analysis/common/src/java/org/apache/lucene/analysis/minhash/MinHashFilterFactory.java:33 (at a9779ef3c193) (SPI name minHash), :46-49 (defaults, hashSetSize passed through)
- Why it looks unintended
lucene/analysis/common/src/java/org/apache/lucene/analysis/minhash/MinHashFilter.java:38-40, (at a9779ef3c193) :112, :317-345JDK 25.0.3 java.base/java/util/TreeSet.java (add javadoc)lucene/analysis/common/src/test/org/apache/lucene/analysis/minhash/TestMinHashFilter.java (at a9779ef3c193) (testCollisions :97, testHashNotRepeated :135, testTokenStream3 :262)
- TestMinHashRepeatedToken.java (JUnit test):
repro/LFV-649/src/org/apache/lucene/analysis/minhash/TestMinHashRepeatedToken.java(51 lines)
A summary diagram (PNG), produced separately from this text from its own probe runs on main and 10.5.2 (hashes, per-token set traces, outputs with and without the fix). The alt text was checked against the final PNG.
assets/LFV-649/bug.png(480 KB)
