All findings · Lucene finding · LFV-649 · minhash-fixedsizetreeset-duplicate-evicts-max

MinHashFilter with hashSetSize >= 2 can drop a kept min hash when a token repeats

With hashSetSize 2 or more, MinHashFilter can drop a kept hash value when a token repeats into a full set, so the signature depends on repeats and order ("b a a" keeps 1 value, "b a" keeps 2). Every release since 6.2.0 is affected; the default hashSetSize 1 is safe.

Summary

Priority
Low, rank 385Needs the opt-in hashSetSize of 2 or more, and at most one value per set is wrong.
Impact
silent wrong results
Since
6.2.0 (LUCENE-6968, 2016), with the filter itselfintroduced 2016-06-14 · over 10 years
Affected
6.2.0 to 10.5.2 and main, when hashSetSize >= 2
Status
documented
Found by
bounded-exhaustive comparison of the real filter with a model of its javadoc

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

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.

Diagram titled "A repeated token can make MinHashFilter drop one of the lowest hash values", for new MinHashFilter(tok, 1, 1, 2, false) on "c a c b" with hashes c < a < b. Left: the 2-slot set after add(c) {c}, add(a) {c, a}, add(c) {c} because pollLast() evicts a and TreeSet.add(c) is a no-op, add(b) {c, b}. Right: expected c, a; actual c, b, where b is the largest hash; "c a b c" keeps only c. Notes: "b a" gives 2 values and "b a a" 1; the javadoc's setting keeps 99 of 100 values for a 150-word cycle (154 words) read twice; hashSetSize 1 is not affected, and with the one-line fix (|| contains(toAdd)) all 9 probe outputs are right and the 13 upstream tests pass. A box explains the add rule (MinHashFilter.java:168, :335-343); footer: affected from 6.2.0 to 10.5.2 and main whenever hashSetSize >= 2.
The "c a c b" example with keep 2: the four 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.
  1. Run MinHashFilter with 1 hash, 1 bucket and hashSetSize 2: keep the 2 lowest hash values of the tokens. For the three words used here the hashes order as c < a < b.
  2. 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".
  3. 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.
  4. Why: on a full set, FixedSizeTreeSet.add removes the largest value (pollLast()) before calling TreeSet.add, which does nothing for a value already kept. The repeated c evicts a, and the next new word, b, takes the free slot.
  5. 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.

Who is affected

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.

Terms

  • Min hash, bottom-k. Each token is hashed to a 128-bit value. With one hash the filter keeps the hashSetSize lowest 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-private TreeSet subclass that holds the kept values of one (hash, bucket), with a capacity of hashSetSize.

The bug

  1. 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).
  2. FixedSizeTreeSet.add (:333-345): if the set is full and the new value is not below last(), it returns false (:337-338); otherwise it calls pollLast() (:340) and then TreeSet.add (:343).
  3. TreeSet.add leaves 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.
  4. The next new value is added without any check because the set is no longer full, however large it is.
  5. With hashSetSize 1 the only kept value is also the largest, so its repeat compares equal and is rejected at :337: the default is safe.

Trace of "c a c b", keep 2 (hashes c < a < b)

tokenset beforewhat add doesset 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}.

The numbers

inputkeepexpectedactual
"b a" (control)2a, ba, b
"b a a"2a, ba
"c a b" (control)2c, ac, a
"c a c b"2c, ac, b
"c a b c"2c, ac
150-word cycle read once / twice, 5-word shingles100the same 100 values100 / 99
any of these1 (default)correct
Exposure and likelihood359 words
  • Who. Users of MinHashFilter or MinHashFilterFactory (SPI name minHash) with hashSetSize >= 2. The javadoc's first example is exactly that: "1 hash and keep the 100 lowest values". hashSetSize is a public constructor and factory parameter, validated only as > 0. Downstream engines wrap the factory as a min_hash token filter and pass hash_set_size through; one of them allows up to 10,000. How many applications use hashSetSize >= 2 is not known.
  • Not affected. The default hashSetSize of 1, which published downstream example configurations also use. With many buckets (the default is 512) a bucket must first hold hashSetSize values 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.

Minimal repro

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).

TestMinHashRepeatedToken.java (JUnit test)51 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.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;
  }
}
Command
# 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 -ea and -da.

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

More files, environment, full output35 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: 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)

Why it looks unintended

  • The javadoc says the filter can "keep the 100 lowest values"; repeating a token cannot change those.
  • FixedSizeTreeSet overrides TreeSet.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 returns false." FixedSizeTreeSet returns false too, 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 TestMinHashFilter feeds FixedSizeTreeSet only distinct values (testCollisions, testHashNotRepeated), and its bookkeeping assumes that add() == false removed nothing; testTokenStream3 (hashSetSize 10, 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 add body 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.

Suggested fix

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.

Also reject a value the full set already holds (one line) (validated by the checks) (recommended)

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 default hashSetSize 1 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 with tests.asserts=true and =false (the hashSetSize 1 control passes).
  • With the fix: the repro passes 5 of 5 with tests.asserts=true and =false; the whole lucene/analysis/common suite passes (340 classes, 1,914 tests, 0 failures, 18 skipped), including TestMinHashFilter 13 of 13; TestRandomChains (in lucene/analysis.tests; it can pick MinHashFilter; whether this seed did was not checked) passes 2 of 2 with one seed; ecjLint and checkGoogleJavaFormat pass 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 a9779ef3c193 tree.
  • Classpath runner: the repro passes 5 of 5 with the fix shadowed, -ea and -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 main ea57360912ed, 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).

Insert first, trim after (classpath runner only) (validated by the checks)

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 TestMinHashFilter tests 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.

De-duplicate tokens before hashing (not run)

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.

Document repeats as unsupported

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 note

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.

How it was found

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 hashSetSize lowest 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 MinHashFilter on 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 with hashSetSize >= 2 and a repeated token.
  • A second model that uses the FixedSizeTreeSet.add rule 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; the JIRA archive; downstream repositories2026-10-26, 2026-10-274 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-27GraphQL 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 PRsno report; open PR #16911 deletes the branch_10x files (not a fix)
JIRA LUCENE, SOLR, OAK; lists.apache.org; the web2026-10-27LUCENE-6968 read in full (43 comments), SOLR and OAK issues, every MinHash list thread, 7 web searchesno report;
Related items (read)2026-10-27the threads of the MinHash issues founddifferent 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

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis 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 affectedthis 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 affectedthe version-independent probe: "b a" gives 2 values, "b a a" 1, byte-identical output
6.2.0 first releasethe probe fails the same way (factory API)
6.1.0 no APIthe probe does not compile: no org.apache.lucene.analysis.minhash package
fix (|| contains(toAdd)) not affectedthis doc's JUnit 5 of 5 pass; the exhaustive model check 0 of 524,256 mismatches

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive 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, highOwn 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 realOwn 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 weakOwn 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 realRead 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 confidenceRe-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 confirmedHistory 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 validatedThis 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.

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

Finding record

LFV-649, snapshot of 2026-11-06

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 whole lucene/analysis/common test 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: main a9779ef3c193 and the hunt's earlier main ea57360912ed; 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.

Lucene and upstream sources

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-345
  • JDK 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)

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 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.

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.