All findings · Lucene finding · LFV-477 · doublemetaphonefilter-drops-alternate-when-token-equals-primary

DoubleMetaphoneFilter drops the alternate code of a token that already equals its primary code, so "JR" gets only JR while "jr" gets JR and AR

DoubleMetaphoneFilter drops the alternate (secondary) code of any token whose text already equals its primary code: "jr" is indexed as JR and AR, but "JR" only as JR. A search on AR finds 1 of the 2 documents "jr" and "JR", in every release since Solr 1.4.0 and Lucene 3.6.0.

Summary

Priority
Low, rank 526The opt-in DoubleMetaphoneFilter drops the alternate code of upper-case acronyms when input is not lower-cased first, losing only matches that depend on that weak alternate code.
Impact
minor
Since
Solr 1.4.0 (2009); Lucene 3.6.0 (2012), the first Lucene release with the classintroduced 2009-08-13 · over 17 years
Affected
every release that ships the class: Solr 1.4.0, Lucene 3.6.0 to 10.5.2 and main (executed on 20 releases and main; the others read from their release tags)
Status
documented
Found by
a bounded-exhaustive comparison of the phonetic filters with the encoder library called directly (semi-formal)

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

One repeated condition: the check that rightly skips a primary code equal to the token is also part of the alternate's condition, so a token that already is its own primary code never gets its alternate.

Double Metaphone in three lines

  1. The encoder turns a word into two upper-case sound codes, a primary and an alternate: "Kuczewski" gives KSSK and KXFS, "jr" gives JR and AR. Codes are cut to maxCodeLength (default 4).
  2. DoubleMetaphoneFilter replaces each token with its codes (inject=false), or keeps the token and adds the codes at the same position (inject=true, the factory default).
  3. A document and a query whose words share either code then match.
Diagram of the bug: the tokens "jr" and "JR" both have the Double Metaphone primary code JR and alternate code AR; DoubleMetaphoneFilter emits JR and AR for "jr" but only JR for "JR", because the alternate's condition also requires the primary code to differ from the token text; a TermQuery on AR finds 1 of the 2 documents instead of 2; keeping the token as the primary and stacking the alternate on it fixes it.
"jr" and "JR" through the filter's two checks: the primary check rightly keeps "JR" as it is, the repeated check drops its alternate AR, and a search on AR finds 1 of 2 documents. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You analyse a field with DoubleMetaphoneFilter, here the factory's documented chain: whitespace tokenizer, then doubleMetaphone with its defaults (inject=true, maxCodeLength 4), no lower-casing. You index two documents, "jr" and "JR".
  2. You expect both to get the codes JR and AR: the encoder ignores letter case and returns primary JR and alternate AR for either spelling. A search on either code should find 2 documents.
  3. You get "jr" indexed as jr, JR, AR, but "JR" only as JR. TermQuery JR finds 2 documents; TermQuery AR finds 1.
  4. Why: the filter skips a primary code equal to the token text (right: the token already is that term), but the alternate's condition repeats the same check, !primaryPhoneticValue.equals(v) (DoubleMetaphoneFilter.java:90), so the different code AR is dropped too.
  5. Hit: upper-case tokens spelled exactly like their own code, such as J, JR, JFK and SMS (206 of 18,278 upper-case strings of length 1-3), in both inject modes. The primary code is still indexed, and nothing throws.

Who is affected

Chains that run DoubleMetaphoneFilter (constructor or doubleMetaphone factory, any settings) on text that is not lower-cased first, such as the factory's own example and Solr's _default phonetic_en field type. Only tokens spelled like their own code are hit, mostly upper-case initials and acronyms; they keep the primary code and lose only alternate-only matches.

Mechanism, terms and numbers637 words

DoubleMetaphoneFilter is documented as "supporting secondary codes": for each token it asks the commons-codec encoder for a primary and an alternate sound code and emits both, replacing the token (inject=false) or next to it (inject=true). The encoder ignores letter case, so "jr" and "JR" both have primary JR and alternate AR. The filter skips a primary code equal to the token text, because the kept token already is that term; but the alternate's condition repeats that check (DoubleMetaphoneFilter.java:90), so a token that already equals its primary code never gets its alternate. "jr" is indexed as JR and AR, "JR" only as JR, and a search on AR finds 1 of the 2 documents. Only tokens spelled exactly like their own code are hit, in practice upper-case initials and acronyms such as J, JR, JFK and SMS; no lower-case string was hit in a probe of every A-Z string of length 1-3. The primary code is always kept, nothing throws and the index is not damaged. The check came with a 2009 rewrite (SOLR-1359), before the first release that shipped the class, so Solr 1.4.0 and Lucene 3.6.0 to 10.5.2 and main are affected. Deleting only the check is not a fix (replace mode would lose the primary); keeping the token as the primary and stacking the alternate on it is.

Terms

  • Primary and alternate code: Double Metaphone gives a word two codes for its two most likely pronunciations; for many words they are equal. The commons-codec encoder returns them through doubleMetaphone(v) and doubleMetaphone(v, true); it upper-cases internally, so letter case does not change the codes.
  • inject: inject=true keeps the original token and adds the codes at position increment 0; inject=false replaces the token with its codes.
  • maxCodeLength: codes are cut to this length (factory default 4).

The bug

  1. incrementToken computes primaryPhoneticValue and alternatePhoneticValue for the token text v (DoubleMetaphoneFilter.java:68-69).
  2. The primary branch runs only when the primary is non-empty and !primaryPhoneticValue.equals(v) (:75-77). That skip is right: when the token already equals its primary code, the kept token carries that term.
  3. The alternate branch requires a non-empty alternate that differs from the primary, and again !primaryPhoneticValue.equals(v) (:87-90). For "JR" the primary JR equals the token, so the alternate AR is skipped although it is a different code.
  4. Nothing was queued, so the token is returned unchanged (:102-104): "JR" becomes [JR] in both modes, while "jr" becomes [JR, AR] (inject=false) or [jr, JR, AR] (inject=true).
  5. The term is load-bearing in replace mode: with inject=false the token is not captured before the alternate overwrites the term, so deleting only the term turns "JR" into [AR] and loses the primary (executed). A fix must keep the token as the primary and stack the alternate on it.

The numbers (maxCodeLength 4; output as TERM/positionIncrement)

tokenencoder: primary / alternateinject=falseinject=true
jr (control)JR / ARJR/1 AR/0jr/1 JR/0 AR/0
JRJR / ARJR/1JR/1
JFKJFK / AFKJFK/1JFK/1
SMSSMS / XMSSMS/1SMS/1
JJ / AJ/1J/1
jfk, sms, j (controls)as aboveboth codestoken + both codes

Which tokens qualify depends on maxCodeLength: "JFK" keeps its alternate at maxCodeLength 1 (J, A) and 2 (JF, AF) and loses it at 3 and 4; "JR" keeps it at 1 and loses it from 2 on. Mixed case is not hit ("Jr" gets JR and AR). Over every A-Z string of length 1-3, 206 of 18,278 upper-case strings lose their alternate at maxCodeLength 4 (1, 23, 206, 206, 206 at maxCodeLength 1, 2, 3, 4, 8) and 0 of 18,278 lower-case strings do; in those 365,560 token streams there was no extra term and no position or offset change.

Exposure and likelihood308 words
  • Who is exposed. Any chain with the public DoubleMetaphoneFilter constructor or the doubleMetaphone factory (DoubleMetaphoneFilterFactory.java:40), with the defaults (inject=true, maxCodeLength=4) or any other settings, on text that is not lower-cased first. The factory's own javadoc example has no lower-casing filter (:26-32). The filter is opt-in: no stock Lucene analyzer uses it.
  • Downstream defaults (read on 2026-10-28, not run): Solr's _default configset field type phonetic_en (dynamic fields *_phon_en) is the standard tokenizer + doubleMetaphone inject="false"; the Elasticsearch analysis-phonetic plugin builds this filter for the encoder double_metaphone, with replace defaulting to true (inject=false) and max_code_len to 4; the follow-up read the same for OpenSearch. Whether a user's chain lower-cases first is up to them.
  • What it takes. A token whose exact text equals its own primary code: upper-case, consonant-heavy initials and acronyms no longer than maxCodeLength (J, JR, JFK, SMS). For such a token it happens every time at index time, at any index size, with assertions on or off. A query analysed by the same chain runs the same filter code, so it loses the alternate too (reasoned from the code; no query parser was run).
  • What changes for the user. Every match through the primary code still works (TermQuery JR finds both documents). Only matches that go through the alternate code alone are lost (TermQuery AR: 1 of 2). For acronyms such a match is mostly a weak phonetic one: words whose only code is AR include "air" and "year".
  • What does not happen. No exception, no extra or changed term, no position or offset change, no index damage.
  • Workaround. A lower-casing filter before DoubleMetaphoneFilter: no lower-case string was hit in the probe (0 of 18,278 A-Z strings of length 1-3; other characters were not probed exhaustively), and lower-case words get both codes.

Minimal repro

The test shows that DoubleMetaphoneFilter drops the alternate code of a token whose text already equals its primary code.

TestDoubleMetaphoneFilterTokenEqualsCode.java (JUnit test)40 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.phonetic;

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;

public class TestDoubleMetaphoneFilterTokenEqualsCode extends BaseTokenStreamTestCase {

  public void testTokenEqualToPrimaryCodeGetsAlternateCode() throws Exception {
    List<String> terms = new ArrayList<>();
    try (TokenStream ts = new DoubleMetaphoneFilter(whitespaceMockTokenizer("JR"), 4, true)) {
      CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
      ts.reset();
      while (ts.incrementToken()) {
        terms.add(term.toString());
      }
      ts.end();
    }
    // "JR" (like "jr") has primary code JR and alternate code AR: expected [JR, AR]; actual [JR]
    assertEquals(List.of("JR", "AR"), terms);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-477/, 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:$LUCENE/lucene/analysis/phonetic/build/libs/lucene-analysis-phonetic-$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.phonetic.TestDoubleMetaphoneFilterTokenEqualsCode
# the module(s) analysis-phonetic also need their third-party runtime jars
Expected vs actual
Expected new DoubleMetaphoneFilter(whitespaceMockTokenizer("JR"), 4, true) to emit [JR, AR] (the codes the encoder gives for both "JR" and "jr"); actual [JR], with -ea and -da.

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

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:<[JR, AR]> but was:<[JR]>
       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.phonetic.TestDoubleMetaphoneFilterTokenEqualsCode.testTokenEqualToPrimaryCodeGetsAlternateCode(TestDoubleMetaphoneFilterTokenEqualsCode.java:38)

Why it looks unintended

  • Documented as "supporting secondary codes"; SOLR-813 added the filter to emit the alternate when it differs from the primary.
  • The encoder ignores case, yet "jr" keeps AR and "JR" loses it, and "JFK" loses its alternate only from maxCodeLength 3 on.
  • The check came with a 2009 rewrite about skipped tokens (SOLR-1359); before it, "J" gave J and A.
The full argument360 words
  • Contract (implicit). Class javadoc: "Filter for DoubleMetaphone (supporting secondary codes)" (DoubleMetaphoneFilter.java:28). SOLR-813 (2008), which added the filter, says the existing PhoneticFilter "ignores the alternate encodings that the encoder provides for some words"; its contributor wrote that the new filter "checks to see if there is an alternate encoding for the token, and adds that to the stream as well, if it's different then the default encoding. This is part of the strength of the DoubleMetaphone implementation". The Solr Reference Guide says PhoneticFilter "will not provide the second (\"alternate\") encoding that is generated by the Double Metaphone Filter for some tokens". No sentence covers a token equal to its code.
  • Tests. TestDoubleMetaphoneFilter.testAlternateInjectFalse expects "Kuczewski" -> KSSK, KXFS (:42-46); no upstream test has a token equal to its own primary code.
  • No documented exception for upper-case or code-shaped tokens in the javadoc, the factory, CHANGES or MIGRATE; the factory's example chain does not lower-case.
  • History. Before SOLR-1359 the alternate was emitted whenever it was non-empty and different from the primary: the previous file version (572d84c9c27) gives "J" -> J, A (executed). The rewrite 551e52f899a (2009-08-13, "DoubleMetaphone filter could skip or reorder tokens, fix and update to new API") added !primaryPhoneticValue.equals(v) to both branches. In the primary branch it removes a real duplicate (inject mode had given "J" -> [J, J, A]); in the alternate branch it removes a different code. The commit, the JIRA issue, the 2009 solr-dev@ thread "DoubleMetaphone bugs?" and CHANGES discuss only skipped or reordered tokens and keeping the original token "if there are no phonetic alternatives"; the commit added no test.
  • A poor fit for a deliberate rule. The result flips with letter case, although the encoder ignores case, and with maxCodeLength; codes that are not fixed points of the encoder are still re-encoded ("XMT" -> SMT, "KSSK" -> KSK); in inject=true mode the term is not needed to keep the token (it is captured first anyway) and only suppresses the alternate and the state captures that emitting it takes. An equality test behaves like this by nature, so these points make a deliberate "already phonetic" rule less likely, not impossible.
The devil's-advocate check231 words

The strongest case for "intended" (the cross-model reviewer's intended lens, the independent reproduction and the intended-behaviour check): a token that already is its own code is treated as already phonetic and passed through untouched. PhoneticFilter has a similar identity check (PhoneticFilter.java:74), the same committer fixed PhoneticFilter's duplicate tokens (SOLR-1360) the same day, the term is load-bearing in replace mode, the class promises only "supporting secondary codes", and alternates of acronyms are arguably noise.

Why it is weaker than it looks: the check compares the primary with the token, not the alternate, so in the alternate branch it never prevents a duplicate; PhoneticFilter's check drops only an output equal to the input; an equality test recognises only codes that are fixed points of the encoder at the chosen length, so it separates "JR" from "jr" and still re-encodes "XMT", a poor fit for an "already phonetic" rule (though it does not rule one out); no commit, issue or comment states such a rule; and the fix meets the replace-path concern without dropping the alternate. The cross-model reviewer rated the argument moderate, and so did the later cross-model fact check, which called this rebuttal partly fair but incomplete; the independent reproduction, the intended-behaviour check, the adjudication and the credibility audit rejected it. What survives is the narrow reach and the implicit contract: reasons for low priority, not for keeping the behaviour.

Suggested fix

Recommended A: keep the token as the primary, stack the alternate on it

Keep the token as the primary code and stack the alternate on it at position increment 0: drop the repeated check from the alternate's condition and add a branch for a token equal to its primary (+5/-2). The repro and all 44 phonetic tests then pass. Deleting only the check is worse: replace mode turns "JR" into AR alone.

         saveState = true;
+      } else if (primaryPhoneticValue != null && primaryPhoneticValue.equals(v)) {
+        // the token already is its primary code: keep it, and stack the alternate on it
+        firstAlternativeIncrement = 0;
+        saveState = true;
       }
 
       if (alternatePhoneticValue != null
           && alternatePhoneticValue.length() > 0
-          && !alternatePhoneticValue.equals(primaryPhoneticValue)
-          && !primaryPhoneticValue.equals(v)) {
+          && !alternatePhoneticValue.equals(primaryPhoneticValue)) {
All options and trade-offs4 options · 617 words

One fix was built and validated in a scratch worktree at a9779ef3c193; a workaround needs no code change. Removing only the condition was executed and is rejected.

A: keep the token as the primary, stack the alternate on it (validated by the checks) (recommended)

The finder's fix, rebased on a9779ef3c193. When the token equals its primary code it is kept as is, and the alternate (when non-empty and different) follows at position increment 0, the same codes as the lower-case spelling. Tokens whose primary differs from their text take exactly the old path.

         saveState = true;
+      } else if (primaryPhoneticValue != null && primaryPhoneticValue.equals(v)) {
+        // the token already is its primary code: keep it, and stack the alternate on it
+        firstAlternativeIncrement = 0;
+        saveState = true;
       }
 
       if (alternatePhoneticValue != null
           && alternatePhoneticValue.length() > 0
-          && !alternatePhoneticValue.equals(primaryPhoneticValue)
-          && !primaryPhoneticValue.equals(v)) {
+          && !alternatePhoneticValue.equals(primaryPhoneticValue)) {

Trade-offs

  • Only tokens that equal their primary code and have a different alternate change: they gain the alternate. Indexes built before the fix lack these terms until reindexed. Plain positive term matching on a rebuilt index only gains matches, but a negated clause (MUST_NOT) on the alternate code now excludes such documents, scores and top-k shift, and a mix of old and new segments (or an old index with the new query analysis) sees a partial change. A CHANGES entry should say so.
  • Input that already is a code changes too: a stand-alone "JR" now gives JR and AR, so a pipeline that knowingly feeds codes, or a chain that runs the filter twice, gets the alternate of such a code as well.
  • Cost on an affected token: two captureState() calls and one more output token (stock makes no capture there; the lower-case spelling costs as much or more, by reading the code). Performance was not measured.

What the checks validated

  • Stock main: the paste-ready test fails 2 of 3, assertions on and off; the 11 upstream DoubleMetaphone tests pass; the whole module passes 8 classes, 41 tests.
  • With fix A, TestDoubleMetaphone*: 3 classes, 14 tests, 0 failures, assertions on and off.
  • With fix A, the whole :lucene:analysis:phonetic:test task: 9 classes, 44 tests, 0 failures, 0 skipped.
  • With fix A, TestRandomChains and TestAllAnalyzersHaveFactories pass (one seed; whether its chains used this filter was not checked).
  • ecjLintMain, ecjLintTest and checkGoogleJavaFormat of the module pass.
  • Earlier checks: the finder's test with the upstream suites (12 of 12), the independent reproduction's (17 of 17 with the upstream suites), the adjudication's re-run, the finder's translation validation (0 mismatches in 1,641,332 checks) and the security probe (0 deviations in 365,560 token streams).

B: workaround, lower-case before the filter (no code change)

Add LowerCaseFilter (factory lowercase) before DoubleMetaphoneFilter. No lower-case token was hit in the probe: 0 of 18,278 lower-case A-Z strings of length 1-3 lose their alternate (other characters were not probed exhaustively), and the test's controls show lower-case words get both codes.

Trade-offs

With inject=true the kept original token becomes lower case; it does not help existing indexes or the Solr _default schema, and the library stays inconsistent.

C: document the behaviour (not recommended)

A javadoc note that a token equal to its primary code is passed through without its alternate.

Trade-offs

Cheap, but it documents a dependence on letter case and code length that contradicts "supporting secondary codes".

D: delete only the repeated check (rejected)

Executed: inject=true is then right, but inject=false turns "JR" into [AR/1], "JFK" into [AFK/1], "SMS" into [XMS/1] and "J" into [A/1].

Trade-offs

The alternate overwrites the token and the primary code is lost: a worse bug (also found by the independent reproduction and the intended-behaviour check).

Downstream note

Solr's _default configset (phonetic_en: standard tokenizer + doubleMetaphone, inject=false, no lower-casing) and the Elasticsearch analysis-phonetic plugin (encoder double_metaphone) build this filter, so upper-case acronyms in such fields lose their alternate code too.

How it was found

Semi-formal. A bounded-exhaustive comparison ran the phonetic filters against the commons-codec encoders called directly, on every token of up to 3 characters over a 17-character alphabet plus fixed words (a smaller set for BeiderMorseFilter), alone and in random streams (1,641,300 checks). The only filter-side mismatches were DoubleMetaphoneFilter on "J"; reading the code then located the condition.

The method in full164 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier). The finder's harness compared each phonetic filter's output with the output computed from the encoder library called directly:

  • filters: PhoneticFilter with 14 encoder configurations and both inject modes, DoubleMetaphoneFilter at maxCodeLength 1, 2, 4 and 8 in both modes, DaitchMokotoffSoundexFilter, and BeiderMorseFilter in 32 configurations (on a smaller term set: length up to 2 over 14 characters, 3 fixed words and 200 sampled terms);
  • inputs: every token of length up to 3 over a 17-character alphabet plus fixed words, alone and in random streams of 1-4 tokens with position increments 0, 1 and 2, position lengths 1 and 2, and the keyword flag on or off;
  • result on stock main: 1,641,300 checks, 16 mismatches, all DoubleMetaphoneFilter on the token "J" (expected J and A, got J); with the fix, 0 mismatches in 1,641,332 checks.

Reading the filter then located the repeated condition, and a JUnit test confirmed it on the stock main jars (found 2026-10-26).

Prior reports, history and checks

Prior reports

No prior report found: scripted prior-report search runs over GitHub, the Lucene and Solr lists, JIRA and the web, plus manual GitHub, JIRA and downstream searches (2026-10-26), and a GitHub re-check on 2026-10-28. Nearest: SOLR-2393, where a different filter never emits alternates. Not fixed upstream.

Searches4 searches

No issue, PR, commit message, JIRA entry, mailing-list thread or web page reports that DoubleMetaphoneFilter drops the alternate of a token equal to its primary code. The nearest items are different bugs or context: SOLR-2393 (open: PhoneticFilterFactory with the DoubleMetaphone encoder never emits alternates; its patch does not touch this check), elastic/elasticsearch#924 (2011, a different filter that emitted only primary codes), SOLR-1359 (the change that introduced the check; its discussion covers only skipped or reordered tokens), SOLR-813 (the original filter), SOLR-1360 (PhoneticFilter duplicates), SOLR-1005 (zero-length tokens), CODEC-84 and CODEC-65 (encoder-side) and the 2013 dev@ thread "Any support for DoubleMetaphone ever putting out secondary tokens?" (answer: this filter does). Gaps: two Stack Overflow questions could not be read and downstream GitHub search was rate-limited.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories2026-10-26the scripted prior-report search (16 issue/PR, 3 commit and 2 downstream queries; the intended-behaviour check's own run 29 queries, 60 hits) and manual searches: DoubleMetaphone, metaphone, DoubleMetaphoneFilter, phonetic with uppercase, secondary, alternate; open PRs touching the filterno report; open PRs touching the file: none
Mailing lists and JIRA2026-10-2630 list queries over dev@, java-user@, issues@, commits@, solr-user@ and solr-dev@ plus the Solr lists; JIRA across all projects (text ~ DoubleMetaphone: 24 issues, metaphone: 34, DoubleMetaphoneFilter: 5)no report; SOLR-813, SOLR-1359, SOLR-1360, SOLR-2393 and the 2009, 2011 and 2013 threads read, none about this
Downstream projects and the web2026-10-26Elasticsearch, OpenSearch, Solr and Lucene.NET issue searches; 9 web querieselastic/elasticsearch#924 (a different filter); no report
GitHub re-check2026-10-2813 read-only searches (12 for issues and PRs: DoubleMetaphone, metaphone, DoubleMetaphoneFilter, metaphone uppercase, phonetic alternate, phonetic secondary; one for open PRs mentioning phonetic) and the commits on the filenothing new; the file's last upstream commit is #15704 (2026-02-26, a list type swap)

History

Introduced on 2009-08-13 by 551e52f899a (SOLR-1359, a rewrite to fix skipped or reordered tokens), before any release shipped the class, so every release is affected: Solr 1.4.0, then Lucene 3.6.0 to 10.5.2 and main. The previous file version passes the probe.

Versions tested9 rows

Introduced by 551e52f899a (Solr trunk, svn r803971, 2009-08-13, "SOLR-1359: DoubleMetaphone filter could skip or reorder tokens, fix and update to new API"; a JIRA patch, no PR). git blame -w -M -C on main assigns DoubleMetaphoneFilter.java:89-90 to it, and a path-limited git log -S finds no other commit adding the term. The filter itself came with SOLR-813 (180ff531e1a, 2008-10-18) after Solr 1.3.0, so the code without the check never shipped. Lucene 3.6.0 ships a copy made on the 3.x branch (16d0944bff2, SOLR-2982); on trunk, the line that became 4.0, the class moved with 8f71031ac88 (LUCENE-2413). The last change, #15704 (2026), only swaps LinkedList for ArrayDeque. All 132 release tags in the history clone that ship the class contain the term. The nine releases 8.8.2 to 8.11.4 are not tags in that clone; all nine were executed instead and fail, so every release of the class is covered by reading or by running.

Versions and branches executed, with results
Version or commitVerdictResult
572d84c9c27 (previous file version) beforethe probe passes: J -> J, A; JR -> JR, AR; JFK -> JFK, AFK; SMS -> SMS, XMS
551e52f899a (SOLR-1359) introducing commitJ -> J; JR -> JR; JFK -> JFK; SMS -> SMS, both inject modes
Solr 1.4.0 affectedfirst affected release (the class was Solr-only until 3.5)
Lucene 3.6.0 affectedfirst Lucene release with the class
4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 affectedthe portable probe fails on each
8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0 to 8.11.3 affectednot tags in the history clone; the portable probe fails on each (run 2026-10-28)
9.12.3, 10.5.2 affectedthis doc's test: 3 run, 2 failed (-da), the same messages as main
main a9779ef3c193 affectedthis doc's test: 2 of 3 fail through gradle and the JUnit runner, -ea and -da
main with fix A fixthe test and all 44 phonetic tests pass

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate, the adjudication and a credibility audit ran code and said real. A reviewer from another model family (OpenAI GPT-5.6) could only read code: real in a lens, not real for a procedural reason in the other, root cause agreed in both. This doc's test and fix ran through gradle.

Checks and verdicts385 words

The hunt's checks ran on main ea57360912ed; this doc's runs on main a9779ef3c193, where the phonetic module is identical.

Each independent check and its verdict
CheckVerdictWhat it found
finder: exhaustive comparison and JUnit 16 of 1,641,300 checks wrong, all "J"The JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; with the fix it passes with the upstream suites (12 of 12).
cross-model reviewer, intended lens (OpenAI GPT-5.6) real, medium (read only)Built the "already a code, leave it alone" defence and rated it moderate; could not run Java. A re-run of the finder's repro failed.
cross-model reviewer, refute lens (OpenAI GPT-5.6) not real, low (procedural)It had no executed repro of its own; it agreed on the root cause and that the inputs are in contract.
Independent reproducer real, mediumOwn 6-test JUnit (word JR, index of "jr" and "JR") fails 3 of 6 on main (-ea, -da), 10.5.2 and 9.12.3; with the fix 17 of 17 pass with the upstream suites; showed that removing only the term loses the primary.
Intended-behaviour devil's advocate real, medium-highProbes show the result flips with letter case and maxCodeLength and that non-fixed-point codes are re-encoded; found the SOLR-813 and Reference Guide contract quotes.
adjudication confirmed, mediumRe-ran the independent reproduction's JUnit with -ea and -da (the same 3 of 6 fail) and with the fix (6 of 6 pass); class minor.
registry re-run and credibility audit keepFresh seeds: the finder's JUnit fails with -ea, -da and on 10.5.2; the independent reproduction's fails 3 of 6 and passes 6 of 6 with the fix.
this doc confirmed; fix validatedNew format-clean test fails 2 of 3 through gradle (-ea, -da) and the runner (main, 10.5.2, 9.12.3); with fix A it and all 44 phonetic tests pass; removing only the term was re-run and loses the primary.
independent fact checks of this doc bug confirmed; wording correctedAn independent re-run (gradle with its own seeds, both release jars, its own probe of 365,560 token streams) found no error that changes a claim. A cross-model fact check (OpenAI GPT-5.6, read only) agreed the bug is real and flagged overstatements, for example that the fix could only add matches; they are corrected here. The diagram check passed.

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

Finding record

LFV-477, snapshot of 2026-11-06

Registry title
DoubleMetaphoneFilter drops the alternate (secondary) code of a token whose text already equals its primary code, so 'JR' gets only JR while 'jr' gets JR and AR
Area
analysis / phonetic (DoubleMetaphoneFilter#incrementToken alternate-code branch; DoubleMetaphoneFilterFactory, SPI name doubleMetaphone)
Priority
Low, rank 526: The opt-in DoubleMetaphoneFilter drops the alternate code of upper-case acronyms when input is not lower-cased first, losing only matches that depend on that weak alternate code.
Impact class
minor
Impact note
Only analysis chains that run DoubleMetaphoneFilter (an opt-in analysis-phonetic filter, through its constructor or the 'doubleMetaphone' factory, at default or any other settings) on text that is not lower-cased first are hit, and only for tokens whose exact text equals their own primary code, in practice upper-case, consonant-only initials and acronyms no longer than maxCodeLength such as J, JR, JFK or SMS: the token keeps its primary code but loses its alternate, so it no longer matches text that shares only the alternate code; no scale is needed, and there is no exception and no index damage.
Summary
You run text through DoubleMetaphoneFilter, documented as 'supporting secondary codes', and expect the token 'JR' to get the same codes as 'jr', because the encoder is case-insensitive and gives both the primary code JR and the alternate code AR. Instead, when a token's text already equals its own primary code, the filter keeps the token as the primary but never emits the alternate: 'jr' gives JR and AR, 'JR' gives only JR, in both inject modes and at every maxCodeLength where the token equals its code. A document containing 'JR' is then missed by a query that matches only through AR (executed: TermQuery AR matches the document 'jr' but not the document 'JR'), while matches through the primary code still work.
Introduced
551e52f899aa6bf751ffb6dfe49be30e1ad638e5 (Solr trunk, svn r803971), SOLR-1359 'DoubleMetaphone filter could skip or reorder tokens, fix and update to new API' (JIRA era, no PR; committed by yonik): the rewrite added '!primaryPhoneticValue.equals(v)' to both the primary and the alternate branch [read: git blame -w -M -C on main assigns the alternate-branch term (DoubleMetaphoneFilter.java:90) to it, and a path-limited git log -S over every path the file has had finds no other commit adding it] [exec: the same probe passes on the file's previous version 572d84c9c27 (SOLR-1005, 2009-02-06; J -> J, A) and fails on 551e52f899a (J -> J), with Lucene 2.9.1 and commons-codec 1.3] (2009-08-13)
First affected version
Solr 1.4.0 [exec] (org.apache.solr.analysis.DoubleMetaphoneFilter, the first release after the commit); the first Lucene release that ships org.apache.lucene.analysis.phonetic.DoubleMetaphoneFilter is 3.6.0 [exec]. No release was ever unaffected: the filter was added by SOLR-813 (180ff531e1a, 2008-10-18) after Solr 1.3.0, and the guard-free code existed only on unreleased Solr trunk
Affected versions
Every release that ships the class: Solr 1.4.0, Lucene/Solr 3.1-3.5 (Solr package only), Lucene 3.6.0 through 10.5.2, and main ea57360912ed (11.0.0-SNAPSHOT). [exec] with a portable public-API probe (J, JR, JFK, SMS against lower case, maxCodeLength 4, both inject modes), identical output on each: Solr 1.4.0, Lucene 3.6.0, 4.0.0, 4.10.4, 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); the finder's JUnit fails on main (-ea, -da), 10.5.2, 10.0.0 and 9.12.3; the independent reproduction's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3. [read] all 132 release tags in the history clone that ship the class contain the guard; releases between the executed points are affected by [read] only. The encoder output for the probe words is identical across commons-codec 1.3 to 1.23.0, so the difference is in the Lucene filter alone. Not fixed upstream: the last change to the file (#15704, 2026) only swaps LinkedList for ArrayDeque. Executed only on aarch64 Linux with JDK 25 (plain Java string logic)
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; also reached by code reading
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, plus the finder's and the intended-behaviour check's searches): scripted prior-report searches over apache/lucene GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@, commits@, solr-user@ and solr-dev@ lists, the Solr users@/dev@/issues@ lists, LUCENE and SOLR JIRA and the web; manual GitHub searches (DoubleMetaphone, metaphone, phonetic uppercase/secondary/alternate, open PRs touching DoubleMetaphoneFilter: none); JIRA across all projects (text ~ DoubleMetaphone: 24, metaphone: 34, DoubleMetaphoneFilter: 5 issues, none about this); downstream Elasticsearch, OpenSearch, Solr and Lucene.NET. Related, not this bug: SOLR-2393 (open: PhoneticFilterFactory with the DoubleMetaphone encoder never emits alternates; its patch does not touch this guard), elastic/elasticsearch#924 (2011, a different filter emitting only primary codes), SOLR-1359 (the introducing change; its discussion covers only skipped or reordered tokens), SOLR-813 (the original filter, contract evidence), SOLR-1360 (PhoneticFilter duplicate tokens), SOLR-1005 (a different zero-length-token bug), CODEC-84 and CODEC-65 (encoder-side), the dev@lucene 2013 thread on secondary tokens. Coverage limits: two Stack Overflow questions (19093780, 25243204) were not readable, and GitHub's search rate limit limited downstream queries.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, the whole phonetic module, TestRandomChains and the module's lint and format checks; and the standalone JUnit runner); the release jars 10.5.2 and 9.12.3 (this doc's test). The hunt's checks ran on main ea57360912ed, where the phonetic module is identical, the history probe on Solr 1.4.0 and 11 Lucene releases from 3.6.0 to 10.5.2, and the same probe on the eight releases 8.8.2 to 8.11.3, which are not tags in the history clone. JDK 25.0.3 on Linux aarch64 only.

Lucene and upstream sources

Finding record
  • lucene/analysis/phonetic/src/java/org/apache/lucene/analysis/phonetic/DoubleMetaphoneFilter.java:28 (at ea57360912ed) (class javadoc 'Filter for DoubleMetaphone (supporting secondary codes)'), :37-40 (constructor javadoc 'either adding encoded forms as synonyms (inject=true) or replacing them'), :68-69 (primary and alternate from the encoder), :75-85 (primary branch, skipped when the primary equals the token), :87-90 (alternate branch, also requires !primaryPhoneticValue.equals(v)), :102-104 (token returned unchanged when nothing was queued)
  • lucene/analysis/phonetic/src/java/org/apache/lucene/analysis/phonetic/DoubleMetaphoneFilterFactory.java:26-32 (at ea57360912ed) (documented example chain: WhitespaceTokenizerFactory + DoubleMetaphoneFilterFactory, no lower-casing), :40 (SPI name doubleMetaphone), :49 and :57-58 (defaults inject=true, maxCodeLength 4), :70-71 (create)
  • lucene/analysis/phonetic/src/test/org/apache/lucene/analysis/phonetic/TestDoubleMetaphoneFilter.java:42 (at ea57360912ed) (testAlternateInjectFalse asserts both codes for 'Kuczewski'; no test of a token equal to its primary code)
The finding in 30 seconds
  • lucene/analysis/phonetic/src/java/org/apache/lucene/analysis/phonetic/DoubleMetaphoneFilter.java:28 (class javadoc), :37-40 (constructor javadoc), :50-110 (incrementToken; per-token part :62-108: :68-69 codes, :73 saveState, :75-85 primary branch, :87-98 alternate branch, :102-104 unchanged token)
  • lucene/analysis/phonetic/src/java/org/apache/lucene/analysis/phonetic/DoubleMetaphoneFilterFactory.java:26-32 (example chain), :40 (SPI name), :49, :57-58 (defaults inject=true, maxCodeLength 4)
  • lucene/analysis/phonetic/src/test/org/apache/lucene/analysis/phonetic/TestDoubleMetaphoneFilter.java:42-46 (testAlternateInjectFalse: Kuczewski -> KSSK, KXFS)
  • commons-codec 1.23.0 DoubleMetaphone.cleanInput (javap -c: trim, then toUpperCase(Locale.ENGLISH)): the encoder upper-cases its input
Who is affected
  • lucene/analysis/phonetic/src/java/org/apache/lucene/analysis/phonetic/DoubleMetaphoneFilterFactory.java:26-32, :40, :49, :57-58, :70-71
  • lucene (grep: outside tests DoubleMetaphoneFilter is used only by its factory; module-info and META-INF/services list the factory)
Why it looks unintended
  • lucene/analysis/phonetic/src/java/org/apache/lucene/analysis/phonetic/DoubleMetaphoneFilter.java:28, :75-90; lucene/analysis/phonetic/src/java/org/apache/lucene/analysis/phonetic/PhoneticFilter.java:74
  • lucene/analysis/phonetic/src/test/org/apache/lucene/analysis/phonetic/TestDoubleMetaphoneFilter.java:42-46
Diagrams
  • lucene/analysis/phonetic/src/java/org/apache/lucene/analysis/phonetic/DoubleMetaphoneFilter.java:75-90

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

Repro files

About the diagrams

A step-by-step picture made in parallel with this doc from the executed repro on main a9779ef3c193; every value drawn should come from the repro output and the source at that commit. The PNG is the final summary frame.

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.