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)