Skip to content

OPENNLP-1893: Support Hunspell affix dictionaries for stemming - #1190

Open
krickert wants to merge 14 commits into
mainfrom
OPENNLP-1893-hunspell
Open

OPENNLP-1893: Support Hunspell affix dictionaries for stemming#1190
krickert wants to merge 14 commits into
mainfrom
OPENNLP-1893-hunspell

Conversation

@krickert

Copy link
Copy Markdown
Contributor

Adds a stemmer that reads standard Hunspell .dic/.aff dictionary pairs, built on the StemmerFactory seam from OPENNLP-1883.

Supported affix features: alias compression (AF), NEEDAFFIX, ONLYINCOMPOUND, FORBIDDENWORD, CIRCUMFIX, twofold suffix analysis through continuation classes, and compound word positioning including linking forms as used in German compounds. Parsing is regex-free; unsupported dictionary features fail loud at load time rather than degrading silently.

Dictionaries are user-supplied and nothing is bundled: the in-tree tests use a project-authored miniature dictionary, and dev/README-hunspell-dictionaries.md documents acquiring published dictionaries together with their license files. The manual gains a Hunspell section whose example is asserted by HunspellManualExampleTest.

Additionally verified against published dictionaries for English, Spanish, Hungarian, and German through the gated HunspellRealDictionaryTest (runs only when -Dopennlp.hunspell.dict.dir is set; skipped otherwise, and no dictionary data enters the tree).

krickert added a commit to ai-pipestream/opennlp that referenced this pull request Jul 21, 2026
@mawiesne mawiesne added java Pull requests that update Java code tests Pull requests that add or update test code labels Jul 24, 2026
@mawiesne
mawiesne requested review from mawiesne and rzo1 July 24, 2026 07:25
@krickert
krickert marked this pull request as ready for review July 24, 2026 11:22
krickert added a commit to ai-pipestream/opennlp that referenced this pull request Jul 24, 2026
…ENNLP-1895 recorded

Restate the map against apache main a864230, cut as 3.0.0-M5 on 2026-07-24.
apache#1177 (OPENNLP-1870) merged upstream and moves into the merged box, apache#1190 and
apache#1191 are marked ready for review, and OPENNLP-1895 (quantized embedding
tables) joins the diagram in its own colour: filed in JIRA with the pull
request deliberately held until apache#1165 and apache#1152 move.

Statuses now carry the measured GitHub draft flag and how far each head sits
behind main, which surfaces three things the old text did not: apache#1182 is a draft
again, apache#1167 is based on main rather than on apache#1155 and carries the seam and
isBlank commits as copies, and apache#1152 reports conflicts only because its
apache-hosted sentencepiece base has diverged from the refreshed head.
@krickert
krickert force-pushed the OPENNLP-1893-hunspell branch from 8722725 to 65a1c31 Compare July 24, 2026 19:27
krickert added a commit to ai-pipestream/opennlp that referenced this pull request Jul 27, 2026
…preview-docs, record OPENNLP-1897

The 2026-07-24 map refresh (PR-head rebase record, apache#1190/apache#1191 ready,
morfologik-fsa, OPENNLP-1895) was committed directly on
kristian-3.x-features and would have been discarded by the next
regeneration; preview-docs is the durable home. Also adds
OPENNLP-1897-term-vectors to the held-PR section and diagram, and moves
the state line to 2026-07-26 (apache main unchanged since the M5 cut).
krickert added 14 commits July 28, 2026 11:12
…aries

A clean-room reader for .dic and .aff files with PFX/SFX rules, strip
strings, character-class conditions matched by a single scan, cross
products, and char, long, and num flag modes. No dictionary data is
bundled: users point at their own files, so dictionary licenses never
attach to the jar. Unsupported affix features fail closed, missing
analyses rather than inventing them.

(cherry picked from commit 0ecc39c)
…lasses

Suffix rules now carry the continuation flags declared on their affix
text, and analysis undoes a stacked pair when the inner rule's classes
allow the outer one, so derived-then-inflected forms reduce to their
dictionary word.

(cherry picked from commit b543dea)
…nd strip-only rules, and read the parser through the whitespace seam
…, and stem nothing from nothing

Loading the Spanish dictionary of the LibreOffice collection, the same
collection this module's README recommends, exposed three gaps against real
data. Flags under FLAG UTF-8 are now one code point each instead of one UTF-16
unit, since that dictionary names prefix rules with supplementary characters
that would otherwise split into two flags and abort the load; a variation
selector after a flag character selects presentation, not identity, and is
dropped, which the same file also relies on. A numeric or long flag run ends at
the first space or tabulator, the separators the word-list format defines, so
trailing morphological text without a tag no longer aborts the load; the
morphology cut itself now splits on exactly those two separators, the set the
reference implementation's hashmgr.cxx uses, which the javadoc previously
claimed while scanning wider whitespace. Stemming the empty word answers the
empty word instead of letting a strip-only rule conjure a stem from nothing.
All four downloaded dictionaries of the collection, English, Spanish,
Hungarian, and German, now load and stem; new tests pin the escaped slash, the
multi-word entry with trailing tags, and each corrected behavior.
…able

The published Hungarian dictionary flags all of its entries as numeric
references into an AF alias table, so without alias support every entry loaded
flagless and stemming answered the surface form unchanged. The affix parser now
reads the AF table, the first line as the declared count and every further line
as one flag run with trailing comments discarded, and a purely numeric flag
field in the word list resolves as a 1-based reference into it, failing loud
with the line and table size when the reference is out of range. Without an AF
table numeric fields keep their FLAG num meaning. The Hungarian dictionary of
the LibreOffice collection now stems inflected forms; remaining gaps there are
compound territory, which is tracked separately.
…heir boundary character

Undoing a suffix requires the word to end with the rule's affix material, so
only rules whose material ends in the word's last character can ever apply,
and likewise for prefixes and the first character. The dictionary now buckets
its rules by that boundary character at load, and every scan in the stem path,
including the twofold and cross-product inner scans, walks the one bucket plus
the strip-only rules instead of the whole inventory. Measured on the
LibreOffice dictionaries at 4,000 words each: English 553k to 1,024k words per
second, Spanish 9.6k to 28.9k, German 132k to 287k.
When the affix analysis finds nothing and the affix file declares compounding,
a word now splits into two listed parts that the COMPOUNDFLAG or the positional
COMPOUNDBEGIN and COMPOUNDEND flags allow in their positions, honoring
COMPOUNDMIN, with the parts reported left to right. Affix analyses keep
precedence, listed words never decompose, and unflagged parts block a split.
Against the published Hungarian dictionary the unlisted kutyahaz decomposes
into its two nouns while listed compounds and inflected forms keep their
regular analyses. Longer chains, syllable rules, and the compound-only flags
stay unimplemented and simply leave such words unanalyzed.
…itioning

A NEEDAFFIX (or PSEUDOROOT) entry is a virtual stem that exists only to
be affixed, an ONLYINCOMPOUND entry appears only inside compounds, and a
FORBIDDENWORD entry is listed to be blocked; none of them is a standalone
analysis anymore, per homonym flag set, and an affix carrying NEEDAFFIX
among its continuation classes yields no single-removal analysis while
its twofold and cross-product removals stand, the other affix being
exactly the further one required. A cross-product now also requires both
removed affixes' flags in the same homonym's flag set, and CIRCUMFIX
binds marked prefix and suffix halves to one another, so neither half
analyzes alone and a marked half never combines with an unmarked affix.

Decomposition grows from two verbatim parts to the compound machinery
the published German dictionary actually uses: any number of parts under
the positional COMPOUNDBEGIN/COMPOUNDMIDDLE/COMPOUNDEND flags and
COMPOUNDWORDMAX, parts standing on an entry plus one affix with
COMPOUNDPERMITFLAG required at internal boundaries and COMPOUNDFORBIDFLAG
barring marked forms, zero and dash linking suffixes included, an
uppercased retry for capitalized entries spelled lowercase inside a
compound, and the CHECKCOMPOUNDDUP, CHECKCOMPOUNDCASE, and
CHECKCOMPOUNDTRIPLE junction guards, case judged against the original
surface. A listed forbidden word never decomposes, and a fixed
part-licensing budget keeps adversarial input bounded, missing analyses
rather than stalling. Abbildungsverzeichnis, Haustuer, and Kinderzimmer
now decompose against de_DE_frami at 137k words/s single-threaded.

An opt-in test class checks everyday morphology against downloaded
dictionaries under -Dopennlp.hunspell.dict.dir; nothing is bundled.
Extend docbkx/stemmer.xml with the hunspell affix-stemmer section, wire the
chapter into the manual, and add StemmerFactoryUsageExampleTest and
HunspellManualExampleTest asserting the load-and-stem values the chapter prints.
Point the dictionary README at the new manual example.
…, split null contracts, thread-safety annotations
…plete javadoc

- Fold the two per-kind bucketing loops in the HunspellDictionary constructor into a
  single bucketByBoundary helper that takes the rule list, the kind, and the sink for
  the rules with empty affix material.
- Fold collectSuffixedPartStem and collectPrefixedPartStem, which differed only in the
  boundary they face, into one collectAffixedPartStem with a suffix marker and an
  atEdge marker; document what atEdge means at each end.
- Extract a parseValue helper for the single-integer directives so COMPOUNDMIN and
  COMPOUNDWORDMAX no longer share one case body that re-tests which directive it is.
- Extract PREFIX_TAG, SUFFIX_TAG, and NO_MATERIAL constants and use them at the affix
  block header, the rule lines, and the strip and affix material checks.
- Give FORBIDDENWORD its own case in the flag directive switch instead of letting the
  catch-all default assign it, and make that default throw for a directive listed on
  the outer switch but not handled on the inner one.
- Add the missing javadoc on the AffixCondition and HunspellDictionary constructors,
  the Affix record components, and the splitLines, splitOn, and split helpers.
- Convert the single-line accessor javadoc on the compounding and affix bucket getters
  to the {@return ...} form, and replace the hand-written prose on
  HunspellStemmerFactory.newStemmer with {@inheritdoc} plus the instancing note.
- Trim commentary that restates the code: the bucketing rationale duplicated in
  HunspellStemmer, the LibreOffice Spanish anecdote on the code point flag reader, and
  the sentence left dangling in testGermanCompoundsDecompose.
- Drop the defensive null and directory guards from the test helpers
  writeAndLoadFixture and load, which no caller can trip, and document what the real
  dictionary tests assert.
- Fold the repeated ByteArrayInputStream plumbing in HunspellStemmerTest into two load
  overloads, one UTF-8 and one taking the charset the SET declaration test needs.
- Turn the four table-style stemming tests into parameterized tests over their word and
  expected stem pairs, so a failing row names itself.
- Add testNullArgumentsAreRejected, pinning the exact IllegalArgumentException message
  of every public entry point including the argument names the stream loader reports.
- Correct the stemmer manual: name the example files after the fixture the test loads
  rather than en_US, and state that the printed stems are the fixture's, since which
  stem a published dictionary yields is that dictionary's decision.
@krickert
krickert force-pushed the OPENNLP-1893-hunspell branch from 3a3f977 to 6f2d9d8 Compare July 28, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

java Pull requests that update Java code tests Pull requests that add or update test code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants