All findings · Lucene finding · LFV-641 · hunspell-twofold-suffix-requires-cross-product

Hunspell rejects and cannot stem twofold-suffix words when a suffix class has cross product N

When a Hunspell dictionary chains two suffixes and either class says cross product N, Lucene rejects the word and finds no stem, although that flag only governs prefix + suffix. In Luxembourgish that is at least 47,840 correct forms on main, like räichsten; the bug affects every release since 3.5.0.

Summary

Priority
Medium, rank 254Since 3.5, Hunspell spell and stemming silently fail on ordinary twofold-suffix words in a real public dictionary (Luxembourgish), which changes index-time stemming and recall, but only a few dictionaries have this shape, so it ends the narrow silent-result Medium rows after LFV-479.
Impact
silent wrong results
Since
3.5.0 (LUCENE-3414, 2011); widened in 4.8.0introduced 2011-09-10 · over 15 years
Affected
3.5.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
differential testing against native Hunspell

AI-assisted page: this finding was first signalled by code reading or testing 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

Hunspell affix files allow "twofold suffixes": a suffix whose continuation class allows a second suffix. Lucene follows such a chain only if both suffix classes say cross product Y, a field the format reserves for prefix + suffix combinations.

Summary diagram. Left: the dictionary SFX S Y 1, SFX S 0 st/E, SFX E N 2, SFX E 0 en, SFX E 0 e, raich/SE, and the word raich + st + en = raichsten with an arrow /E from st to en, S: Y, E: N. Lucene takes en off, then stops (Stemmer.java:621): class E is N, so st is never tried and raich is never reached. Right: expected (C Hunspell 1.7.2; Lucene when S and E are Y): spell("raichsten") = true, getRoots = [raich]; actual (Lucene main, E = N): spell = false, getRoots = []; a correct word is rejected and cannot be stemmed. Below: HunspellStemFilter leaves raichsten unstemmed, so a document with it gets 0 hits for TermQuery(body:raich) (1 with E = Y); getAllWordForms("raich") returns 4 forms (6 with E = Y); real Luxembourgish räichsten, räichste, räichstem, räichster are rejected while C Hunspell accepts them; only S = Y with E = Y works, prefix + suffix pairs agree with C Hunspell, and with the fix sketch all four accept. Why: Stemmer.java:621, :564-573 and WordFormGenerator.java:251. Affected: 3.5.0 to 10.5.2 and main.
The finder's raich/SE dictionary: native Hunspell (and Lucene with both classes Y) accepts raichsten; Lucene with E = N strips en, stops at Stemmer.java:621, and rejects it. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You load a dictionary where the suffix st (class S) allows the endings en and e (class E) through its continuation class /E, and E's header says cross product N: SFX S Y 1, SFX S 0 st/E ., SFX E N 2 (en, e), entry raich/SE. Real Luxembourgish has this shape: räich + st + en = räichsten, "richest".
  2. You expect raichsten and raichste to be correct with root raich: hunspell(5) defines cross product as the "permission to combine prefixes and suffixes", there is no prefix here, and native Hunspell (1.3.2 and 1.7.2) accepts both with N or Y.
  3. Instead spell("raichsten") is false and getRoots returns []. Lucene strips en, then never looks for st, because class E is N (Stemmer.java:621); with S = N a second check rejects st (:564). Only S = Y with E = Y works.
  4. No error is raised: HunspellStemFilter leaves raichsten unstemmed, so a document containing it gets 0 hits for the term raich (1 with E = Y), and getAllWordForms("raich") returns 4 forms instead of 6.
  5. On the real Luxembourgish dictionary, at least 47,840 word forms of 13,526 distinct roots that native Hunspell 1.3.2 accepts are rejected by Lucene main, such as räichsten, verstümmeltem and Hälsercher. A small fix at three sites makes Lucene accept them.

Who is affected

Applications using Lucene's opt-in Hunspell module (spell-checking, HunspellStemFilter, getAllWordForms) with a dictionary whose twofold-suffix chains contain an N class. Real Luxembourgish is hit on ordinary words: correct words are flagged and stay unstemmed, so searches for the root miss them. Silent and deterministic; Elasticsearch's and OpenSearch's hunspell token filters wrap the same filter.

Mechanism, terms and numbers689 words

In a Hunspell .aff file a suffix can carry a continuation class that allows a second suffix after it ("twofold suffix stripping"). Each affix class header also has a cross-product field, Y or N, which hunspell(5) defines as the "permission to combine prefixes and suffixes". Lucene also requires it for suffix + suffix: if either suffix class of a chain says N, Hunspell.spell rejects the word, getRoots, analyzeSimpleWord and Stemmer return no stem, HunspellStemFilter leaves the token unstemmed, and getAllWordForms leaves the form out. Native Hunspell (1.3.2 and 1.7.2, in every Y/N variant tested) accepts these words. Three code sites carry the assumption (Stemmer.java:621, Stemmer.java:564, WordFormGenerator.java:251 on main a9779ef3c193). On the real Luxembourgish dictionary, from the collection Lucene's package docs point to, at least 47,840 word forms of 13,526 distinct roots that native Hunspell 1.3.2 accepts are rejected by Lucene main. All 18 releases tested from 3.5.0 to 10.5.2 fail, and the same gates are in every release since 3.5.0; nothing throws.

Terms

  • Affix class: a group of PFX or SFX rules under one flag. Its header line is SFX <flag> <cross product> <count>, for example SFX E N 2.
  • Cross product (Y or N): hunspell(5) field (2), "Cross product (permission to combine prefixes and suffixes)" (Hunspell 1.7.2 man/hunspell.5:522).
  • Continuation class: flags after a slash on a rule, SFX S 0 st/E .: a word that got st may take a suffix of class E next. hunspell(5) calls this "Twofold suffix stripping" (:921); its example is drink/X with SFX X 0 able/Y . and SFX Y 0 s ., giving drink, drinkable, drinkables.
  • Outer / inner suffix (Lucene's naming): the outer suffix is the last one applied and the first one stripped (en in raich + st + en), the inner suffix the first one applied (st).

The bug

  1. Dictionary.java:685 parses the field per class (crossProduct = args[2].equals("Y")), :791 stores it with every rule and isCrossProduct(affix) reads it back (:812-814).
  2. Outer suffix N: no search for the inner suffix. Stemmer.applyAffix strips an affix, checks the stem, then decides whether to strip one more. The decision is gated on the affix just stripped: if (dictionary.isCrossProduct(affix) && recursionDepth <= 1) (Stemmer.java:621); for a suffix it then looks for "another suffix" (:630-634). With E = N, after en is stripped from raichsten, the search for st never starts.
  3. Inner suffix N: the continuation check is never reached. Stemmer.isAffixCompatible accepts an inner suffix only inside if (dictionary.isCrossProduct(affix)) { ... return dictionary.hasFlag(append, prevFlag); } (:564-571) and otherwise returns false (:573). With S = N: after en is stripped, the intermediate raichst is looked up (:262) and is not an entry; then this check refuses to strip st as the inner suffix, so the root raich is never reached, although st/E carries the continuation flag.
  4. The generator copies the rule. WordFormGenerator.expand recurses for a further affix only if (dictionary.isCrossProduct(affix.id)) (WordFormGenerator.java:251-253), so getAllWordForms omits the same forms.
  5. Native Hunspell checks cross product only when a prefix is combined: SfxEntry::check_twosfx skips an entry only if ((optflags & aeXPRODUCT) != 0 && (opts & aeXPRODUCT) == 0) (Hunspell 1.7.2 affentry.cxx:712, "if this suffix is being cross checked with a prefix"), and without a prefix it checks the inner suffix with optflags 0 (:753-755). nuspell, an independent C++ implementation, does the same.

The numbers

With the hunspell(5) example, only X = Y, Y = Y accepts drinkables; the other three Y/N combinations give spell = false, roots = [] and getAllWordForms("drink") = [drink, drinkable], while drinkable stays accepted and drinks rejected in all four. Native Hunspell 1.3.2 accepts drinkables and stems it to drink in all four. For prefix + suffix words Lucene already agrees with native Hunspell 1.3.2 on every Y/N combination of a 4-dictionary matrix with a prefix class (S and E each Y or N, the prefix class tied to S); only the suffix chains differ.

Exposure and likelihood299 words
  • Who. Users of Hunspell.spell, suggest, getRoots, analyzeSimpleWord, HunspellStemFilter / HunspellStemFilterFactory and getAllWordForms whose dictionary gives cross product N to a class in a twofold-suffix chain. No setting beyond the dictionary; deterministic; assertions on or off.
  • What goes wrong. No exception. Spell-checking flags correct words, and suggest may offer a different word (suggest("raichsten") = [raichen] in the hunt's probe). Stemming returns nothing, so the stem filter indexes and queries the surface form: in an 8-document probe index a query for raich found 4 of 6 forms, and a query for raichsten only itself. Every hit set was a subset of the correct one. getAllWordForms omits the forms.
  • Scale on a real dictionary (Luxembourgish lb from wooorm/dictionaries at 8cfea406b505, the commit Lucene's regression build pins). generateAllSimpleWords over every entry gives 293,583 distinct forms on stock and 341,573 with the fix, none lost. Of the letters-only added forms, 47,840 of 13,526 distinct roots are accepted by native Hunspell 1.3.2 (the observed hunspell -l result), rejected by stock Lucene and accepted with the fix: 36,137 lowercase (for example superlatives and participles with adjective endings: räichsten, verstümmeltem, standardiséierten) and 11,703 capitalised (for example diminutive and feminine plurals: Hälsercher, Steenemerinne). Native -m output on the named forms shows two suffixes, for example verstümmeltem st:verstümmelen po:verb is:adjective is:papa is:em; morphology was not run on all 47,840.
  • Other dictionaries. A static survey of 191 public .aff files found such chains in 8, among them lb, hu_HU, ta_IN, is and fa; only lb was executed. Lucene documents Hungarian as unsupported.
  • Downstream. The hunspell token filters of Elasticsearch and OpenSearch return Lucene's HunspellStemFilter (source read, not executed).
  • Since. Every release with Lucene's Hunspell code: stemming since 3.5.0, Hunspell.spell since 8.9.0, getAllWordForms since 9.5.0.

Minimal repro

The test shows that with the hunspell(5) twofold-suffix example (drink/X, SFX X 0 able/Y, SFX Y 0 s) and cross product N on class Y, Hunspell.spell("drinkables") is false, although cross product only governs prefix+suffix combinations.

TestTwofoldSuffixCrossProduct.java (JUnit test)23 lines
package org.apache.lucene.analysis.hunspell;

import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTwofoldSuffixCrossProduct extends LuceneTestCase {

  public void testTwofoldSuffixWhenOuterClassHasCrossProductN() throws Exception {
    String aff = "SFX X Y 1\nSFX X 0 able/Y .\nSFX Y N 1\nSFX Y 0 s .\n";
    Dictionary dictionary =
        new Dictionary(
            new ByteBuffersDirectory(),
            "dict",
            new ByteArrayInputStream(aff.getBytes(StandardCharsets.UTF_8)),
            new ByteArrayInputStream("1\ndrink/X\n".getBytes(StandardCharsets.UTF_8)));
    // cross product only governs prefix+suffix: expected drink+able+s valid, actual rejected
    assertTrue(
        "expected spell(\"drinkables\") to be true, was false",
        new Hunspell(dictionary).spell("drinkables"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-641/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.analysis.hunspell.TestTwofoldSuffixCrossProduct
Expected vs actual
Expected spell("drinkables") true (as with SFX Y Y); actual false, with -ea and -da.

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

More files, environment, full output36 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 spell("drinkables") to be true, was false
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.analysis.hunspell.TestTwofoldSuffixCrossProduct.testTwofoldSuffixWhenOuterClassHasCrossProductN(TestTwofoldSuffixCrossProduct.java:19)

Why it looks unintended

  • hunspell(5) defines the field as "permission to combine prefixes and suffixes"; native Hunspell and nuspell check it only when a prefix is involved.
  • Lucene claims native behaviour: Hunspell "can be used in place of native Hunspell"; LUCENE-5483 says recursion is "driven correctly by continuation classes".
  • No test or comment found says N blocks suffix chains; all twofold fixtures use Y.
The full argument288 words
  • The format's contract. hunspell(5) (Hunspell 1.7.2 man/hunspell.5:522): "(2) Cross product (permission to combine prefixes and suffixes). Possible values: Y (yes) or N (no)". Its "Twofold suffix stripping" section (:921-962) ties the second suffix to the continuation class only. A native maintainer restated the rule on hunspell/hunspell#177 (2026): "A prefix and a suffix are only combined on the same word when both the prefix rule and the suffix rule are marked with the cross-product flag Y."
  • Lucene claims that behaviour. Hunspell javadoc: "can be used in place of native Hunspell for many languages" (Hunspell.java:34-37; the listed exceptions are Hungarian, non-BMP characters and PHONE). Stemmer javadoc: "conforms to the algorithm in the original hunspell algorithm, including recursive suffix stripping" (Stemmer.java:28-32). CHANGES for LUCENE-5483 (4.8.0): "Multi-stage affix-stripping, prefix-suffix dependencies, and COMPLEXPREFIXES now work correctly according to the hunspell algorithm ... rules for recursive affix application are driven correctly by continuation classes in the affix file" (lucene/CHANGES.txt:12545-12549); its issue #6546 "greatly refined this algorithm to match correct hunspell behavior".
  • No source we found documents the rule. The searches found no test, comment, CHANGES entry, issue or commit message saying N blocks suffix chains; no commit message in the full history contains "cross product" or "twofold".
  • Why tests never caught it. 12 of Lucene's 97 test .aff files chain two suffixes (twofold.aff, the manual's example in TestTwoFold, and dependencies.aff, compress.aff, needaffix5.aff and others), all with Y on both classes; no Lucene fixture and no Hunspell 1.7.2 test fixture puts an N class in a continuation chain.
  • Asymmetry. For prefix + suffix words Lucene already applies the cross-product rule exactly as native Hunspell does; only suffix + suffix uses it.
The devil's-advocate check229 words

The strongest case for "intended": the gate is deliberate code from the very first Lucene Hunspell port (2011), LUCENE-5483 (2014) added the inner check, LUCENE-10626 (2022) copied it into the generator, every manual example uses Y, the Hunspell javadoc says not all languages are supported, and in 2012 the original author wrote on LUCENE-4311 (#5379) that "our implementation of Hunspell is not a port of the C/C++ app per se". So it could be read as a conservative supported subset.

Why it fails: no source gives a reason for the gate; the later, explicit intent (LUCENE-5483's issue and CHANGES entry, the Hunspell and Stemmer javadocs, LUCENE-9687 "on a par with the native C++ Hunspell") is native compatibility; the unsupported-language list is about language-specific logic, while this is generic affix semantics shown by a six-line dictionary; and keeping drinkable but dropping drinkables only because an unrelated flag says N is not a coherent subset. The cross-model reviewer rated the argument moderate in a lens and weak in the other; the independent reproducer and the devil's-advocate check rated it weak, and the adjudication found that none of the intended-behaviour arguments holds. Verdict: a defect. WordFormGenerator is the same misreading in a second place, not a separate defect; its javadoc promises "all word forms for all dictionary entries with the given root word" (WordFormGenerator.java:117), which the omission also contradicts.

Suggested fix

Recommended Use cross product only when a prefix is combined (suffix chains need only the continuation class)

Check cross product only when a prefix is combined: keep the Stemmer.applyAffix gate for prefixes only, accept an inner suffix in isAffixCompatible by its continuation flag (plus cross product after a prefix), and let WordFormGenerator recurse after affixes with continuation flags. Validated: the repro passes; analysis-common: 1,911 tests, 0 failures, 18 skipped. Analysis output changes, so affected indexes need reindexing.

All options and trade-offs3 options · 771 words

One fix (A) was applied in a scratch worktree at a9779ef3c193 and run through gradle; it was also shadowed onto the stock jars for the hunt's tests and the real-dictionary probe. The finder's broader sketch (B) was run on the same tests for comparison.

Use cross product only when a prefix is combined (suffix chains need only the continuation class) (validated by the checks) (recommended)

Two files, +13/-9. After a suffix Lucene only ever looks for another suffix, never a prefix (Stemmer.java:630-647), so dropping the gate for suffixes changes only suffix chains. A suffix after a prefix still needs Y on both (unchanged), and a prefix + suffix + suffix chain keeps the check on the inner suffix. The generator keeps a form only if Lucene's (fixed) stemmer stems it back to the entry (canStemToOriginal, WordFormGenerator.java:248), a Lucene self-check, not a comparison with native Hunspell.

--- Stemmer.isAffixCompatible (Stemmer.java:564)
-    if (dictionary.isCrossProduct(affix)) {
-      if (previousWasPrefix) return true;
-      if (outerSuffix >= 0) {
-        char prevFlag = dictionary.affixData(outerSuffix, Dictionary.AFFIX_FLAG);
-        return dictionary.hasFlag(append, prevFlag);
-      }
+    if (previousWasPrefix) return dictionary.isCrossProduct(affix);
+    if (outerSuffix >= 0) {
+      char prevFlag = dictionary.affixData(outerSuffix, Dictionary.AFFIX_FLAG);
+      return dictionary.hasFlag(append, prevFlag)
+          && (outerPrefix < 0 || dictionary.isCrossProduct(affix));
     }
--- Stemmer.applyAffix (Stemmer.java:621)
-    if (dictionary.isCrossProduct(affix) && recursionDepth <= 1) {
+    if ((!prefix || dictionary.isCrossProduct(affix)) && recursionDepth <= 1) {
--- WordFormGenerator.expand (WordFormGenerator.java:251)
-            if (dictionary.isCrossProduct(affix.id)) {
+            if (dictionary.isCrossProduct(affix.id) || append.length > 0) {

Trade-offs

  • Bounded extra work: suffix recursion still stops at two suffixes, and the generator at two affixes of a kind (WordFormGenerator.java:376).
  • It leaves the mirror case unchanged: under COMPLEXPREFIXES, two prefixes whose classes say N (reunfoo) are accepted by native Hunspell and rejected by Lucene with or without this fix. That path is entangled with a separate prefix-recursion bug (option B) and is better fixed on its own.
  • Compatibility: the fix changes analysis output. HunspellStemFilter now stems the affected words, so an index built before the fix keeps their surface forms and needs reindexing to match the fixed query-time analysis. A Lucene-only dictionary that relied on N to block a suffix chain (nonportable: native Hunspell ignores it there) would accept more words, and getAllWordForms returns more forms (on Luxembourgish 341,573 instead of 293,583); no benchmark was run.

What the checks validated

  • Stock main a9779ef3c193, gradle: the repro fails 2 of 2 with assertions on and off.
  • With the fix, gradle: the repro passes with assertions off; the whole lucene/analysis/common test task with assertions on, repro included, runs 340 classes, 1,911 tests, 0 failures, 18 skipped (the hunspell package: 47 classes, 160 tests, 13 skipped, because TestAllDictionaries, TestHunspellRepositoryTestCases and TestPerformance need external dictionaries). ecjLint, checkGoogleJavaFormat and forbiddenApis pass with the test in place; both diffs apply cleanly to a9779ef3c193.
  • With the fix shadowed onto the stock jars: the finder's test, the independent reproducer's spell/stem and generator tests (4 of 4 each), the cross-model review's 4-dictionary prefix/suffix matrix (prefix + suffix results unchanged, now equal to native everywhere), the cross-model reviewers' tests (6 of 6, 2 of 2), and the hunt's security probe (no query loses hits; its COMPLEXPREFIXES N dictionary still does not overflow, as on stock, while its Y dictionary still overflows for bx and bbx, as on stock: that is the separate LFV-375).
  • Real dictionary: on Luxembourgish the generator adds 47,990 forms and loses none; spell accepts all but 36 of them (hyphenated or special forms).
  • Not run: the full Lucene suite, the external-dictionary suites, a benchmark.

Drop the cross-product gate for prefixes too (the finder's sketch; not alone)

Also lets a second prefix under COMPLEXPREFIXES combine without cross product, which fixes the mirror case (reunfoo accepted). It passes the repro and, by the finder's run, the 146 hunspell tests of the time.

Trade-offs

Alone it lets a separate known bug, the COMPLEXPREFIXES third-prefix recursion that ends in StackOverflowError (registry LFV-375), reach dictionaries whose chained prefix classes say N, which today are immune: in the hunt's security probe the COMPLEXPREFIXES dictionary with N overflows for bx with B, and not with A or on stock. In the finder's differential, words Lucene accepts and native rejects went from 49 to 96, all new ones under COMPLEXPREFIXES. Only safe together with or after the LFV-375 fix (doPrefix = false at Stemmer.java:640) and the sibling second-prefix continuation fix.

Document the restriction instead

State in the Hunspell javadoc that suffix chains need cross product Y.

Trade-offs

Contradicts hunspell(5) and the class's "in place of native Hunspell" claim, and leaves real dictionaries such as Luxembourgish broken.

Downstream note

Elasticsearch and OpenSearch build their hunspell token filter on Lucene's HunspellStemFilter (source read, not executed), so their users of dictionaries with this shape get the same missed stems; the effect is lower recall only.

How it was found

Differential testing of Lucene's Hunspell code against native Hunspell 1.7.2 on 4,000 generated micro-dictionaries and 1,069 fixture cases; with the finder's fix (option B) alone, the words native accepts and Lucene rejects fell from 202 to 24. A public-API JUnit then reproduced it on the stock jar.

The method in full190 words

Method: cross-implementation differential testing (the hunt's non-formal tier), in a hunt unit comparing Lucene's word-form generator, spell and analyzeSimpleWord with native Hunspell 1.7.2 (libhunspell spell and stem) and the hunspell-tools unmunch/munch.

  • Inputs: 4,000 seeded, grammar-built micro-dictionaries (per dictionary every candidate word reachable by at most 3 affix rules) and 1,069 per-root cases from the Hunspell 1.7.2 and Lucene test fixtures.
  • Root causes were separated with switchable shadow fixes: with the finder's fix for this bug (option B) alone, words native accepts and Lucene rejects fell from 202 to 24 (the rest are other causes or native deviations from hunspell(5)).
  • The finder then wrote a public-API JUnit (raich/SE) that fails on the stock jar, found the real Luxembourgish case and checked native Hunspell on it.
  • Found 2026-10-27 on main a9779ef3c193; then checked by an independent reproducer, an intended-behaviour devil's advocate, two executed reviews by a model from another family (OpenAI GPT-5.6), a re-run of the intended lens, an adjudication, a history and prior-report follow-up, a security screen and a credibility audit. This doc added the gradle runs, the fix validation and the Luxembourgish count.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): three runs of 45 queries over GitHub, the lists and JIRA, 30 manual searches, all 179 hunspell-titled issues and PRs, downstream projects, native Hunspell and nuspell, and the changed files of all 409 open PRs. Nothing upstream fixes it.

Searches4 searches

Verdict new: no report by anyone. Keyword search cannot prove absence, and private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; Lucene, Solr and OpenNLP lists; JIRA2026-10-27a scripted prior-report search, three query variants (classes and terms; symptoms; generator side), 45 queries eachno report; closest: #6546, #6547, #5379, #11662, #10727, #10838, #10842
apache/lucene (GitHub)2026-10-2730 manual searches; titles of all 179 items with "hunspell" in the title; the 98 sub-tasks of #10726; changed files of all 409 open PRsno report; only #16422 (directive-parsing refactor) and #16911 (branch decommission) touch hunspell files
Elasticsearch, OpenSearch, Solr, Lucene.NET, hunspell/hunspell, nuspell, Luxembourgish dictionary repositories, all of GitHub2026-10-2747 searchesno Lucene report; hunspell/hunspell#177 states the native rule
JIRA, lists.apache.org, the web2026-10-2715 JIRA queries, 72 list queries, 7 web queriesnothing about Lucene

History

Present since Lucene's first Hunspell code, LUCENE-3414 (e3172b9239c, 2011; first release 3.5.0); LUCENE-5483 added the inner check in 4.8.0 and LUCENE-10626 copied the rule into the generator in 9.5.0. Probes fail on 18 releases from 3.5.0 to 10.5.2 and on main. Not fixed upstream.

Versions tested9 rows

Introduced by e3172b9239c on trunk, LUCENE-3414 "Added Hunspell for Lucene" (2011-09-10, svn r1167467; JIRA migrated as #4487), backported to branch_3x as 1e084f1d08e the same day: HunspellStemmer.applyAffix recursed only if (affix.isCrossProduct() && recursionDepth < RECURSION_CAP), today's Stemmer.java:621. Widened by 56babb83687 (LUCENE-5483 "fix hunspell inaccuracies", #6546, 2014-03-02; branch_4x 040cf121458; 4.8.0), which added the inner-suffix check that is today's isAffixCompatible condition; before it, the inner-N case passed only because continuation classes were not checked at all. Copied by d537013e708 (LUCENE-10626, PR #975, 2022-07-05; branch_9x 3b763af12f5; 9.5.0) into WordFormGenerator.expand. Refactors since (LUCENE-4542, LUCENE-5468, LUCENE-9671, LUCENE-9685, LUCENE-9748) kept the gates; branch_10x, branch_10_6 and upstream main a95c754ee003 have the same lines.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe compact JUnit fails 2 of 2 through gradle, assertions on and off; the finder's, the reproducer's and the reviewers' tests fail with -ea and -da
10.0.0, 10.5.2 affectedspell, stem and getAllWordForms fail; the compact JUnit fails 2 of 2 on 10.5.2
9.5.0, 9.12.3 generator from 9.5.0spell, stem and getAllWordForms fail; the compact JUnit fails 2 of 2 on 9.5.0 and 9.12.3
8.9.0, 8.11.4, 9.0.0, 9.4.2 spell from 8.9.0spell and stem fail; no getAllWordForms yet
4.8.0, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3 affectedstemming fails for outer N and inner N
3.5.0, 3.6.2, 4.0.0, 4.7.2 first releasestemming fails for outer N; inner N passes only because continuation classes were not checked
4.8.0 + hunspell package at 040cf121458^ vs 040cf121458 (LUCENE-5483) commit A/Bonly the inner-N case flips
3.4.0 no Hunspell codethe probe does not compile
main + fix A not affectedthe repro passes; analysis-common: 1,911 tests, 0 failures (18 skipped); Luxembourgish forms accepted

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests and real Luxembourgish words; two executed reviews by a model from another family (OpenAI GPT-5.6) rated it real; an adjudication re-ran it; this doc's gradle runs reproduce it on a9779ef3c193 and validate the fix.

Checks and verdicts379 words

Every executing check wrote or re-ran its own JUnit on stock main; every control passed, and native Hunspell accepted the words in every check that ran it.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateDifferential against native Hunspell 1.7.2, then the raich/SE JUnit: fails on main (-ea, -da), 10.5.2 and 9.12.3; native accepts with N and Y; the fix sketch passes.
cross-model review, refute lens (OpenAI GPT-5.6) real, high; intended argument weakOwn spell and generation tests fail 2 of 3 each with -ea and -da; Y/Y controls pass; native Hunspell 1.3.2 accepts for YY, YN and NY.
cross-model review, intended lens (OpenAI GPT-5.6) real, high; intended argument moderateOwn test fails on main, 10.5.2 and 9.12.3; its prefix + suffix control passes. Rated the 'deliberate subset' reading moderate, then rejected it: nothing documents N as blocking suffix chains.
independent reproducer realThe hunspell(5) drinkables example: 3 of 4 combinations fail on main (-ea, -da), 10.5.2, 9.12.3, 9.4.2, 8.11.2; real Luxembourgish words rejected; its probe patch shows all three sites must change.
intended-lens re-run reproducedRe-ran the intended-lens test on a9779ef3c193 and built a 4-dictionary Y/N matrix with a prefix class: Lucene matches native Hunspell 1.3.2 on every prefix + suffix word and differs only on the twofold words.
intended-behaviour devil's advocate realLucene needs Y on both classes, native 1.7.2 on neither; real Luxembourgish in both engines; no Lucene or Hunspell fixture combines N with a continuation chain; rebutted the 'not a port' and 'examples use Y' arguments.
adjudication confirmed, highRe-ran the independent reproduction's test (4 run, 3 failed, -ea and -da) and the finder's test; native 1.3.2 accepts all four combinations.
credibility audit keepRe-ran the finder's test and its own drinkables matrix with an N prefix control on main (-ea, -da) and 10.5.2: only Y/Y works, the prefix control is rejected as it should be; native Hunspell 1.3.2 accepts all four.
this doc reproduced; fix validatedCompact JUnit fails 2 of 2 through gradle on stock (asserts on and off) and passes with fix A; the analysis-common test task runs 1,911 tests, 0 failures (18 skipped); Luxembourgish count 47,840 forms; the fix does not make the COMPLEXPREFIXES N dictionary overflow.

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

Finding record

LFV-641, snapshot of 2026-11-06

Registry title
Hunspell rejects, cannot stem and does not generate twofold-suffix words (a second suffix allowed by the first suffix's continuation class) when either suffix class has cross product N, although cross product only governs prefix+suffix combinations: with 'SFX S 0 st/E', 'SFX E N 2' (en, e) and raich/SE, spell("raichsten") is false; the real Luxembourgish dictionary rejects räichsten
Area
analysis / hunspell (Stemmer affix stripping: Hunspell.spell, getRoots, analyzeSimpleWord, Stemmer.stem, HunspellStemFilter; WordFormGenerator.getAllWordForms)
Priority
Medium, rank 254: Since 3.5, Hunspell spell and stemming silently fail on ordinary twofold-suffix words in a real public dictionary (Luxembourgish), which changes index-time stemming and recall, but only a few dictionaries have this shape, so it ends the narrow silent-result Medium rows after LFV-479.
Impact class
silent wrong results
Impact note
Only applications that use the opt-in Hunspell analysis (Hunspell.spell, getRoots and analyzeSimpleWord, Stemmer.stem and HunspellStemFilter, and the getAllWordForms tooling) with a dictionary in which a suffix class of a twofold-suffix chain has cross product N are hit: valid twofold-suffix words are reported as misspelled, get no stems (HunspellStemFilter passes them through unstemmed, so a search for the root misses them) and are missing from getAllWordForms, with no error [exec]; no scale is needed; the real Luxembourgish dictionary (lb, from the dictionary collection at the commit Lucene's regression build pins) triggers it on ordinary superlatives such as räichsten, räichste, räichstem and räichster [exec, the independent reproduction and the intended-behaviour check]; the finder's static survey of 191 public .aff files found such chains in 8 files (among them lb, hu_HU, ta_IN, is and fa; only lb was executed, and Lucene documents Hungarian as unsupported); every release with Lucene's Hunspell code, 3.5.0 to 10.6.0 (10.6.0 by reading), has it [exec].
Summary
You load a Hunspell dictionary with a twofold suffix, where the first suffix's continuation class allows a second suffix (for example 'SFX S Y 1 / SFX S 0 st/E .', 'SFX E N 2 / SFX E 0 en . / SFX E 0 e .' and the entry raich/SE), and spell-check or stem raichsten. You expect it to be accepted and stemmed to raich: hunspell(5) defines the cross-product field only as the 'permission to combine prefixes and suffixes', no prefix is involved, and C Hunspell accepts the word with N as with Y. Instead Lucene rejects raichsten and raichste, returns no stems and leaves them out of getAllWordForms("raich"), because Stemmer.applyAffix (Stemmer.java:621), Stemmer.isAffixCompatible (:564) and WordFormGenerator.expand (:251) follow a suffix+suffix chain only when the suffix classes are cross product Y; the real Luxembourgish dictionary shows the same for räichsten, räichste, räichstem and räichster.
Introduced
e3172b9239cda3c53769a9e1dd82ea122178b93b (svn r1167467, LUCENE-3414 'Added Hunspell for Lucene', migrated as apache/lucene#4487; svn era, no PR), 2011-09-10: the first Lucene Hunspell code, HunspellStemmer.applyAffix, recursed to a second affix only 'if (affix.isCrossProduct() && recursionDepth < RECURSION_CAP)'; it shipped through the branch_3x backport 1e084f1d08e (r1167505, same day) [read: git show; exec: 3.5.0 rejects the outer-N case]. That gate is today's Stemmer.java:621. Contributing commits: 56babb83687 (branch_4x 040cf121458; LUCENE-5483 'hunspell inaccuracies', #6546; 2014-03-02; first released in 4.8.0) added the cross-product requirement on the inner suffix that is today's isAffixCompatible condition (Stemmer.java:564-570), so N on either class breaks the chain [exec: commit-level A/B, the hunspell package at 040cf121458^ and at 040cf121458 shadowed over the 4.8.0 jars, flips only the inner-N case]; d537013e708 (LUCENE-10626, PR #975, issue #11662; 2022-07-05; branch_9x 3b763af12f5; first released in 9.5.0) copied the gate into WordFormGenerator.expand (WordFormGenerator.java:251) [read]. Later refactors kept the gate unchanged (LUCENE-4542, LUCENE-5468, LUCENE-9671, LUCENE-9685, LUCENE-9748). Not a regression. Whether the external project that LUCENE-3414 ported already had the gate was not checked. (2011-09-10 (LUCENE-3414, outer recursion gate); 2014-03-02 (LUCENE-5483, inner-suffix check); 2022-07-05 (LUCENE-10626, generator copy))
First affected version
3.5.0 [exec] (the first release with Lucene's Hunspell code; 3.4.0 has none [exec: the probe does not compile]). The outer-N case fails in every release; the inner-N case fails from 4.8.0 (LUCENE-5483) [exec]. Entry points by release: stemming 3.5.0+, Hunspell.spell 8.9.0+, getAllWordForms 9.5.0+ [exec]
Affected versions
every release with Lucene's Hunspell code, 3.5.0 through 10.6.0 (10.6.0 by reading), and main a9779ef3c193; never fixed. [exec] the follow-up's version-portable probes fail on 18 releases (3.5.0, 3.6.2, 4.0.0, 4.7.2, 4.8.0, 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.9.0, 8.11.4, 9.0.0, 9.4.2, 9.5.0, 9.12.3, 10.0.0, 10.5.2) and on main with -ea and -da; the finder's JUnit fails on main (-ea, -da), 10.5.2, 10.0.0, 9.12.3 and 9.5.0 (it does not compile before 9.5.0, its spell-only variant fails on 9.4.2); the independent reproduction's tests fail on main (-ea, -da), 10.5.2, 9.12.3, 9.4.2 and 8.11.2 (stem-only variant); the intended-behaviour check re-ran the finder's test on 10.5.2 (fail). In 3.5.0-4.7.2 the inner-N case is accepted only because those versions did not check continuation classes at all (they also accept drinkables when 'able' has no continuation class, which C Hunspell rejects). [read] Stemmer.java and WordFormGenerator.java on upstream main a95c754ee003 are byte-identical to a9779ef3c193; the gates are also on branch_10x and branch_10_6. All runs on aarch64 with JDK 25.0.3.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record verdict new): a scripted prior-report search in three runs (45 queries each over GitHub issues, PRs and commits, downstream repositories, the Lucene, Solr and OpenNLP lists and JIRA), 30 manual gh searches of apache/lucene, a title scan of all 179 apache/lucene issues and PRs with hunspell in the title, the 97 sub-tasks of #10726 (LUCENE-9687), searches of Elasticsearch, OpenSearch, Solr, Lucene.NET, hunspell/hunspell, nuspell, wooorm/dictionaries and all of GitHub, commit search and git log over the full history, the changed files of all 409 open apache/lucene PRs, 15 JIRA queries, 72 lists.apache.org queries and 7 web queries; the finder's, the independent reproduction's and the intended-behaviour check's searches agree. Closest items, none a report of this bug: #6546 (LUCENE-5483, origin of the inner check, which set out to 'match correct hunspell behavior'), #6547 (LUCENE-5484: recursion is 'driven correctly by continuation classes'), #5379 (LUCENE-4311, Czech over-stemming with cross product Y, fixed by a recursion limit), #11662 and #975 (introduced the generator gate), #8431 (LUCENE-7378) and #7237 (LUCENE-6175), open Hungarian complaints that name another mechanism or none (Hungarian is documented as unsupported), and hunspell/hunspell#177 (a native maintainer's statement that cross product governs prefix+suffix combinations). Not fixed upstream: no open PR changes Stemmer.java or WordFormGenerator.java (only #16422, a Dictionary directive-parsing refactor, and #16911, the branch_10x decommission, touch hunspell files).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the compact JUnit through gradle in a clean worktree, stock and with the recommended fix, assertions on and off, plus the whole lucene/analysis/common test task and its lint checks with the fix; through the hunt's classpath runner, the same JUnit on main (-ea, -da), 10.5.2 and 9.12.3, the hunt's other repros with the fix shadowed, and a reach probe over the real Luxembourgish dictionary (stock, with the fix, and native Hunspell 1.3.2), 2026-10-28. Hunt runs on main a9779ef3c193 and ea57360912ed (identical hunspell code), version-portable probes on 18 releases from 3.5.0 to 10.5.2, and a commit-level A/B of LUCENE-5483 over the 4.8.0 jars.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Stemmer.java:560-574 (at a9779ef3c193) (isAffixCompatible: after the 'no previous affix' early return, an inner suffix after an outer suffix is accepted only inside 'if (dictionary.isCrossProduct(affix))' at :564, by the outer suffix's flag at :567-569; otherwise 'return false' at :573), :615-621 (applyAffix: 'if (innerSuffix >= 0) return true;' and the recursion gate 'if (dictionary.isCrossProduct(affix) && recursionDepth <= 1)' at :621), :28-31 (class javadoc 'conforms to the algorithm in the original hunspell algorithm, including recursive suffix stripping')
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/WordFormGenerator.java:238-256 (at a9779ef3c193) (expand: recursion after an applied affix only 'if (dictionary.isCrossProduct(affix.id))' at :251)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:34-46 (at a9779ef3c193) (class javadoc 'can be used in place of native Hunspell for many languages'; exceptions: Hungarian, non-BMP characters, PHONE); HunspellStemFilter.java:113 (a word without stems is returned unchanged); Dictionary.java:685 and :790-791 (the header's cross-product field is parsed as args[2].equals("Y") and stored per affix)
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/twofold.aff (at a9779ef3c193) (Lucene's only twofold fixture: the hunspell(5) example with 'SFX Y Y 1' and 'SFX X Y 1')
  • git diff ea57360912ed a9779ef3c193 -- lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/ (empty: the GPT reviews on ea57360912ed exercised identical code)
  • hunspell(5) of Hunspell 1.7.2, man/hunspell.5:522 '(2) Cross product (permission to combine prefixes and suffixes)' and the 'Twofold suffix stripping' section (:921), as quoted by the independent reproduction and the intended-behaviour check; C Hunspell 1.7.2 src/hunspell/affentry.cxx:630-634, :700-760 and affixmgr.cxx affix_check/suffix_check_twosfx, as read by the finder, the intended-behaviour check and the adjudication
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Dictionary.java:685, (at a9779ef3c193) 790-791, 812-814; Stemmer.java:262, 537-574, 588-655; WordFormGenerator.java:233-259
  • Hunspell 1.7.2 man/hunspell.5:522, 921-962 and src/hunspell/affentry.cxx:633, 712, 745-756 (fetched read-only, 2026-10-28)
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:34-47 (at a9779ef3c193) (Hungarian unsupported); HunspellStemFilter.java:113; lucene/analysis/common/build.gradle:150-153 (pinned dictionary commit); package-info.java:23-25
Why it looks unintended
  • Hunspell 1.7.2 man/hunspell.5:522, 921-962 (fetched read-only, 2026-10-28)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Hunspell.java:34-47; (at a9779ef3c193) WordFormGenerator.java:117; Stemmer.java:28-32; lucene/CHANGES.txt:12545-12549; lucene/analysis/common/src/test/org/apache/lucene/analysis/hunspell/twofold.aff, TestTwoFold.java
  • apache/lucene#6546 body (gh issue view, read-only, 2026-10-28)
Suggested fix
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/hunspell/Stemmer.java:617-652, (at a9779ef3c193) 630-647; WordFormGenerator.java:248, 376

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

Repro files

About the diagrams

The diagram uses the finder's raich/SE example, the miniature of the Luxembourgish räich/b0a0 case; the compact repro uses the hunspell(5) drinkables example. Both fail the same way.

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.