Skip to content

Inflection-208 Add multisource and DMLex dictionary parsing support - #212

Open
nciric wants to merge 2 commits into
unicode-org:mainfrom
nciric:Inflection-208-dmlex-multisource-parser
Open

nciric wants to merge 2 commits into
unicode-org:mainfrom
nciric:Inflection-208-dmlex-multisource-parser

Conversation

@nciric

@nciric nciric commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #208

Summary

Adds support for parsing OASIS DMLex v1.0 JSON dictionaries alongside (or instead of) Wikidata lexeme dumps in tools/dictionary-parser, with configurable multi-source conflict resolution.

Key Changes

  • Explicit Multi-Source CLI Flags (ParserOptions):
    • --wikidata <file1> [<file2> ...] and --dmlex <file1> [<file2> ...] (supporting multiple space- or comma-separated files as well as --wikidata=<files> / --dmlex=<files>, including .bz2 compressed inputs).
    • --merge-conflicts[={true|false}] (default false): controls whether same-part-of-speech entries across multiple input files are overridden by later files (false) or merged (true).
    • Replaced static in-place mutation of Grammar.TYPEMAP with per-instance overrides (customTypeMap and ignoredGrammemes) on ParserOptions.
  • OASIS DMLex v1.0 Deserialization & Streaming (DMLexEntry, DMLexInflectedForm, DMLexStringListDeserializer, ParseWikidata):
    • Streaming Jackson parser (parseDMLexStream) validating the top-level DMLex JSON object (langCode and entries) and converting entries into the shared Lemma / Inflection pipeline.
    • Supports both flat string tags and nested DMLex tag/label/pronunciation objects, synthesizing a default form from headword when inflectedForms is omitted.
    • Extended Grammar lookup (getMappedGrammemes) to normalize camelCase, snake_case, and hyphenated DMLex/Universal Dependencies grammatical tags in addition to Wikidata Q-IDs.
  • Two-Tier Cross-Source Conflict Resolution (ParseWikidata, DictionaryEntry):
    • Tier 1 (Lemma/Paradigm level): When --merge-conflicts=false (default) and multiple source files are provided, stages lemmas and preempts any earlier (lemma, PartOfSpeech) paradigm superseded by a later source file before generating inflection patterns (inflectional.xml) or surface forms (dictionary.lst), preventing orphaned surface forms and inflated pattern counts.
    • Tier 2 (Surface-form level): Tracks per-PartOfSpeech provenance (sourceIndex) inside DictionaryEntry so same-POS surface-form collisions from a later source replace earlier ones while preserving non-conflicting parts of speech (e.g., keeping interjection from an earlier source when a later source adds noun) and intra-file homographs.
  • Testing (ParseWikidataTest):
    • Added unit tests and test fixtures covering standalone DMLex parsing, multi-source Wikidata + DMLex override (--merge-conflicts=false), cross-source union merging (--merge-conflicts=true), partial multi-POS overrides, space/comma-separated file lists, CLI option compatibility (--map-grammeme, --expand-grammemes, --ignore-property, --ignore-entries-with-grammemes, --exclude-language), and schema validation errors.

@nciric
nciric requested a review from grhoten October 9, 2026 23:00

@grhoten grhoten left a comment

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.

It's going in the right direction. I'm getting hung up on some of the details. Some of the details I've gotten hung up on for years, especially the layering/overriding of data.

Comment on lines +134 to +150
/**
* Returns {@code true} when multiple input files are provided and {@code --merge-conflicts} is {@code false}
* (the default). In this mode, later files on the CLI override conflicting entries of the same
* {@link PartOfSpeech} from earlier files via a two-tier strategy:
* <ul>
* <li><b>Tier 1 (Lemma/Paradigm level)</b>: {@link #recordOrAnalyzeLemma} stages lemmas and
* {@link #processStagedLemmas} strips any {@code (lemma.value, PartOfSpeech)} superseded by a later
* source before {@link #analyzeInflections} runs. This prevents removed inflected surface forms
* (e.g., an obsolete plural dropped in a patch file) from lingering as orphans in {@code dictionary.lst}
* and prevents superseded paradigms from inflating {@link InflectionPattern} counts in {@code inflectional.xml}.</li>
* <li><b>Tier 2 (Surface-form level)</b>: {@link DictionaryEntry#merge} resolves remaining surface-form
* collisions per {@link PartOfSpeech} using {@code sourceIndex}.</li>
* </ul>
*/
private boolean isMultiSourceOverwriteMode() {
return !parserOptions.mergeConflicts && parserOptions.sourceInputs.size() > 1;
}

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.

I'm not quite understanding this logic. What does merge mean? Here are the options that I see when there is a conflict.

  1. Pattern is mixed in with the words with normal priority sorting.
  2. Pattern is mixed in, and the conflict comes first.
  3. New pattern and grammemes overrides all previous data for a surface form.
  4. Normal processing happens, until you hit the overrides, and then one of the above options happens.

@nciric nciric Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If merging continues being an issue, we can remove it from this round and just do overrides for now.

Option 4 makes the design much cleaner. I removed the two-tier lemma staging (stagedLemmas) in ParseWikidata and the per-POS PosSlice splitting in DictionaryEntry, and replaced them with Option 4:

  • All files now stream directly into analyzeLemma(...) with normal intra-file merging.
  • When a surface form from a later source file (entry.sourceIndex > this.sourceIndex) collides with an existing DictionaryEntry:
    • By default (--merge-conflicts=false — Option 4 + Option 3): The new entry's pattern and grammemes completely override the previous data for that surface form (matching how --supplementalfile works in buildDictionary). If the same override file itself has multiple entries for that surface form (same sourceIndex), they merge normally with each other. Before writing inflectional.xml, DocumentState.printDocument() prunes any InflectionPattern whose surface forms were all overridden so inflectional.xml stays in sync with dictionary.lst.
    • When --merge-conflicts=true (Option 4 + Option 2): The new entry's grammemes and pattern are mixed in, and the later source's conflicting pattern is placed first in priority ahead of earlier sources' patterns.

Comment on lines +1181 to +1288
return TYPEMAP.get(grammeme);
if (grammeme == null) {
return null;
}
Set<? extends Enum<?>> mapped = TYPEMAP.get(grammeme);
if (mapped != null) {
return mapped;
}
return TYPEMAP.get(normalizeGrammemeKey(grammeme));

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.

This is hit constantly, and it can add to the parse time. Is there a way to optimize this. Perhaps there should be 2 maps? If that complicates the logic elsewhere too much, then maybe not.

Also if grammeme is null, that't an error condition. You need something! Even a part of speech helps.

@nciric nciric Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • Removed normalizeGrammemeKey() completely; getMappedGrammemes() is back to a single TYPEMAP.get(grammeme) lookup with zero string allocations, and DMLex entries use canonical grammeme names from DEFAULTMAP (seeded once into TYPEMAP at static initialization).
  • Added an explicit if (grammeme == null) throw new IllegalArgumentException("Grammeme cannot be null"); check so a null grammeme fails fast.

Comment on lines +68 to +75
// Per-run string-to-grammeme overrides configured via --map-grammeme or Q-ID arguments to
// --ignore-property / --ignore-entries-with-grammemes. Stored per ParserOptions instance so
// static Grammar.TYPEMAP is never mutated in place.
private final Map<String, Set<? extends Enum<?>>> customTypeMap = new HashMap<>();
// Per-run enum overrides configured via --ignore-property or --ignore-entries-with-grammemes
// when specified by grammeme name (e.g. "countable", "shortForm"). Any resolved grammeme enum
// present in this map is replaced with its configured Grammar.Ignorable token during lookup.
private final Map<Enum<?>, Grammar.Ignorable> ignoredGrammemes = new HashMap<>();

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.

Can you elaborate on the purpose of this change? What issue was encountered when mutating Grammar.TYPEMAP?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This was originally added out of concern that mutating static Grammar.TYPEMAP via --map-grammeme or --ignore-property across multiple ParseWikidata.main(...) calls in ParseWikidataTest could affect subsequent tests in the same JVM.

In practice, the test cases use distinct test-only grammemes and keeping separate customTypeMap and ignoredGrammemes fields on ParserOptions made both ParserOptions and every grammeme lookup unnecessarily complicated. I removed customTypeMap and ignoredGrammemes and reverted ParserOptions back to mutating Grammar.TYPEMAP directly as before.

Comment on lines +381 to +390
var grammemeEnum = DEFAULTMAP.get(grammeme);
if (grammemeEnum == null) {
grammemeEnum = DEFAULTMAP.get(Grammar.normalizeGrammemeKey(grammeme));
}
if (grammemeEnum == null && !grammeme.matches("Q\\d*")) {
Set<? extends Enum<?>> mapped = Grammar.getMappedGrammemes(grammeme);
if (mapped != null && mapped.size() == 1) {
grammemeEnum = mapped.iterator().next();
}
}

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.

This is getting complicated. Is there a way to simplify this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Simplified—by removing customTypeMap, ignoredGrammemes, ParserOptions.getMappedGrammemes(), and normalizeGrammemeKey(), setIgnoreProperty() and toEnumSet() are back to their original logic from main.

Comment on lines -67 to -73
void reset() {
value = null;
isRare = false;
grammemes.clear();
inflections.clear();
}

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.

This was done to improve the performance. It's not stored long term. Can you elaborate on why you made this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Lemma.reset() was only removed because the earlier two-tier override logic buffered Lemma instances in stagedLemmas until all files were read. Now that we use streaming (Option 4) without stagedLemmas, Lemma is short-lived again and I have restored Lemma.reset().

* unusual case where {@code "entries"} appears before {@code "langCode"} in the JSON key order,
* entries are buffered in {@code entriesBeforeLangCode} until {@code "langCode"} is read.
*/
private void parseDMLexStream(

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.

Is there a way to leverage a JsonDeserializer to simplify this logic? This seems to be going fairly low level with the parsing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done—replaced the manual JsonToken loop in parseDMLexStream with objectMapper.readValue(parser, DMLexEntry.Lexicon.class) and simplified DMLexStringListDeserializer to use JsonNode.

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.

Add support for lexical dictionary format for custom vocabulary

2 participants