Implement German compound word tokenizer and fix head word capitalization by nciric · Pull Request #196 · unicode-org/inflection · GitHub
Skip to content

Implement German compound word tokenizer and fix head word capitalization - #196

Open
nciric wants to merge 5 commits into
unicode-org:mainfrom
nciric:german-tokenizer-improvement
Open

nciric wants to merge 5 commits into
unicode-org:mainfrom
nciric:german-tokenizer-improvement

Conversation

@nciric

@nciric nciric commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

This PR implements the German compound word tokenizer (DeTokenizer) and integrates it into the inflection engine. It also resolves capitalization lookup issues for lowercased head-word segments resulting from compound splitting.

Key Changes

1. German Tokenizer Infrastructure & Configuration (DeTokenizer)

  • Implemented DeTokenizer and DeDictionaryTokenizerConfig registered in TokenizerFactory.cpp.
  • Configured German to use the new tokenizer and compiled binary dictionary (/de/tokenizer.tokd) in config_de.properties.
  • Configured German Fugenelemente (s, es, e, n, en, er, ens) in DeDictionaryTokenizerConfig.

2. German Head-Word Capitalization Lookup

  • Lowercased compound head-word segments (e.g. frau from Ehefrau) are checked against capitalized noun entries (Frau) in dictionary.getCombinedBinaryType to resolve noun POS and feminine gender morphology, restoring lower-case formatting on inflectedHeadWord after inflection synthesis.

3. German Tokenizer Dictionary

  • Added tokenizer.dictionary for German containing word frequencies and flags (isNoCompound, isNoAtomic, isSegment).

Verification

  • Added unit tests in TokenizerTest.cpp covering German compound splitting (Hauptstraße, Waldweg, Schlossgasse, Marktplatz, Kaiserring, Sonnenallee, Arbeitszimmer, Kindergarten).
  • Executed make check - all 264 test cases passed (290,502 assertions).

@nciric

nciric commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@nciric
nciric requested a review from grhoten July 16, 2026 16:52
@grhoten

grhoten commented Jul 20, 2026 •

Copy link
Copy Markdown
Member
  • Lower-case inputs (frau): Searches frau (fails, as entry is stored as Frau), then transform lowercases frau -> frau (normalized == word), returning nullptr.

This is a bad assumption. Frau is first, and it gets 2 entries in buildDictionary. One is exact casing, and the other is lower cased. Then frau is encountered later. It's an exact match, and overwrites the grammemes from Frau as frau, but I believe that it pushes the possible inflection to the end of the list of inflections.

It does not return null in this scenario.

I remain skeptical on the letter casing of this pull request.

@nciric

nciric commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Correction + deeper investigation on dictionary lookup casing

@george you're right, and my earlier comment was wrong on the mechanism: getCombinedBinaryType("frau") does not return nullptr. Thanks for pushing on this. I verified empirically against the built German dictionary (temporary diagnostic test, since reverted):

=== frau (lowercase) result non-null: 1
=== frau (lowercase) properties: indefinite pronoun
=== Frau (uppercase) result non-null: 1
=== Frau (uppercase) properties: accusative dative feminine genitive nominative noun singular
=== frau has noun bit: 0
=== Frau has noun bit: 1

The lookup succeeds, but resolves to the wrong entry. dictionary_de.lst has two real entries:

Frau: singular accusative dative genitive nominative feminine noun inflection=1
frau: indefinite pronoun inflection=13b

frau is a genuine German word (indefinite pronoun), not a casing artifact. In buildDictionary (Dictionary.cpp::extractPartsOfSpeech):

  • Frau is processed first: wordsToTypes["Frau"] = {noun,feminine,…} (operator[]), and since the normalized form differs, emplace("frau", {noun,feminine,…}) inserts a lowercase copy.
  • frau is processed later: wordsToTypes["frau"] = {indefinite,pronoun} via operator[], which overwrites the normalized copy. (The emplace at line 195 can't reinstate it — the key already exists.)

Net: frau -> {indefinite,pronoun} (noun bit 0, paradigm 13b); Frau -> {noun,feminine,…} (paradigm 1). So when Ehefrau decompounds into Ehe + frau, the head-word lookup returns the pronoun homonym, (type & dictionaryNoun) == 0, and it inflects with the pronoun paradigm. The inflection test einer Ehefrau -> eine Ehefrau fails without the capitalization fix.

Could we instead fix buildDictionary so the lowercase form keeps the noun grammemes?

I tested this directly: changed both writes to merge grammemes (wordsToTypes[phrase] |= combinedType; and wordsToTypes[normalizedKey] |= combinedType;), rebuilt, and ran the full suite. It regresses other languages:

Locale Input Expected Got
de decken {accusative, singular} decke decken
en Jones {genitive} Jones's Jones'

Folding a capitalized word's grammemes onto its lowercase homonym pollutes the POS/number/gender bits that downstream inflection rules rely on — which is exactly what the existing comment warns against ("Don't override previous value because this isn't an exact value").

And even ignoring the regressions, it's the wrong layer: the inflection lookup is separate and case-sensitive. DictionaryLookupInflector resolves patterns via getInflectionPatternsForWord(word) keyed by the exact string, retrying only with a lowercased variant (never capitalized), and InflectionDictionary does no case-folding. So frau can only ever resolve to paradigm 13b; the noun paradigm 1 lives exclusively under Frau. Merging grammemes would hand frau the noun bits but still leave it inflecting with the pronoun paradigm.

Conclusion: capitalizing the compound head word to Frau in DeGrammarSynthesizer is the correct, targeted layer — it fixes the lookup key for both the grammeme metadata and the inflection paradigm, scoped to German compounds rather than globally reshaping the dictionary data model. Not a null-lookup problem (my mistake), but a lowercase-homonym-shadowing problem.

We can discuss this more in the meeting.

@nciric
nciric force-pushed the german-tokenizer-improvement branch 5 times, most recently from 384dc44 to 6163a80 Compare August 12, 2026 17:56
@nciric
nciric force-pushed the german-tokenizer-improvement branch from 6163a80 to 59d47b5 Compare August 12, 2026 19:14
@nciric
nciric force-pushed the german-tokenizer-improvement branch from 59d47b5 to c2d99e0 Compare August 12, 2026 19:42
Comment on lines +559 to +560
// for dictionary lookup to avoid missing the noun entry or matching a lowercase
// homonym with a different POS (e.g., matching pronoun 'frau' instead of noun 'Frau').

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some thought, and if this is the main concern, it would be quicker and more accurate to do a POS check from the getCombinedBinaryType result than to modify the casing during lookup.

You can always find the lower cased forms of all words because that is how the dictionary normalizes the words, and the lower cased forms are collapsed with the title cased forms. This is important when a word is camel cased, which can also be encountered.

You can never miss a word by incorrect casing, but you can disambiguate if necessary.

It's also worth pointing out that DeGrammarSynthesizer_DeDisplayFunction won't inflect pronouns, but it will inflect nouns.

This code already works with a German decompounder, but at least 1 test case was modified when contributing this code to avoid the decompounded test case. See the constructor for the parts of speech and priorities on inflecting such words.

From what I can tell, this is unnecessary work. Was the Ehefrau test in resources/inflection/dialog/inflection/de.xml failing with your changes?

Comment on lines +419 to +432
TEST_CASE("TokenizerTest#testGermanDictionary")
{
::std::unique_ptr<::inflection::tokenizer::Tokenizer> tokenizer(::inflection::tokenizer::TokenizerFactory::createTokenizer(::inflection::util::LocaleUtils::GERMANY()));
compareValueTokens(tokenizer->createTokenChain(u"Hauptstraße"), {u"", u"Haupt", u"straße", u""});
compareValueTokens(tokenizer->createTokenChain(u"Waldweg"), {u"", u"Wald", u"weg", u""});
compareValueTokens(tokenizer->createTokenChain(u"Schlossgasse"), {u"", u"Schloss", u"gasse", u""});
compareValueTokens(tokenizer->createTokenChain(u"Marktplatz"), {u"", u"Markt", u"platz", u""});
compareValueTokens(tokenizer->createTokenChain(u"Kaiserring"), {u"", u"Kaiser", u"ring", u""});
compareValueTokens(tokenizer->createTokenChain(u"Sonnenallee"), {u"", u"Sonne", u"n", u"allee", u""});
compareValueTokens(tokenizer->createTokenChain(u"Arbeitszimmer"), {u"", u"Arbeit", u"s", u"zimmer", u""});
compareValueTokens(tokenizer->createTokenChain(u"Kindergarten"), {u"", u"Kinder", u"garten", u""});
}


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These types of tests are best kept in test/resources/inflection/tokenizer/de.xml. It's harder to maintain here in C++. Some of the other tests in this file can probably move to the data driven tests some day.

@grhoten

grhoten commented Oct 10, 2026

Copy link
Copy Markdown
Member

I think that the tokenizer changes are generally fine. I am pushing back on the DeGrammarSynthesizer_DeDisplayFunction.cpp changes because it has worked fine in the past with a different German tokenizer that decompounds. It's an implementation that I can't open source.

It's probably a good idea to update the copyrights in these files too.

Now if your code were looking up/normalizing variations of ü, ä, and ö, that might be a compelling change to make. This code isn't looking up "ue" nor the NFD forms of ü. All languages pretty much assume NFC.

@nciric

nciric commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants