All findings · Lucene finding · LFV-537 · jarowinkler-negative-threshold-javadoc-inverted

JaroWinklerDistance.setThreshold javadoc says a negative value gives the Jaro distance, but it gives Jaro-Winkler

The setThreshold javadoc says a negative value gives the Jaro distance, but a negative threshold adds the Winkler prefix bonus exactly as 0 does: martha/marhta scores 0.9611, not its Jaro score 0.9444. Only a threshold of 1 or more gives plain Jaro, so the javadoc is the side to fix.

Summary

Priority
Low, rank 1271The JaroWinklerDistance threshold javadoc is wrong and only slightly raises scores for callers who follow it, documentation only.
Impact
minor
Since
2.4.0 (LUCENE-1297, 2008-06-18)introduced 2008-06-18 · over 18 years
Affected
2.4.0 to 10.6.0 and main; documentation only
Status
documented
Found by
semi-formal: bounded-exhaustive check against a separate Jaro model

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

getDistance computes the Jaro score j (JaroWinklerDistance.java:103) and adds the bonus unless j < threshold (:104). j is never below 0 and the bonus vanishes at j = 1, so 0 or less always adds it and 1 or more always returns plain Jaro (Lucene's unboosted value). The javadoc (:109-110) promises plain Jaro for a negative value.

Background: Jaro and Jaro-Winkler in Lucene

  1. JaroWinklerDistance is one of the StringDistance classes that Lucene's spell checkers (and applications) use to score how similar two words are, from 0 (nothing in common) to 1 (identical).
  2. The Jaro score counts matching letters and swapped pairs: for martha/marhta, 6 matches and one swapped pair give (6/6 + 6/6 + 5/6) / 3 = 0.9444.
  3. Jaro-Winkler adds a bonus for a shared prefix: here 0.1 x 3 x (1 - 0.9444) = 0.0167, so 0.9611. Lucene skips it when the Jaro score is below a threshold, 0.7 by default.
Diagram of the bug. Left: setThreshold(-1), getDistance("martha", "marhta") on main; the two strings with 6 matches, one transposition and the common prefix mar (3), j = (1 + 1 + 5/6) / 3; expected (javadoc) plain Jaro 0.9444444, actual 0.96111107 = j + 0.1 x 3 x (1 - j), Jaro-Winkler; the same for every threshold of 0 or less, while 1 or 2 gives 0.9444444. Right: the javadoc sentence (JaroWinklerDistance.java:109-110), the Jaro value (:103) and the bonus condition j < getThreshold() (:104): j is never negative, so with a threshold of 0 or less the bonus is always added; 0 < t < 1 adds it when j >= t; t >= 1 gives plain Jaro. Below: affected releases 2.4.0 to 10.6.0 and main, who is hit, and the javadoc fix.
martha/marhta at threshold -1: the javadoc promises plain Jaro 0.9444, but getDistance adds the prefix bonus and returns 0.9611. Every threshold of 0 or less does the same; only 1 or more gives Jaro. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You want plain Jaro similarity from JaroWinklerDistance, so you do what its javadoc says ("Set to a negative value to get the Jaro distance") and call setThreshold(-1).
  2. For "martha" and "marhta" the Jaro score is (6/6 + 6/6 + 5/6) / 3 = 0.9444 (6 matching letters, one swapped pair), so you expect getDistance to return 0.9444.
  3. Instead you get 0.9611, the Jaro-Winkler score: the shared prefix "mar" adds a bonus of 0.1 x 3 x (1 - 0.9444) = 0.0167.
  4. Why: getDistance leaves the bonus out only when the Jaro score is below the threshold, and a Jaro score is never below 0. So any negative threshold adds the bonus, exactly like 0 (pairs with no matching character return 0 before the bonus step); only 1 or more gives plain Jaro, Lucene's unboosted value.
  5. Nothing fails or warns, and scores only go up: abcdef/abxyzw gets 0.6444 instead of its Jaro score 0.5556, while the default threshold 0.7 leaves it at 0.5556.

Who is affected

Only applications that use JaroWinklerDistance directly and call setThreshold with a negative value to get plain Jaro, as the javadoc says. They silently get Jaro-Winkler scores (never lower, still in [0, 1]), so a spell checker's accuracy cut-off or their own cut-off can let more pairs through. The default 0.7 is unaffected, and no Lucene code calls the setter.

Mechanism, terms and numbers634 words

JaroWinklerDistance (suggest module) adds a Winkler bonus for a shared prefix to the Jaro score j, but skips it when j is below a threshold, 0.7 by default. The setThreshold javadoc says "Set to a negative value to get the Jaro distance." That is false: getDistance skips the bonus only when j < threshold, and j is never negative, so a negative threshold adds the bonus to every pair that has a matching character (pairs with none return 0 first), exactly as 0 does. setThreshold(-1) then getDistance("martha", "marhta") returns 0.96111107 (Jaro-Winkler) instead of 0.9444444 (Jaro). Only a threshold of 1 or more gives plain Jaro, meaning Lucene's own unboosted value.

The sentence and the condition were written in the same 2008 commit (LUCENE-1297), so the sentence was never true; every release from 2.4.0 to 10.6.0 and main carries it. The threshold condition follows the usual Winkler boost-threshold rule and the rest of the class's documentation agrees with it, so this is a documentation defect: the proposed fix is the javadoc, with a code change only as an option. Not security-sensitive.

The code (main 645d4fe14a20)

  • JaroWinklerDistance.java:29: private float threshold = 0.7f;
  • :103: float j = ((m / s1.length() + m / s2.length() + (m - mtp[1]) / m)) / 3; Each term is in [0, 1], so j is in [0, 1]; pairs with no match return 0 earlier (:100-102).
  • :104: float jw = j < getThreshold() ? j : j + Math.min(0.1f, 1f / mtp[3]) * mtp[2] * (1 - j); where mtp[2] is the common prefix length and mtp[3] the longer length (:85-93).
    • threshold 0 or less: j < threshold is never true, so the bonus is always added (it is 0 for a pair with no common prefix). NaN acts the same way, because j < NaN is false. Pairs with no matching character never reach this line: they return 0 at :100-102 for every threshold.
    • threshold 1 or more: either j < threshold (plain j), or j == 1 and the bonus is multiplied by 1 - j = 0. So the result is always plain Jaro, in the sense below.
  • What "plain Jaro" means here: Lucene's own unboosted value, j from :103, or 0 for a pair with no matching character (:100-102). It is not textbook Jaro in every detail: Lucene compares UTF-16 chars and halves the transposition count with integer division (:93), so aaaabc/aaabca gives 0.9444444 at threshold 1, where textbook Jaro (1.5 transpositions) gives 0.9166667; and ("", "") returns 0 at every threshold, where textbook Jaro gives 1 (the separate #3901). The bonus is not textbook Jaro-Winkler either: the prefix is not capped at 4 and the scale is min(0.1, 1 / longer length) (:85-93, :104), so abcdefgh/abcdefgx gives 0.975 at the default, where textbook Jaro-Winkler gives 0.95. None of this changes the bug: in this class, "the Jaro distance" can only mean the unboosted value.
  • :108-113, the false sentence: "Sets the threshold used to determine when Winkler bonus should be used. Set to a negative value to get the Jaro distance."
  • :118-123, the getter, which matches the code: "Returns the current value of the threshold used for adding the Winkler bonus. The default value is 0.7."

What each threshold does (stock jar)

thresholdmartha/marhtadixon/dicksonxresult
0.7 (default), 0, -0, -0.5, -1, -Infinity, NaN0.961111070.81333333Jaro-Winkler
1.0, 1.5, 2.00.94444440.76666665Jaro

Other pairs at threshold -1 (Jaro, then the returned value): dwayne/duane 0.82222 to 0.84000, jones/johnson 0.79048 to 0.83238, abcdef/abxyzw 0.55556 to 0.64444. abcdef/abxyzw gets no bonus at the default 0.7, because its Jaro score is below 0.7, so the setting the javadoc offers for plain Jaro scores it higher than the default does.

Exposure and likelihood188 words
  • Opt-in, rare. JaroWinklerDistance is used only when an application picks it, through SpellChecker.setStringDistance, DirectSpellChecker.setDistance or its own code; the trigger is an explicit setThreshold call with a negative value, which is what the javadoc tells someone who wants plain Jaro to do.
  • No Lucene caller. Nothing in Lucene main or its tests calls setThreshold on a JaroWinklerDistance; TestJaroWinklerDistance and TestSpellChecker use the default. The study's security screen read the spell-suggestion code of Elasticsearch, OpenSearch and Solr (2026-10-26, default branches): all three use the no-argument constructor and none calls setThreshold; the fact check re-read the Elasticsearch and Solr lines on 2026-11-01. A spot check of three servers, not a survey of applications.
  • Effect. Pairs with a shared prefix score higher, never lower, so a spell checker's accuracy cut-off (the score read in SpellChecker and DirectSpellChecker) can let a few more candidates through and their order can change; an application's own similarity cut-off can accept more pairs. Silent, deterministic, on every call.
  • Scale. Every release since 2.4.0 (2008); no public report was found in 18 years, which fits the small, easy-to-miss effect. Impact minor, priority Low.

Minimal repro

One JUnit test, one method, deterministic: setThreshold(-1), then it asserts that getDistance("martha", "marhta") is the Jaro value (1 + 1 + 5/6) / 3 = 0.9444, as the javadoc promises. Public API only.

TestJaroWinklerNegativeThreshold.java33 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.search.spell;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestJaroWinklerNegativeThreshold extends LuceneTestCase {

  public void testNegativeThresholdGivesJaro() {
    JaroWinklerDistance jw = new JaroWinklerDistance();
    jw.setThreshold(-1);
    // expected the Jaro value (1 + 1 + 5/6) / 3 = 0.9444, actual the Jaro-Winkler value 0.9611
    assertEquals(
        "setThreshold javadoc: a negative value gives the Jaro distance",
        (1f + 1f + 5f / 6f) / 3f,
        jw.getDistance("martha", "marhta"),
        1e-6f);
  }
}
Command
cp $REPRO/TestJaroWinklerNegativeThreshold.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/spell/
cd $LUCENE
./gradlew :lucene:suggest:test --tests org.apache.lucene.search.spell.TestJaroWinklerNegativeThreshold -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
Per the javadoc, the Jaro value 0.9444444.
Observed
0.96111107, the Jaro-Winkler value: expected:<0.9444444> but was:<0.96111107>.

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

More files, environment, full output296 words
  • Deterministic. Fixed input, no randomness; it fails the same way with seeds DEADBEEF and 5EED1234 and with assertions on and off.
  • It transcribes the current javadoc. Under the recommended javadoc fix it does not pass; it becomes obsolete. As a regression test for the javadoc fix (optional for a javadoc-only change), use this same test with the value the corrected sentence names: jw.setThreshold(1) instead of -1, message "setThreshold javadoc: 1 or more gives the Jaro distance". The run copy also renames the class to TestJaroWinklerJaroThreshold and the method to testThresholdOfOneGivesJaro and rewords the comment; nothing else changes. It passes with the javadoc fix, with assertions on and off. Under the code alternative the test as written passes unchanged.
  • Releases. The same file fails unchanged on the 10.6.0 release jars. For releases before 9.1, which lack the org.apache.lucene.tests package, the study used an equivalent public-API main() probe (2.4.0 to 10.5.2, see the history).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 10.6.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestJaroWinklerNegativeThreshold.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/spell/
cd $LUCENE
./gradlew :lucene:suggest:test --tests org.apache.lucene.search.spell.TestJaroWinklerNegativeThreshold -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)

Observed output

# selected exact lines of the gradle output (task lines, warnings, repeated nested frames left out)
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestJaroWinklerNegativeThreshold > testNegativeThresholdGivesJaro FAILED
    java.lang.AssertionError: setThreshold javadoc: a negative value gives the Jaro distance expected:<0.9444444> but was:<0.96111107>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:246726E85FD44D7C]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:577)
        at org.apache.lucene.search.spell.TestJaroWinklerNegativeThreshold.testNegativeThresholdGivesJaro(TestJaroWinklerNegativeThreshold.java:27)
:lucene:suggest:test (FAILURE): 1 test, 1  failure
# separate recorded runs, summarized (not console output):
# -Ptests.asserts=false: the same failure
# 10.6.0 release jars (standalone): the same failure
# javadoc fix (option A): changes no code; the same test pinned to setThreshold(1) passes
# code alternative (option B): :lucene:suggest:test (SUCCESS): 1 test

Why it looks unintended

  • The javadoc promises plain Jaro for a negative value (JaroWinklerDistance.java:109-110); the code adds the bonus instead, as threshold 0 does.
  • The getter's javadoc matches the code ("threshold used for adding the Winkler bonus"); no test, CHANGES entry or issue covers negative values.
  • The threshold condition is the usual Winkler boost-threshold rule and 18 years old: fix the javadoc.
The full argument170 words
  • A direct promise, inverted. The sentence says a negative value gives the Jaro distance; a negative value gives the bonus to every pair that has a matching character.
  • The other documentation agrees with the code: the first sentence of the same javadoc ("the threshold used to determine when Winkler bonus should be used"), the getter ("threshold used for adding the Winkler bonus") and the constructor ("the default threshold for the Jaro Winkler bonus (0.7)").
  • No coverage: no test, CHANGES or MIGRATE entry, or package doc mentions negative thresholds; the upstream tests use only the default.
  • History: one 2008 commit (LUCENE-1297, #2374) added the class, the sentence and the condition. In #2374 a reviewer asked for a configurable threshold and the contributor replied the same day "Added getter/setter for JaroWinker threshold and javadoc"; none of the 16 comments on #2374 (17 on LUCENE-1297 in JIRA, the last being the 2022 migration notice) discusses negative values. Earlier patch versions are not in the repository, so whether one honoured the sentence is unknown.
The devil's-advocate check95 words

The strongest case: the runtime rule is coherent (its threshold condition is the usual Winkler boost-threshold rule) and 18 years old, and users who pass a negative value today get the always-bonus mode and may rely on it; perhaps the author meant "a negative value always applies the bonus" and wrote "Jaro" by mistake.

Why it fails as a defence of the docs: under either reading the sentence is false, since it says "Jaro" and the result is Jaro-Winkler. The argument only shows which side to fix, the javadoc, which is what this doc proposes.

Suggested fix

Recommended A. Correct the javadoc

Fix the javadoc: say the bonus is skipped when the Jaro distance is below the threshold and added otherwise, that 1 or more gives the Jaro distance, and that 0 or less always applies the bonus. No behaviour change. Validated with gradle: the format check and javadoc render pass, and all 31 spell tests pass.

   /**
-   * Sets the threshold used to determine when Winkler bonus should be used. Set to a negative value
-   * to get the Jaro distance.
+   * Sets the threshold used to determine when Winkler bonus should be used: the bonus is skipped
+   * when the Jaro distance is below the threshold and added otherwise. Set to 1 or more to get the
+   * Jaro distance; a value of 0 or less always applies the Winkler bonus.
    *
    * @param threshold the new value of the threshold
    */
All options and trade-offs2 options · 656 words

Two options; both were applied in a scratch worktree of main 645d4fe14a20 and run with gradle. The javadoc fix is the proposal, because the threshold condition follows the usual Winkler boost-threshold rule and the rest of the class's documentation already describes it.

A. Correct the javadoc (validated by the checks) (recommended)

Replace the false sentence with what the code does.

   /**
-   * Sets the threshold used to determine when Winkler bonus should be used. Set to a negative value
-   * to get the Jaro distance.
+   * Sets the threshold used to determine when Winkler bonus should be used: the bonus is skipped
+   * when the Jaro distance is below the threshold and added otherwise. Set to 1 or more to get the
+   * Jaro distance; a value of 0 or less always applies the Winkler bonus.
    *
    * @param threshold the new value of the threshold
    */

Trade-offs

  • No behaviour change, so no existing score moves; it uses the wording of the first sentence and of the getter ("threshold used for adding the Winkler bonus").
  • Callers who already pass a negative value to get Jaro keep getting Jaro-Winkler; they can use 1 instead.
  • "1 or more", not "above 1": at exactly 1 the bonus is multiplied by 1 - j = 0 when j = 1, so 1 also gives plain Jaro.
  • The wording "skipped when below the threshold, added otherwise" is exactly the j < getThreshold() test, so it is also right for NaN, where the bonus is added (j < NaN is false). An earlier draft said "added when the Jaro distance is at least the threshold", which is wrong for NaN.
  • It keeps the class's historical word "distance"; StringDistance values are similarities (1 means identical, StringDistance.java:22-31), so "Jaro similarity" is a fine alternative. "The Jaro distance" means Lucene's unboosted value; the empty-string result is the separate #3901.

What the checks validated

  • Gradle, a second scratch worktree of 645d4fe14a20 (after the fact check corrected the wording), seed DEADBEEF: :lucene:suggest:checkGoogleJavaFormat and :lucene:suggest:renderJavadoc pass, and the rendered JaroWinklerDistance.html shows the new text in the method detail (the method summary shows its first sentence). The first wording passed the same steps in the first worktree.
  • The spell tests (org.apache.lucene.search.spell.*: the 31 upstream tests of the 8 spell test classes plus the minimal test pinned to the corrected sentence, setThreshold(1)) pass, 32 tests, with assertions on; the pinned test passes with assertions off too.
  • The minimal test as written was not re-run with it: the fix changes only a comment, so it keeps failing as on stock main.

B. Make a negative threshold mean plain Jaro (an option only) (validated by the checks)

In getDistance, never add the bonus when the threshold is negative, which makes the current sentence true.

     float j = ((m / s1.length() + m / s2.length() + (m - mtp[1]) / m)) / 3;
-    float jw = j < getThreshold() ? j : j + Math.min(0.1f, 1f / mtp[3]) * mtp[2] * (1 - j);
+    float jw =
+        (getThreshold() < 0 || j < getThreshold())
+            ? j
+            : j + Math.min(0.1f, 1f / mtp[3]) * mtp[2] * (1 - j);
     return jw;

Trade-offs

  • Changes scores for every caller that passes a negative value today (Jaro-Winkler becomes Jaro); it would likely warrant a CHANGES entry as a behaviour change.
  • Makes the threshold non-monotonic: 0 and -0.0 (for which getThreshold() < 0 is false) and NaN still always add the bonus, while -0.5 never does (the study's threshold table, run with this fix). That contradicts the first sentence of the same javadoc.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: the minimal test as written passes with assertions on (inside the 32-test spell run) and off, the 31 upstream spell tests pass, and :lucene:suggest:checkGoogleJavaFormat passes.
  • The study's bounded-exhaustive model, run with this fix, left only the empty pair ('', ''), which is the separate #3901.

Downstream note

Products that expose this threshold to their own users would inherit the misleading sentence; in a spot check, Elasticsearch, OpenSearch and Solr use the no-argument constructor and do not call setThreshold.

How it was found

Semi-formal. A hunt unit compared Lucene's string distances with a separate model over all 14,641 ordered pairs of strings of up to 4 code points over a 3-code-point alphabet, at thresholds 0.7, -1, 0 and 2. At -1, JaroWinklerDistance differed from Jaro on 4,680 pairs (plus the empty pair, the known #3901); at 2 it matched.

The method in full139 words

Method: bounded-exhaustive translation validation of the StringDistance implementations on the stock jar (hunt unit on spell-checker dictionaries and word break, 2026-10-26, main ea57360912ed).

  • Inputs: every ordered pair of the 121 strings of at most 4 code points over {a, b, U+1F600}, 14,641 pairs; JaroWinklerDistance at thresholds 0.7, -1, 0 and 2.
  • Oracle: a separate Jaro / Jaro-Winkler model that follows Lucene's matching arithmetic (UTF-16 chars, integer-halved transpositions; Jaro of ('', '') is 1), and the setThreshold sentence. Threshold -1 differed from that Jaro on 4,681 pairs: 4,680 plus ('', ''), which is the separate known #3901 (getDistance("", "") returns 0). Threshold 2 matched Jaro except on ('', '').
  • The finder then wrote a public-API JUnit test with controls, which failed on main, 10.5.2 and 9.12.3; a code-variant fix made it pass and kept the upstream TestJaroWinklerDistance green.

Prior reports, history and checks

Prior reports

No matching public report found in the recorded searches (2026-10-26, 210 queries: GitHub issues and PRs incl. the JIRA archive, mailing lists, downstream projects, the web and open PRs). Related, not the same: open #3901, getDistance("", "") returns 0 in the same class.

Searches5 searches

The recorded searches found no report or discussion of the false sentence (keyword searches: a report that never names Jaro, Winkler or JaroWinkler would be missed). The nearest items are listed below.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive)2026-10-26scripted prior-report search 1 (16 issue/PR and 3 commit searches) plus 56 GraphQL searches, e.g. JaroWinklerDistance, "Jaro distance", "Winkler bonus", setThreshold Jaro; all 16 comments of #2374 read (17 on LUCENE-1297 in JIRA)#3901 (open, empty strings return 0: different defect and fix site); #2374 (LUCENE-1297, added the class, no discussion of negative thresholds); #3581 (unrelated threshold)
Open PRs2026-10-26the changed files of all 407 open apache/lucene PRs#16847 and #13951 touch search/spell/ but not JaroWinklerDistance.java (full file list of #16847, 438 files: 7 spell files, none this one)
Mailing lists and JIRA2026-10-26scripted prior-report search list and JIRA queries (20 + 4 in run 1, 24 in run 2); 60 direct searches of the dev, java-user, issues, general, users and solr-user lists; 5 LUCENE and SOLR JIRA searchesno report
Downstream and the web2026-10-26Elasticsearch, OpenSearch, Solr, Lucene.NET, Apache Commons Text, all of GitHub, Stack Overflow (archive copies), web searchesno report; the Lucene.NET port copies the same sentence and condition
Recheck2026-11-01upstream file on main (history clone; GitHub API); the fact check's searches of apache/lucene issues and PRsfile unchanged; "setThreshold Jaro", "Jaro distance", "Winkler bonus", "JaroWinkler threshold": 0 hits; "JaroWinklerDistance": only #3901 and #2374

History

In the class's first commit, ce19aa28c01 (LUCENE-1297, 2008-06-18), first released in 2.4.0; the sentence was never true. It reproduces on every release tested, from 2.4.0 to 10.6.0, and on main; 2.3.2 has no such class. Not fixed upstream.

Versions tested6 rows

Introduced by ce19aa28c01 "LUCENE-1297 - Allow other string distance measures for the SpellChecker" (svn r669085, 2008-06-18, migrated as #2374; pre-GitHub, no PR), which added the class, the sentence and the condition together. None of the 13 later commits on the file changed the sentence or the condition: a typo fix in the getter javadoc (2009), a cast removal (2010), three pure renames (to lucene/contrib/spellchecker, modules/suggest and lucene/suggest), the class javadoc (2012), license headers (2012, 2016), hashCode/equals/toString (SOLR-3548, 2012), the constructor javadoc (2012), @Override (2012), a google-java-format reflow (2cbf261032d, LUCENE-9570, 2021-01-05) and a <p> cleanup (2662a74cab52, 2021-04-05, the last commit on the file). Only the reflow touched the two lines, and it left their text unchanged. All 138 release tags from 2.4.0 to 10.5.2 in the history clone contain both the sentence and the condition; the 10.6.0 release tag (tagged 2026-10-29, its commit dated 2026-10-26) has the same file as main. Upstream main f815e679bf6c (2026-11-01) has the same file blob.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails (gradle, assertions on and off); passes with the code alternative
10.6.0 affectedminimal test fails (release jars, 2026-11-01)
10.5.2, 9.12.3, main ea57360912ed affectedthe study's JUnit tests fail: 0.96111107 instead of 0.9444444
10.0.0, 9.0.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.0.0, 3.6.2, 3.0.3, 2.9.4, 2.4.1 affectedpublic-API probe fails: 0.96111107 for -1, controls pass
2.4.0 first releasepublic-API probe fails, the same values
2.3.2 no APIcompile error: no JaroWinklerDistance

Independent checks

Every check that ran Java agrees it is a real documentation defect: the study finder, an independent reproducer with its own Jaro reference, a devil's advocate and the adjudication (confirmed, high confidence). A cross-model reviewer (OpenAI GPT-5.6) could only read code; it agreed with the root cause in the lenses.

Checks and verdicts375 words

Each check ran the test on main with assertions on and off; the reviewer that could not run Java had its lenses' tests re-run separately (fail).

Each independent check and its verdict
CheckVerdictWhat it found
study finder candidateBounded-exhaustive check (4,681 of 14,641 pairs differ at -1); public-API JUnit fails on main, 10.5.2 and 9.12.3; the code-variant fix passes and keeps TestJaroWinklerDistance green.
independent reproducer real, docOwn test with a textbook Jaro reference: 1 of 4 fails (3 controls pass) on main, 10.5.2 and 9.12.3. Its sweep of 14,400 pairs (-1 never lower than Jaro, 1.0 always equal) was a scratch run without a saved record; the diagram probe and the fact check re-ran it with recorded output and the same counts. Corrected 'above 1' to '1 or more'.
cross-model review, refute lens not real, low confidence (procedural)OpenAI GPT-5.6, reading only: could not run Java; agreed with the root cause and that the inputs are in contract, and suggested the javadoc fix. A separate re-run of the test failed.
cross-model review, intended lens real, mediumWorks-as-designed argument moderate: the threshold condition is the usual Winkler boost-threshold rule, so the javadoc is the side to fix.
threshold table fails as claimed10 thresholds on 2 pairs: 0 or less, NaN and 0.7 give Jaro-Winkler; 1.0 and above give Jaro; passes with the code fix.
devil's advocate real, documentation defect onlyNo test, CHANGES, MIGRATE or package doc covers negative thresholds; #2374 never discussed them.
adjudication confirmed, high; documentation defect; LowRe-ran both tests on main; javadoc fix as the primary proposal.
this doc's validation fails on stock main; both fixes validatedGradle in a scratch worktree of 645d4fe14a20: stock main, the javadoc fix and the code alternative; standalone on 10.6.0.
fact check bug confirmed; 4 minor wording errors, fixedRe-ran the gradle repro (assertions on and off), standalone runs on main and 10.6.0, the threshold table, both sweeps and the history; corrections applied.
cross-model fact check defect real, javadoc fix right; wording errors, fixedOpenAI GPT-5.6, reading the files: caught that the first proposed wording was wrong for NaN and that 'plain Jaro' needed the empty-pair caveat; one claim rejected after checking.

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

Finding record

LFV-537, snapshot of 2026-11-06

Registry title
JaroWinklerDistance.setThreshold javadoc says 'Set to a negative value to get the Jaro distance', but a negative threshold adds the Winkler bonus to every pair (the same as threshold 0); only a threshold of 1 or more gives plain Jaro; documentation defect, the javadoc fix is the default proposal
Area
suggest / spell (JaroWinklerDistance threshold)
Priority
Low, rank 1271: The JaroWinklerDistance threshold javadoc is wrong and only slightly raises scores for callers who follow it, documentation only.
Impact class
minor
Impact note
Only applications that use the suggest module's JaroWinklerDistance and follow the setThreshold javadoc to get plain Jaro (a negative threshold) are hit: they get Jaro-Winkler scores instead, equal to or higher than Jaro, still in [0, 1] and identical to threshold 0, so pairs with a shared prefix score higher and a spell-check accuracy cut-off or the caller's own match cut-off can let more pairs through; the default threshold 0.7 is unaffected, no Lucene code calls setThreshold, nothing throws and nothing is written, and every release from 2.4.0 (2008) is affected.
Summary
You want the plain Jaro similarity from JaroWinklerDistance, so, as the setThreshold javadoc says ('Set to a negative value to get the Jaro distance'), you call setThreshold(-1) and expect getDistance("martha", "marhta") to return the Jaro value 0.9444. Instead it returns 0.9611, the Jaro-Winkler value: getDistance skips the Winkler bonus only when the Jaro value is below the threshold, which never happens for a negative threshold, so every pair gets the bonus, exactly as with threshold 0. Only a threshold of 1 or more gives plain Jaro; the sentence has been false since the class was added in 2008, so the javadoc is the side to fix by default.
Introduced
ce19aa28c01060dafae0b60b8005a22972c7ef94 (svn r669085, lucene/java/trunk; LUCENE-1297 'Allow other string distance measures in spellchecker', migrated as apache/lucene#2374; pre-GitHub, so no PR), which added JaroWinklerDistance together with the setThreshold sentence and the condition 'jw = j < getThreshold() ? j : j + bonus' [read: history clone, git blame -w -M -C -C of main lines 96-126 and git log -G on the threshold lines; the only later edit on these lines is the google-java-format reflow 2cbf261032d (LUCENE-9570, 2021-01-05), text identical; the 2.4.0 tag's file is byte-identical to ce19aa28c01's]. The sentence was never true; never a regression. (2008-06-18)
First affected version
2.4.0 (2008-10-06) [exec: the public-API main() probe fails on 2.4.0; 2.3.2 has no JaroWinklerDistance (compile error)]
Affected versions
every release from 2.4.0 through 10.5.2 (151 releases), 10.6.0 [exec 2026-11-01: the minimal test fails on the 10.6.0 release jars, -ea] and main ea57360912ed; upstream main and branch_10x unchanged on 2026-10-26 [exec: public-API main() probe fails with passing controls on 2.4.0, 2.4.1, 2.9.4, 3.0.3, 3.6.2, 4.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da), always 0.96111107 for setThreshold(-1) against Jaro 0.9444444; the finder's JUnit fails on 9.12.3, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3; read: all 138 release tags in the history clone from 2.4.0 to 10.5.2 contain both the sentence and the condition; inferred, untagged in the clone but bracketed on the same line: 2.9.3, 3.0.2, 8.8.2, 8.9.0, 8.10.x, 8.11.0-8.11.3]. Not affected: 2.3.2 and earlier, which have no JaroWinklerDistance [exec: compile error on 2.3.2]. Not fixed upstream. All runs on aarch64 with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, 210 queries: a scripted prior-report search in two runs (51 queries over apache/lucene GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 24 more list and JIRA queries); 56 GitHub GraphQL searches of issues and PRs in apache/lucene, apache/lucene-solr, apache/solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch, apache/commons-text, all of GitHub; 60 direct mailing-list searches; 5 direct LUCENE and SOLR JIRA searches, with every comment of LUCENE-1297, LUCENE-2827 and LUCENE-2507 read; the changed files of all 407 open apache/lucene PRs; git history; 30 hits read, none the same bug). Nearest items, not the same bug: open #3901 / LUCENE-2827 (since 2010: getDistance("", "") returns 0; same class, different defect and fix site); #2374 / LUCENE-1297 (closed; added the class, the setter and the sentence; none of its 17 comments discusses negative thresholds); open PRs #16847 and #13951 touch search/spell/ but not JaroWinklerDistance.java. Not fixed on upstream main (same file blob as ea57360912ed; last change 2662a74cab52, 2021-04-05, javadoc <p> cleanup) or branch_10x
Lucene commit tested
main 645d4fe14a20 (2026-10-28), through gradle in a scratch worktree (2026-11-01): the minimal test fails on stock main with assertions on and off; with the javadoc fix the format check and javadoc render pass, the spell tests pass, and the same test pinned to the corrected sentence (setThreshold(1)) passes with assertions on and off; with the code alternative the minimal test as written passes with assertions on and off and the spell tests pass. After the fact check corrected the javadoc wording, the corrected text passed the format check, the javadoc render and the same spell runs in a second scratch worktree (2026-11-01). The minimal test was run standalone on the 10.6.0 release jars (fail, 2026-11-01). The study's runs (2026-10-26) covered main ea57360912ed, 10.5.2 and 9.12.3 (JUnit) and 2.4.0 to 10.5.2 (a public-API probe). Upstream main f815e679bf6c (2026-11-01) has the same file.

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/spell/JaroWinklerDistance.java:21-26 (class javadoc), :29 (default threshold 0.7), :103 (Jaro j), :104 (bonus condition j < getThreshold()), :108-116 (setThreshold javadoc and setter), :118-126 (getThreshold javadoc 'threshold used for adding the Winkler bonus') at ea57360912ed
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/spell/JaroWinklerDistance.java:29, 31-35, 85-93, 100-104, 108-116, 118-126 (at 645d4fe14a20)
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/spell/SpellChecker.java:382 and DirectSpellChecker.java:458 (the score read), DirectSpellChecker.java:286 (setDistance) (at 645d4fe14a20)
  • lucene/suggest/src/test/org/apache/lucene/search/spell/TestJaroWinklerDistance.java and TestSpellChecker.java:131 (default threshold only); grep of lucene/ for setThreshold (at 645d4fe14a20)
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/spell/JaroWinklerDistance.java:31-35, 108-113, 118-123 (at 645d4fe14a20)
History
  • a Lucene clone: tags releases/lucene/10.5.2 and 10.6.0 (annotated tag dated 2026-10-29, commit 4ae946e5a9b dated 2026-10-26), blob 3b442cb8e538 (same as 645d4fe14a20 and upstream main f815e679bf6c, which is in the history clone; the fact check also read it through the read-only contents API, 2026-11-01)

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

Repro files

About the diagrams

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.