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.
JaroWinklerDistanceis one of theStringDistanceclasses that Lucene's spell checkers (and applications) use to score how similar two words are, from 0 (nothing in common) to 1 (identical).- 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.
- 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.
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.- 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 callsetThreshold(-1). - 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
getDistanceto return 0.9444. - 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.
- Why:
getDistanceleaves 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. - 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.
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.
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], sojis 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);wheremtp[2]is the common prefix length andmtp[3]the longer length (:85-93).- threshold 0 or less:
j < thresholdis never true, so the bonus is always added (it is 0 for a pair with no common prefix). NaN acts the same way, becausej < NaNis false. Pairs with no matching character never reach this line: they return 0 at:100-102for every threshold. - threshold 1 or more: either
j < threshold(plainj), orj == 1and the bonus is multiplied by1 - j = 0. So the result is always plain Jaro, in the sense below.
- threshold 0 or less:
- What "plain Jaro" means here: Lucene's own unboosted value,
jfrom:103, or 0 for a pair with no matching character (:100-102). It is not textbook Jaro in every detail: Lucene compares UTF-16chars 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."
| threshold | martha/marhta | dixon/dicksonx | result |
|---|---|---|---|
| 0.7 (default), 0, -0, -0.5, -1, -Infinity, NaN | 0.96111107 | 0.81333333 | Jaro-Winkler |
| 1.0, 1.5, 2.0 | 0.9444444 | 0.76666665 | Jaro |
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.
JaroWinklerDistanceis used only when an application picks it, throughSpellChecker.setStringDistance,DirectSpellChecker.setDistanceor its own code; the trigger is an explicitsetThresholdcall 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
setThresholdon aJaroWinklerDistance;TestJaroWinklerDistanceandTestSpellCheckeruse 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 callssetThreshold; 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
SpellCheckerandDirectSpellChecker) 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.
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.
/*
* 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);
}
}
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>.
More files, environment, full output296 words
- Deterministic. Fixed input, no randomness; it fails the same way with seeds
DEADBEEFand5EED1234and 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 toTestJaroWinklerJaroThresholdand the method totestThresholdOfOneGivesJaroand 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.testspackage, the study used an equivalent public-APImain()probe (2.4.0 to 10.5.2, see the history).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
# 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- 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.
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.
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 < NaNis 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";
StringDistancevalues 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), seedDEADBEEF::lucene:suggest:checkGoogleJavaFormatand:lucene:suggest:renderJavadocpass, and the renderedJaroWinklerDistance.htmlshows 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.
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() < 0is 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, seedDEADBEEF: 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:checkGoogleJavaFormatpasses. - The study's bounded-exhaustive model, run with this fix, left only the empty pair ('', ''), which is the separate #3901.
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.
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;
JaroWinklerDistanceat 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
setThresholdsentence. 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
TestJaroWinklerDistancegreen.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, incl. the migrated JIRA archive) | 2026-10-26 | scripted 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 PRs | 2026-10-26 | the 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 JIRA | 2026-10-26 | scripted 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 searches | no report |
| Downstream and the web | 2026-10-26 | Elasticsearch, OpenSearch, Solr, Lucene.NET, Apache Commons Text, all of GitHub, Stack Overflow (archive copies), web searches | no report; the Lucene.NET port copies the same sentence and condition |
| Recheck | 2026-11-01 | upstream file on main (history clone; GitHub API); the fact check's searches of apache/lucene issues and PRs | file unchanged; "setThreshold Jaro", "Jaro distance", "Winkler bonus", "JaroWinkler threshold": 0 hits; "JaroWinklerDistance": only #3901 and #2374 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails (gradle, assertions on and off); passes with the code alternative |
| 10.6.0 | affected | minimal test fails (release jars, 2026-11-01) |
10.5.2, 9.12.3, main ea57360912ed | affected | the 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 | affected | public-API probe fails: 0.96111107 for -1, controls pass |
| 2.4.0 | first release | public-API probe fails, the same values |
| 2.3.2 | no API | compile error: no JaroWinklerDistance |
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).
| Check | Verdict | What it found |
|---|---|---|
| study finder | candidate | Bounded-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, doc | Own 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, medium | Works-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 claimed | 10 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 only | No test, CHANGES, MIGRATE or package doc covers negative thresholds; #2374 never discussed them. |
| adjudication | confirmed, high; documentation defect; Low | Re-ran both tests on main; javadoc fix as the primary proposal. |
| this doc's validation | fails on stock main; both fixes validated | Gradle 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, fixed | Re-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, fixed | OpenAI 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. |
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 448 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,125 words
- 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 mainea57360912ed, 10.5.2 and 9.12.3 (JUnit) and 2.4.0 to 10.5.2 (a public-API probe). Upstream mainf815e679bf6c(2026-11-01) has the same file.
- 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)
- TestJaroWinklerNegativeThreshold.java:
repro/LFV-537/src/org/apache/lucene/search/spell/TestJaroWinklerNegativeThreshold.java(33 lines)
assets/LFV-537/bug.png(448 KB)
