You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@opennlp.apache.org by "Kristian Rickert (Jira)" <ji...@apache.org> on 2026/08/04 04:12:00 UTC

[jira] [Created] (OPENNLP-1904) Default-locale case folding corrupts lemmatizer models and output

Kristian Rickert created OPENNLP-1904:
-----------------------------------------

             Summary: Default-locale case folding corrupts lemmatizer models and output
                 Key: OPENNLP-1904
                 URL: https://issues.apache.org/jira/browse/OPENNLP-1904
             Project: OpenNLP
          Issue Type: Bug
            Reporter: Kristian Rickert
            Assignee: Kristian Rickert


h2. Summary

Several code paths fold case with the JVM default locale. In the lemmatizer this is
not cosmetic: the folded strings become outcome labels that are *written into the
trained model*, and the inference path folds again with whatever locale the consumer
happens to run under. A model trained on a Turkish JVM therefore carries labels no
other machine reproduces, and an existing English-trained model emits corrupted
lemmas when served on one. Nothing logs, nothing throws; the output is simply wrong.
h2. Reproduction

Each of these is an actual assertion failure observed against unfixed code, under
{{Locale.setDefault}} of {{tr}} (or {{de_DE}} for the report cases).
{noformat}
LemmatizerME.decodeLemmas          expected <mouse>       but was <mıuse>
StringUtil.getShortestEditScript   expected <D0s>         but was <R6ıiD0s>
LemmatizerME.encodeLemmas          expected <R2ioR1cuI1s> but was <R2ıoR1cuI1s>
DictionaryLemmatizer               expected <Illinois>    but was <O>
TrainingParameters.setParams       expected <50>          but was <100>
{noformat}
The Turkish dotless i is the mechanism: {{"Illinois".toLowerCase()}} under {{tr}}
yields {{{}ıllinois{}}}, which never matches an ASCII-lowercase dictionary entry, so
{{DictionaryLemmatizer}} silently degrades to its {{"O"}} sentinel.
h2. Affected sites
||Severity||Location||Expression||
|Critical|{{opennlp-api/.../util/StringUtil.java:488}}|{{wordForm.toLowerCase()}} in {{getShortestEditScript}}|
|Critical|{{opennlp-api/.../util/StringUtil.java:489}}|{{{}lemma.toLowerCase(){}}}, same method|
|Critical|{{opennlp-core/opennlp-runtime/.../lemmatizer/LemmatizerME.java:171}}|{{toks[i].toLowerCase()}} in {{decodeLemmas}}|
|Critical|{{opennlp-core/opennlp-runtime/.../lemmatizer/DictionaryLemmatizer.java:164}}|{{word.toLowerCase()}} in {{getDictKeys}}|
|Moderate|{{opennlp-ml-commons/.../util/TrainingParameters.java:332-337}}|{{{}ITERATIONS_PARAM.toLowerCase(){}}}, {{CUTOFF_PARAM.toLowerCase()}}|
|Moderate|{{opennlp-ml-commons/.../ml/model/AbstractModel.java:34}}|{{static final DecimalFormat}} without locale, used by {{getAllOutcomes()}}|
|Moderate|{{opennlp-core/opennlp-cli/.../cmdline/DetailedFMeasureListener.java:119}}|{{createReport(Locale.getDefault())}}|
|Moderate|{{opennlp-core/opennlp-cli/.../cmdline/FineGrainedReportListener.java}}|9 {{MessageFormat.format}} sites|

Sites 1 to 3 are one defect in two halves. {{getShortestEditScript}} produces the
labels via {{LemmatizerME.encodeLemmas}} and {{{}LemmaSampleEventStream{}}}; {{decodeLemmas}}
consumes them. They must change together or train/serve skew is reintroduced.
h2. Why {{Locale.ROOT}} and not {{StringUtil.toLowerCase}}

This is the load-bearing decision in the patch and a reviewer will likely raise it.

The project's own {{StringUtil.toLowerCase}} maps code points through
{{{}Character.toLowerCase{}}}. That is context-free, and it *disagrees* with
{{String.toLowerCase(Locale.ROOT)}} on context-sensitive mappings (Greek final sigma:
{{ΟΔΟΣ}} folds to {{οδοσ}} rather than {{{}οδος{}}}) and on one-to-many mappings such as
{{{}ß{}}}.

Every shipped model and dictionary was produced on an English-locale JVM, whose fold
is byte-identical to {{{}Locale.ROOT{}}}. So {{Locale.ROOT}} keeps those artifacts working
unchanged, while the code-point fold would silently alter behaviour for every existing
model.

*No model regeneration is required.* That is the point of the choice. A guard test
({{{}StringUtilLocaleTest.testGetShortestEditScriptUsesRootFoldingNotCodePointFolding{}}})
exists specifically to fail if someone later "simplifies" this to
{{StringUtil.toLowerCase}} for consistency.
h2. Deliberately not changed

Reporting these would waste review time; all were checked and are already safe:
{{{}StringUtil.toLowerCase{}}}/{{{}toUpperCase{}}} (code-point based), {{{}String.CASE_INSENSITIVE_ORDER{}}},
all {{equalsIgnoreCase}} uses, {{String.format("U+%04X")}} (the {{{}x{}}}/{{{}X{}}}/{{{}o{}}}
conversions are never localized per the {{Formatter}} spec), {{{}NameFinderDL{}}}'s own
fold, and all model serialization ({{{}Double.toString{}}}, {{{}DataOutputStream{}}}).

Two residual {{toUpperCase()}} calls in main sources are benign and were left alone:
{{EmojiFlags.java:171}} (hex digits in an exception message) and
{{GenerateManualTool.java:227}} (DocBook titles in a build-time doc generator).
h2. Behaviour change to note in release notes

{{DetailedFMeasureListener.createReport()}} and {{FineGrainedReportListener}} now
always render with {{{}Locale.ROOT{}}}. Eval report output is therefore no longer
localized. This was chosen so reports are reproducible, diffable and machine-parseable
across machines. The explicit {{createReport(Locale)}} overload is untouched, so a
caller who wants localized output can still ask for it.

This is the one judgement call in the patch and deserves a maintainer sanity check.
h2. Test coverage

There was *no locale test coverage anywhere in the tree* before this branch. There is
no {{Locale.setDefault}} in main or test sources; the only Turkish-aware tests are
Snowball stemmer data tests, which exercise Turkish {*}input{*}, not a Turkish {*}JVM locale{*}.

Nine new tests, each verified to fail before the fix and pass after (the fix was
stashed, the build rerun, the failure observed, then restored). Failure messages are
in the Reproduction section above.

Two gaps in that coverage:
 * {{AbstractModel.DECIMAL_FORMAT}} is fixed *without* a red/green test. The formatter is
a {{static final}} resolved at class initialization, so any {{Locale.setDefault}} in a
test runs after the class is loaded and such a test passes both before and after. A
fresh-classloader harness would need to reload an abstract class plus {{Context}} and
{{EvalParameters}} and reflectively build a concrete subclass; adding
{{-Duser.language=tr}} to the surefire {{argLine}} would flip the locale for every
other test in the module. Neither seemed worth it for a formatter used only by the
diagnostic {{{}getAllOutcomes(){}}}.
 * {{StringUtilLocaleTest.testGetShortestEditScriptUsesRootFoldingNotCodePointFolding}}
is a {*}guard{*}, not a regression test. It passes before and after, because under an
English default the two folds agree. Its purpose is to break on a future
"simplification". Flagged because it does not meet the fails-before bar.

One drafted test was discarded after it passed unfixed: an encode/decode round trip is
self-consistent within a single locale and so could never fail. It is not in the commits.

Note for anyone adding more: the root {{pom.xml}} surefire \{{argLine}}s do *not*
forward {{{}-Duser.language{}}}, so {{mvn -Duser.language=tr test}} does not reach the
forked test JVM.
h2. Build

{{mvn -o clean install}} over the full 23-module reactor: BUILD SUCCESS, zero failures
or errors, with checkstyle, rat and the existing forbiddenapis configuration active.
{{mvn javadoc:javadoc}} clean on the five touched modules (three pre-existing unknown-tag warnings, none on a changed line).



--
This message was sent by Atlassian Jira
(v8.20.10#820010)