Skip to content

OPENNLP-1933: Remove per-call regex compilation from Leipzig, Morfologik and spellcheck dictionary loading; read event lines and MASC identifiers by their format - #1279

Draft
krickert wants to merge 36 commits into
apache:mainfrom
ai-pipestream:OPENNLP-1933-string-regex-calls
Draft

krickert wants to merge 36 commits into
apache:mainfrom
ai-pipestream:OPENNLP-1933-string-regex-calls

Conversation

@krickert

@krickert krickert commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Based on #1275 and including its commits until that one merges. The diff over the #1275 tip is this PR.

What changes

Three groups of change, listed separately so that the reviewers' request to split them can be acted on.

1. Rewrites that behave as before

  • LeipzigLanguageSampleStream: the matches("[a-z]+") check on the first three characters of a file name is a scan of those characters in place. 3.2M generated names give the same answer as the old call.
  • MorfologikDictionaryBuilder: the anchored replaceAll of the .info suffix is endsWith and substring. Same output on 3M generated names, apart from a trailing line terminator that DictionaryMetadata cannot produce.
  • FrequencyDictionaryLoader: the [\t ]+ column split is a scan over tabs and spaces. 3M lines through the per-line pipeline give the same entries, and the 82k-word dictionary and the tab-separated test resources load the same. Two details are now stated in the manual: whitespace at the line ends is removed as String.strip() does it, and a line of Unicode whitespace only is skipped, independent of opennlp.whitespace.mode.

2. Event line format (RealValueFileEventStream, RealBasicEventStream, SimpleEventStreamBuilder)

The fields of an event line are separated by runs of Unicode whitespace (the White_Space property, StringUtil.splitOnUnicodeWhitespace). The definition is fixed: it does not depend on opennlp.whitespace.mode, and it does not go through the shared WhitespaceTokenizer.INSTANCE (OPENNLP-1947). The old code split the outcome at the first space and the contexts with split("\\s+"), which gave these differences, all pinned by tests:

Input Before Now
a b=1 contexts ["", "b"] contexts [b]
a b outcome "", context a outcome a, context b
a<TAB>b c outcome a<TAB>b outcome a, contexts [b, c]
U+00A0, U+0085, U+2028, U+3000, U+2000 to U+200A inside a field part of the field separate fields
U+001C and U+200B inside a field part of the field part of the field (not White_Space)
outcome-only line RealValueFileEventStream threw StringIndexOutOfBoundsException; RealBasicEventStream returned null and ended the stream, dropping the rest of the file an event without contexts
blank line, also an extra empty line at the end of the file as above InvalidFormatException
null line NullPointerException IllegalArgumentException

SimpleEventStreamBuilder.add:

Input Before Now
o/a/b c rejected outcome o, contexts [a/b, c]
/a outcome "" rejected
o/ a;1 contexts ["", "a;1"] context [a] with value [1.0]
o/;0.5 accepted with an empty context name rejected, the pair named
o/a;, o/a;0.5;1 rejected rejected, the pair named
o/a;-1 accepted rejected, the pair named
format errors RuntimeException IllegalArgumentException

A value follows the last = of a context, so a context name may contain that character; a context with text after the last = that is not a number is kept as written with the value 1, and an error is logged. This was the behavior before and is now written down in the manual. FileEventStream is not touched; it reads events without values and still splits on spaces, tabs, and form feeds through StringTokenizer.

No eval build is needed for this group: every in-repo caller of these three classes is under src/test, and the existing test data parses identically.

3. MASC identifiers (MascIdentifiers, the three MASC parsers)

An identifier is its fixed prefix followed by ASCII digits, and a list of identifiers or anchors is separated by runs of spaces. The XML parser has already turned tabs and line breaks written into an attribute value into spaces (attribute-value normalization), so a tab or no-break space that reaches the parser through a character reference is malformed. The old code removed the first occurrence of the prefix and parsed the rest, which gave these differences:

Input Before Now
7, ne-n+7, 7ne-n as an id accepted SAXException
0 1seg-r0 as a target list [0, 10] SAXException
seg-r1 seg-r2, seg-r1 rejected accepted
seg-r1<NBSP>seg-r2, seg-r1&#9;seg-r2 rejected rejected
ne-n2147483648 NumberFormatException with the JDK message IllegalArgumentException naming the identifier, as SAXException from the parser
region anchors with one or three numbers ArrayIndexOutOfBoundsException or the third number ignored SAXException naming the reason

A malformed MASC id, target list, or anchor pair now fails with a SAXException that includes the cause, instead of being parsed leniently. All 53 ids in the MASC test resources parse the same as before.

Also in this diff

  • The two file-handle fixes that earlier revisions of this branch carried (MarkableFileInputStream.close, the Leipzig Files.lines stream) moved to OPENNLP-1948 with their tests, so they get a release note and a 2.x backport.
  • RealValueFileEventStream.parseEvent(String) is public because RealBasicEventStream in opennlp-ml-maxent calls it. It is new API.
  • Test sources carry invisible characters as \u escapes; the MASC parser tests share one SAX helper and the spellcheck loader tests share one text resource helper.

Before merge

Open decisions for the maintainers

  • Splitting the event line and MASC format changes (groups 2 and 3) into their own issue, leaving OPENNLP-1933 with group 1.

Verification, with checkstyle, -Dopennlp.forkCount=1: ml-commons 117, ml-maxent 51, formats 673, morfologik 15, spellcheck 189 tests, 0 failures.

OPENNLP-1933

@rzo1 rzo1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Little time, so here is a GPT 5.6-sol review instead for now

No blocking findings. No additional regression found in the replacement scans or resource-closing fixes.

Validation across the combined stack: 1,856 targeted tests, zero failures, one skipped.

@krickert
krickert force-pushed the OPENNLP-1933-string-regex-calls branch from 60c0eed to cb4660d Compare September 15, 2026 16:00
@rzo1

rzo1 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Here are some additional comments. The rewrites that are meant to be pure (Leipzig file names, Morfologik file name, spellcheck frequency dictionary loader) fuzz identical to the old calls. The event-stream and MASC parts change file formats without saying so, and the resource-leak fixes don't belong in this PR.

Blocking

  1. Scope: split this PR. It covers 4 modules and mixes three kinds of change:

    • Rewrites that behave exactly as before: Leipzig, Morfologik, FrequencyDictionaryLoader.
    • Format changes: ml event-file parsing and MASC id validation.
    • Two file-handle leak fixes: MarkableFileInputStream has no close override, and the Leipzig reader leaves Files.lines open. Both leaks exist on main and on 2.x.

    Put the leak fixes in their own JIRA so they get a release note and a 2.x backport, and the format changes in their own issue. OPENNLP-1933 keeps only the rewrites that change nothing.

  2. RealValueFileEventStream.java:143, SimpleEventStreamBuilder.java:54. Training features now depend on the global opennlp.whitespace.mode, because WhitespaceTokenizer goes through the mode-dependent StringUtil.isWhitespace. "o a<U+0085>b" gives [a, b] under UNICODE and [a<U+0085>b] under LEGACY, and U+001C–001F behave the other way round. U+0085 is what byte 0x85 decodes to in ISO-8859-1 event files. The old ASCII \s split did not depend on any property, and the same file must give the same events in every JVM. Use a fixed definition: an ASCII scan (in line with FileEventStream's StringTokenizer) or the mode-independent StringUtil.splitOnUnicodeWhitespace. Same point as OPENNLP-1928: Stop EmojiCharSequenceNormalizer from blanking hyphens and BMP characters; reject malformed CoNLL-U multiword ids #1275's DownloadUtil finding.

  3. RealValueFileEventStream.java:142-149, RealBasicEventStream.java:53, SimpleEventStreamBuilder.java:47-57. Undocumented format changes, all verified old vs new:

    • "a b=1": an empty feature "" is now dropped.
    • " a b": the outcome was "" with context a; now the outcome is a.
    • "a\tb c": the outcome was "a\tb"; now a with contexts [b, c].
    • NBSP, U+2028, U+3000 and U+2000–200A inside a feature name now split it.
    • An outcome-only line: RealValueFileEventStream threw SIOOBE and RealBasicEventStream returned null, silently ending the stream and dropping the rest of the file. Now it is an event with no contexts.
    • " " and a blank line (including an extra empty line at EOF) now throw InvalidFormatException.
    • SimpleEventStreamBuilder: "o/a/b c" was rejected and is now accepted with context a/b; "/a" was accepted and is now rejected; "o/ a;1" gave contexts ["", "a;1"] and now gives [a] with value [1.0].

    The tests pin these, but the description doesn't mention them. List them all. No eval build is needed (every in-repo caller is under src/test, and the existing test data parses identically); say that explicitly.

  4. MascIdentifiers.java:52, :71. MASC files now parse differently. On 2M fuzz inputs no input is accepted by both with different values, but:

    • 171k ids the old code accepted now throw ("7", "ne-n+7", "7ne-n").
    • 254k target lists the old code accepted now throw; some were silently wrong ("0 1seg-r0" gave [0, 10]).
    • 3k lists the old code rejected are now accepted (leading, doubled or NBSP separators).

    Failing loud is right, but say so, e.g. "a malformed MASC id or target list now fails with SAXException instead of being parsed leniently". All 53 ids in the fake MASC test resources parse the same. MASC has no eval coverage, so run a real MASC corpus through MascDocumentStream before merge.

  5. machine-learning.xml:89-96. The claim is false for FileEventStream, which is not touched and still uses StringTokenizer (no NBSP split; a blank line throws NoSuchElementException; no =value split). Parent and subclass now split the same file format differently, and FileEventStream.java:35 still says "space delimited". Either align FileEventStream or limit the paragraph to the real-valued streams. Also say the value follows the last =, and that an unparsable value keeps the whole text as the context with value 1 and an error log. OPENNLP-1929: BasicContextGenerator splits on whitespace by default and takes a custom separator literally #1280 edits the same section, so coordinate.

  6. PR description. It is out of date after cb4660d:

    • It names StringUtil.splitOnAsciiWhitespace, which does not exist anywhere; the code uses WhitespaceTokenizer.
    • The MASC row says removeFirst, first occurrence only; the code has strict parseId/parseIds.
    • "with String.split semantics" and "each new helper is tested with the old call as an oracle" are no longer true; cb4660d removed the oracles.
    • "String calls that compile a Pattern on each invocation" doesn't fit FrequencyDictionaryLoader, whose pattern was already a static constant; the real reason is OPENNLP-1935: Add a checkstyle guard for java.util.regex in production code #1282's checkstyle rule.
    • "Each has a test that fails without its fix" holds only for MarkableFileInputStreamTest; nothing pins the Files.lines leak on Linux or macOS.
    • The new public RealValueFileEventStream.parseEvent(String) isn't mentioned, and it is frozen once released.

    Rewrite it. Suggested title for what remains: "OPENNLP-1933: Remove per-call regex compilation from Leipzig, Morfologik and spellcheck dictionary loading".

Minor

  • WhitespaceTokenizer.INSTANCE (RealValueFileEventStream:143, SimpleEventStreamBuilder:54, MascIdentifiers:75). It is a shared mutable singleton: TokenizerME.java:195-196 calls setKeepNewLines on it, after which add("o/a\nb") gives [a, "\n", b] and "seg-r0&#10;seg-r1" fails. Don't split fields with a Tokenizer; a StringUtil split also avoids the Span allocation and the extra Arrays.copyOfRange. For MASC, SAX already turns tab/CR/LF in attributes into spaces, so splitting on runs of ' ' is enough.
  • MascWordParser.java:47. anchors is still split(" ") while targets accepts whitespace runs. Either align all or keep both on a single space.
  • MascWordParser.java:56. The catch drops the message and cause, unlike the other two parsers. Use new SAXException("..." + e.getMessage(), e).
  • MascIdentifiers.java:58. An overflowing id throws NumberFormatException with the JDK message. Mention "or does not fit an int" in @throws.
  • MascPennTagParserTest.java:54. Literal tab/newline in XML attributes reach the handler as spaces, so those rows test nothing new. Use &#9;/&#10; or drop them.
  • RealValueFileEventStream.java:137. "Must not be null", but null throws NPE. Validate with IAE, and consider whether parseEvent needs to be public at all.
  • RealBasicEventStream.java:42-45. Add <p> after {@inheritDoc} and name InvalidFormatException in "or if a line has no outcome".
  • SimpleEventStreamBuilder.java:49, :56, :43. The "format error of the event \"%s\"" literal is duplicated (declare a constant), the new check throws a bare RuntimeException (use IAE), and @throws RuntimeException If … should read "Thrown if …" like the rest of the file.
  • RealBasicEventStreamTest.java:109-130. A verbatim copy of RealValueFileEventStreamTest.java:100-121. Keep the parsing matrix in one @ParameterizedTest on parseEvent and one stream smoke test.
  • Tests with invisible characters. Raw U+000B in FrequencyDictionaryLoaderTest.java:53/:69/:76, and raw U+00A0 in RealValueFileEventStreamTest.java:124/:143 and SimpleEventStreamBuilderTest.java:70. Use \u escapes; :143 also looks like a duplicate " ".
  • Tests. Mode dependence is not pinned (U+0085, U+001C under both modes). Add U+200B (not whitespace) and a supplementary character to testOutcomeEndsAtTheFirstWhitespace and SimpleEventStreamBuilderTest:68.
  • LeipzigLanguageSampleStream.java:193. No need to be static, the isEmpty() branch is dead, and it is the third inline 'a'..'z' check. Add StringUtil.isAsciiLowerCase next to isAsciiLetter, and scan the first LANG_CODE_LENGTH chars without substring.
  • LeipzigLanguageSampleStreamTest.java:108, :113. "" is unreachable; use real Leipzig names (eng_news_2010_10K-sentences.txt).
  • MarkableFileInputStreamTest.java:79. Repeats the first half of testCloseTwiceIsAccepted (:71). Drop it.
  • PlainTextByLineStream.java:73 (blast radius, for the leak JIRA). reset() creates a new reader without closing the old one, so every reset over MarkableFileInputStreamFactory still leaks a handle until GC.
  • FrequencyDictionaryLoader.java:159/183, :204. A precompiled Pattern was replaced by 25 lines only to satisfy OPENNLP-1935: Add a checkstyle guard for java.util.regex in production code #1282. Either suppress the class or keep the scan, make splitColumns a private instance method, and declare the '\t'/' ' literals as constants.
  • FrequencyDictionaryLoaderTest. Copies stringResource from SymSpellModelSerializationTest.java:194. Move loaderSkipsBlankAndCommentLines/loaderRejectsMalformedLine (:164, :174) into the new class and share the helper.
  • spellcheck.xml:307. strip() removes any Character.isWhitespace at line ends (VT, FF, U+2003, U+3000), not only TAB and space. Reword.
  • MorfologikDictionaryBuilder.java:81. No need to be static. The @CsvSource rows dictionary.txt, .info.bak, INFO, info and '' test input build never passes; keep the .info rows.
  • MorfologikDictionaryBuilderTest.java:78-85. Repeats the build at :47-49; reuse createMorfologikDictionary(). Register deleteOnExit before the assertions.
  • MASC parser tests. The SAX parse() helper is copied into all 3 tests. Extract a test utility.

Verified:

  • FrequencyDictionaryLoader: 3M lines through the whole per-line pipeline, 0 differences; the 82k-word dictionary and the TAB test resources load the same.
  • Morfologik: 3M names, 0 differences except a trailing line terminator, which DictionaryMetadata can never produce.
  • Leipzig: 3.2M inputs, 0 differences.
  • Event-stream float parsing is unchanged, and the module layering is fine.
  • The poms only add test-scoped junit-jupiter-params.
  • The SpellCheckingCharSequenceNormalizer.URL_LIKE and BasicContextGenerator references are correct.

1 similar comment
@rzo1

rzo1 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Here are some additional comments. The rewrites that are meant to be pure (Leipzig file names, Morfologik file name, spellcheck frequency dictionary loader) fuzz identical to the old calls. The event-stream and MASC parts change file formats without saying so, and the resource-leak fixes don't belong in this PR.

Blocking

  1. Scope: split this PR. It covers 4 modules and mixes three kinds of change:

    • Rewrites that behave exactly as before: Leipzig, Morfologik, FrequencyDictionaryLoader.
    • Format changes: ml event-file parsing and MASC id validation.
    • Two file-handle leak fixes: MarkableFileInputStream has no close override, and the Leipzig reader leaves Files.lines open. Both leaks exist on main and on 2.x.

    Put the leak fixes in their own JIRA so they get a release note and a 2.x backport, and the format changes in their own issue. OPENNLP-1933 keeps only the rewrites that change nothing.

  2. RealValueFileEventStream.java:143, SimpleEventStreamBuilder.java:54. Training features now depend on the global opennlp.whitespace.mode, because WhitespaceTokenizer goes through the mode-dependent StringUtil.isWhitespace. "o a<U+0085>b" gives [a, b] under UNICODE and [a<U+0085>b] under LEGACY, and U+001C–001F behave the other way round. U+0085 is what byte 0x85 decodes to in ISO-8859-1 event files. The old ASCII \s split did not depend on any property, and the same file must give the same events in every JVM. Use a fixed definition: an ASCII scan (in line with FileEventStream's StringTokenizer) or the mode-independent StringUtil.splitOnUnicodeWhitespace. Same point as OPENNLP-1928: Stop EmojiCharSequenceNormalizer from blanking hyphens and BMP characters; reject malformed CoNLL-U multiword ids #1275's DownloadUtil finding.

  3. RealValueFileEventStream.java:142-149, RealBasicEventStream.java:53, SimpleEventStreamBuilder.java:47-57. Undocumented format changes, all verified old vs new:

    • "a b=1": an empty feature "" is now dropped.
    • " a b": the outcome was "" with context a; now the outcome is a.
    • "a\tb c": the outcome was "a\tb"; now a with contexts [b, c].
    • NBSP, U+2028, U+3000 and U+2000–200A inside a feature name now split it.
    • An outcome-only line: RealValueFileEventStream threw SIOOBE and RealBasicEventStream returned null, silently ending the stream and dropping the rest of the file. Now it is an event with no contexts.
    • " " and a blank line (including an extra empty line at EOF) now throw InvalidFormatException.
    • SimpleEventStreamBuilder: "o/a/b c" was rejected and is now accepted with context a/b; "/a" was accepted and is now rejected; "o/ a;1" gave contexts ["", "a;1"] and now gives [a] with value [1.0].

    The tests pin these, but the description doesn't mention them. List them all. No eval build is needed (every in-repo caller is under src/test, and the existing test data parses identically); say that explicitly.

  4. MascIdentifiers.java:52, :71. MASC files now parse differently. On 2M fuzz inputs no input is accepted by both with different values, but:

    • 171k ids the old code accepted now throw ("7", "ne-n+7", "7ne-n").
    • 254k target lists the old code accepted now throw; some were silently wrong ("0 1seg-r0" gave [0, 10]).
    • 3k lists the old code rejected are now accepted (leading, doubled or NBSP separators).

    Failing loud is right, but say so, e.g. "a malformed MASC id or target list now fails with SAXException instead of being parsed leniently". All 53 ids in the fake MASC test resources parse the same. MASC has no eval coverage, so run a real MASC corpus through MascDocumentStream before merge.

  5. machine-learning.xml:89-96. The claim is false for FileEventStream, which is not touched and still uses StringTokenizer (no NBSP split; a blank line throws NoSuchElementException; no =value split). Parent and subclass now split the same file format differently, and FileEventStream.java:35 still says "space delimited". Either align FileEventStream or limit the paragraph to the real-valued streams. Also say the value follows the last =, and that an unparsable value keeps the whole text as the context with value 1 and an error log. OPENNLP-1929: BasicContextGenerator splits on whitespace by default and takes a custom separator literally #1280 edits the same section, so coordinate.

  6. PR description. It is out of date after cb4660d:

    • It names StringUtil.splitOnAsciiWhitespace, which does not exist anywhere; the code uses WhitespaceTokenizer.
    • The MASC row says removeFirst, first occurrence only; the code has strict parseId/parseIds.
    • "with String.split semantics" and "each new helper is tested with the old call as an oracle" are no longer true; cb4660d removed the oracles.
    • "String calls that compile a Pattern on each invocation" doesn't fit FrequencyDictionaryLoader, whose pattern was already a static constant; the real reason is OPENNLP-1935: Add a checkstyle guard for java.util.regex in production code #1282's checkstyle rule.
    • "Each has a test that fails without its fix" holds only for MarkableFileInputStreamTest; nothing pins the Files.lines leak on Linux or macOS.
    • The new public RealValueFileEventStream.parseEvent(String) isn't mentioned, and it is frozen once released.

    Rewrite it. Suggested title for what remains: "OPENNLP-1933: Remove per-call regex compilation from Leipzig, Morfologik and spellcheck dictionary loading".

Minor

  • WhitespaceTokenizer.INSTANCE (RealValueFileEventStream:143, SimpleEventStreamBuilder:54, MascIdentifiers:75). It is a shared mutable singleton: TokenizerME.java:195-196 calls setKeepNewLines on it, after which add("o/a\nb") gives [a, "\n", b] and "seg-r0&#10;seg-r1" fails. Don't split fields with a Tokenizer; a StringUtil split also avoids the Span allocation and the extra Arrays.copyOfRange. For MASC, SAX already turns tab/CR/LF in attributes into spaces, so splitting on runs of ' ' is enough.
  • MascWordParser.java:47. anchors is still split(" ") while targets accepts whitespace runs. Either align all or keep both on a single space.
  • MascWordParser.java:56. The catch drops the message and cause, unlike the other two parsers. Use new SAXException("..." + e.getMessage(), e).
  • MascIdentifiers.java:58. An overflowing id throws NumberFormatException with the JDK message. Mention "or does not fit an int" in @throws.
  • MascPennTagParserTest.java:54. Literal tab/newline in XML attributes reach the handler as spaces, so those rows test nothing new. Use &#9;/&#10; or drop them.
  • RealValueFileEventStream.java:137. "Must not be null", but null throws NPE. Validate with IAE, and consider whether parseEvent needs to be public at all.
  • RealBasicEventStream.java:42-45. Add <p> after {@inheritDoc} and name InvalidFormatException in "or if a line has no outcome".
  • SimpleEventStreamBuilder.java:49, :56, :43. The "format error of the event \"%s\"" literal is duplicated (declare a constant), the new check throws a bare RuntimeException (use IAE), and @throws RuntimeException If … should read "Thrown if …" like the rest of the file.
  • RealBasicEventStreamTest.java:109-130. A verbatim copy of RealValueFileEventStreamTest.java:100-121. Keep the parsing matrix in one @ParameterizedTest on parseEvent and one stream smoke test.
  • Tests with invisible characters. Raw U+000B in FrequencyDictionaryLoaderTest.java:53/:69/:76, and raw U+00A0 in RealValueFileEventStreamTest.java:124/:143 and SimpleEventStreamBuilderTest.java:70. Use \u escapes; :143 also looks like a duplicate " ".
  • Tests. Mode dependence is not pinned (U+0085, U+001C under both modes). Add U+200B (not whitespace) and a supplementary character to testOutcomeEndsAtTheFirstWhitespace and SimpleEventStreamBuilderTest:68.
  • LeipzigLanguageSampleStream.java:193. No need to be static, the isEmpty() branch is dead, and it is the third inline 'a'..'z' check. Add StringUtil.isAsciiLowerCase next to isAsciiLetter, and scan the first LANG_CODE_LENGTH chars without substring.
  • LeipzigLanguageSampleStreamTest.java:108, :113. "" is unreachable; use real Leipzig names (eng_news_2010_10K-sentences.txt).
  • MarkableFileInputStreamTest.java:79. Repeats the first half of testCloseTwiceIsAccepted (:71). Drop it.
  • PlainTextByLineStream.java:73 (blast radius, for the leak JIRA). reset() creates a new reader without closing the old one, so every reset over MarkableFileInputStreamFactory still leaks a handle until GC.
  • FrequencyDictionaryLoader.java:159/183, :204. A precompiled Pattern was replaced by 25 lines only to satisfy OPENNLP-1935: Add a checkstyle guard for java.util.regex in production code #1282. Either suppress the class or keep the scan, make splitColumns a private instance method, and declare the '\t'/' ' literals as constants.
  • FrequencyDictionaryLoaderTest. Copies stringResource from SymSpellModelSerializationTest.java:194. Move loaderSkipsBlankAndCommentLines/loaderRejectsMalformedLine (:164, :174) into the new class and share the helper.
  • spellcheck.xml:307. strip() removes any Character.isWhitespace at line ends (VT, FF, U+2003, U+3000), not only TAB and space. Reword.
  • MorfologikDictionaryBuilder.java:81. No need to be static. The @CsvSource rows dictionary.txt, .info.bak, INFO, info and '' test input build never passes; keep the .info rows.
  • MorfologikDictionaryBuilderTest.java:78-85. Repeats the build at :47-49; reuse createMorfologikDictionary(). Register deleteOnExit before the assertions.
  • MASC parser tests. The SAX parse() helper is copied into all 3 tests. Extract a test utility.

Verified:

  • FrequencyDictionaryLoader: 3M lines through the whole per-line pipeline, 0 differences; the 82k-word dictionary and the TAB test resources load the same.
  • Morfologik: 3M names, 0 differences except a trailing line terminator, which DictionaryMetadata can never produce.
  • Leipzig: 3.2M inputs, 0 differences.
  • Event-stream float parsing is unchanged, and the module layering is fine.
  • The poms only add test-scoped junit-jupiter-params.
  • The SpellCheckingCharSequenceNormalizer.URL_LIKE and BasicContextGenerator references are correct.

@rzo1

rzo1 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Follow-up:

  • The two file-handle leak fixes (MarkableFileInputStream, Leipzig Files.lines) are now tracked in OPENNLP-1948, together with the PlainTextByLineStream.reset() leak. Please move them there so they can be backported to 2.x.
  • The WhitespaceTokenizer.INSTANCE hazard for the event streams and MascIdentifiers is OPENNLP-1947.

@krickert krickert changed the title OPENNLP-1933: Replace per-call String regex splits and replacements with scans OPENNLP-1933: Remove per-call regex compilation from Leipzig, Morfologik and spellcheck dictionary loading; read event lines and MASC identifiers by their format Sep 16, 2026
@krickert
krickert force-pushed the OPENNLP-1933-string-regex-calls branch from af1d535 to 51bd136 Compare September 16, 2026 01:43
@krickert

Copy link
Copy Markdown
Contributor Author

Dependency note: #1302 (OPENNLP-1955) adds StringUtil.split(CharSequence, char) and goes first. This PR then needs a short rebase for the placement conflict in StringUtil.java, and the "-", "/" and ";" splits in ConlluStream and SimpleEventStreamBuilder can switch to the shared utility.

@krickert
krickert force-pushed the OPENNLP-1933-string-regex-calls branch from 51bd136 to fff4466 Compare September 16, 2026 06:01
…in DefaultPOSContextGenerator

Add pinning tests for the accept and reject sides of both predicates.
… checks in FeatureGeneratorUtil

Add pinning tests for the capPeriod accept and reject sides.
…enPatternFeatureGenerator

Add a pinning test that non-letter sub-tokens do not produce st= features.
… char scan

Matches (.+)-\w+ semantics: group(1) is everything before the last hyphen,
the hyphen must not be at index 0, and the suffix must be non-empty word
chars. Add pinning tests for outcomes without hyphen, hyphen at index 0,
empty suffix, non-word suffix, and the normal accept case.
…n BrownCluster

Replicates String.split(\t) semantics, including dropped trailing empty fields.
…explicit char scans in TokenSampleStream

splitOnWhitespace replicates String.split(\\s+): a leading whitespace run
yields one empty leading field, runs collapse, and trailing empty fields are
dropped.
…mojiCharSequenceNormalizer

The replaced pattern contains a high surrogate range, so the regex engine
matches whole code points in the flattened range [U+D83C, U+10FC00]. The
replacement scans code points, collapses each maximal matching run into a
single space, and copies non-matching code points verbatim. Add pinning
tests for unpaired surrogates, BMP chars above U+D83C, and supplementary
code points beyond U+10FC00.
…xplicit scans in ConlluStream

splitOnHyphen replicates String.split("-"): every hyphen is a boundary,
empty fields between consecutive hyphens are kept, and trailing empty
fields are dropped.

extractTextLang replicates find() of text_([a-z]{2,3}): the first
occurrence of "text_" followed by two to three ASCII lowercase letters,
preferring three.
…ss scans in ParserTool

The two replaceAll passes are replicated by two cursor passes with the
same leftmost-first resume-after-match semantics, which matters for
overlapping pairs such as "x((" or "((a)(b))": a pair starting at the
second char of a match is only reconsidered by the second pass.
…cans in DownloadUtil

parseChecksum now scans to the first ASCII whitespace character,
replicating split(\s)[0] on the trimmed content.

extractLinks replicates find() of the <a href="(.*?)">(.*?)</a> pattern
with CASE_INSENSITIVE and DOTALL flags: the href value ends at the first
"> and the first case-insensitive </a> closes the match, so nested link
markup is swallowed by the outer match.
…meric patterns with explicit scans in ADNameSampleStream

splitOnWhitespace and splitOnUnderscores replicate run-based splitting: a
leading separator run yields one empty leading field, trailing empty
fields are dropped, and an all-separator input yields no fields.

matchHyphenatedToken replicates the three-branch hyphen pattern at code
point granularity, isAlphaNumeric replicates ^[\p{L}\p{Nd}]+$ via
Character.isLetter and Character.isDigit, and tagContent replicates
matches() of <(NER:)?(.*?)> including its optional NER: prefix.
…OSSampleStream

replaceWhitespaceWithEquals replicates replaceAll("=") of the \s+
pattern: every run of ASCII whitespace, including leading and trailing
runs, is replaced by a single equals sign.
…tenceSampleStream

parseTextAndParagraph replicates matches() of the
^(?:[a-zA-Z\-]*(\d+)).*?p=(\d+).* pattern: after the optional ASCII
letters and hyphens, the text id is the first ASCII digit run and the
paragraph id is the digit run after the first "p=" that is followed by
at least one digit.
…entenceStream

replaceGuillemetPunctuation replicates replaceAll of the »\s+ punct
patterns: every run of ASCII whitespace between » and the punctuation
character is removed.

parsePunctuationLine replicates matches() of the ^(=*)(\W+)$ pattern:
the line consists of leading equals signs followed by one or more
non-word characters, where a word character is an ASCII letter, digit,
or underscore. A line of only equals signs matches, with the last
equals sign as lexeme.
Adds StringUtil.isAsciiWhitespace, splitOnAsciiWhitespace,
containsAsciiUpperCase, and containsAsciiDigit and removes the copies
from the AD streams, the English TokenSampleStream, DownloadUtil, and
the POS and lemmatizer context generators. NameFinderME.extractNameType
delegates to BioCodec.

Cases the new tests found first: the TokenSampleStream split returned
one empty token for a whitespace-only line where the original split
returned none, and matchHyphenatedToken accepted a single hyphen.
BrownCluster.splitTabs now removes all trailing empty fields, as
String.split does.

Each helper has a test, parameterized where the inputs are a table,
with the reject side and the edge cases: empty input, leading and
trailing separators, non-ASCII spaces and digits, and
supplementary-plane characters. Helpers only called from instance
methods are no longer static; the block comments on the helpers are
now Javadoc that states the behavior.
… quirks

Scans now do what the code meant instead of what the regular expression
would accept:

- EmojiCharSequenceNormalizer replaces only supplementary-plane code
  points. BMP characters, hyphens and unpaired surrogates are kept.
- ParserTool separates brackets on Unicode whitespace in one pass, so
  brackets that follow each other are all spaced and no space is doubled.
- The FeatureGeneratorUtil capital-period feature needs one capital and
  one period; a trailing line break no longer qualifies.
- BioCodec rejects a line terminator at any position of an outcome type.
- DownloadUtil accepts a checksum file with leading Unicode whitespace
  and treats a blank file as missing.
- The AD streams split on the toolkit whitespace definition, drop empty
  underscore parts and judge punctuation lines by code point.
- ConlluStream fails a malformed multiword token id with an
  InvalidFormatException instead of skipping it.

StringUtil adds isAsciiLetter, isAsciiDigit, endOfAsciiDigits,
isLineTerminator and indexOfLineTerminator, shared by BioCodec,
ADSentenceSampleStream and ConlluStream here and needed by the related
OPENNLP-1929 to OPENNLP-1935 branches. isAsciiWhitespace and
splitOnAsciiWhitespace are removed; callers use the Unicode-aware
StringUtil.isWhitespace and WhitespaceTokenizer.

The manual documents the bracket handling of the parser tool, the
CoNLL-U multiword id and language code rules, the AD reader rules and
the emoji normalizer in the language detector chapter. Tests cover the
accept and reject side of each scan, the Unicode plane boundaries and
Unicode whitespace separators.
SimpleEventStreamBuilder and RealValueFileEventStream split the context
part of each event line with String.split("\\s+"), which compiles the
pattern on every call. Both now use StringUtil.splitOnAsciiWhitespace,
which yields the same elements: a leading run gives one empty first
element, trailing empty elements are dropped, and only the six ASCII
whitespace characters separate fields, so a no-break space stays inside
a field. New tests pin those cases through the builder and the stream.
RealBasicEventStream split the context part of each event line with
String.split("\\s+"), compiling the pattern per line. It now uses
StringUtil.splitOnAsciiWhitespace, which yields the same elements
including the leading empty element after a double space and the
dropped trailing empties. A new test pins tab and multi-space runs and
a no-break space that must stay inside a field.

BasicContextGenerator.getContext still calls String.split with the
caller supplied separator. Its public constructor accepts any String,
so a caller may pass a pattern today; changing that needs a decision on
the API contract and is left as is.
LeipzigLanguageSampleStream accepted a sentences file when the first
three characters of its name matched "[a-z]+", compiling the pattern
for every directory entry. A small scan now checks that those
characters are ASCII lower case letters, which rejects the same names:
capitals, digits, punctuation, and letters outside ASCII. The length
three is a named constant shared with the two other places that cut
the language code from the file name. Tests cover the scan directly on
both sides and read a temporary directory holding accepted and rejected
names through the stream.
MascNamedEntityParser, MascPennTagParser, and MascWordParser removed
the "ne-n", "penn-n", and "seg-r" prefixes from identifier attributes
with String.replaceFirst, which compiles a pattern for every element.
The new package-private MascIdentifiers holds the three prefixes as
constants and a removeFirst helper that cuts the first occurrence with
indexOf and substring and leaves later occurrences in place, as
replaceFirst did. Tests check the helper against replaceFirst on both
sides, drive each parser over inline annotation XML, and pin that a
repeated prefix still fails to parse.
MorfologikDictionaryBuilder.build turned the metadata file name into
the dictionary file name with replaceAll("\\.info$", ".dict"), building
the pattern from the Morfologik extension constant on each call. The
new toDictionaryFileName checks endsWith(".info") and exchanges that
suffix with substring, leaving any other name unchanged. Both suffixes
are named constants. The test compares the helper with the former
replaceAll over names with a repeated, missing, or differently cased
suffix and checks that the built dictionary is written to the metadata
directory under the expected name. The module pom adds the parent
managed junit-jupiter-params test dependency for the parameterized
test.
FrequencyDictionaryLoader split each dictionary line with the pattern
"[\t ]+". The new splitColumns walks the line and breaks it on runs of
TAB and space only, with the result of Pattern.split: a leading run
gives one empty first column, trailing empty columns are dropped, a
separator-only line gives an empty array, and an empty line gives one
empty column. A no-break space, a vertical tab, or a form feed stays
inside a column, as before. The test compares the scan with the former
pattern over those cases and reads unigram and bigram lines with mixed
TAB and space runs through parseUnigrams and parseBigrams. The module
pom adds the parent managed junit-jupiter-params test dependency for
the parameterized test.

SpellCheckingCharSequenceNormalizer keeps its URL_LIKE pattern: it
combines three alternatives with URL schemes, an email shape, and a
list of top level domains, which is a heuristic rather than a plain
character scan.
New base removed StringUtil.splitOnAsciiWhitespace, so the event
streams no longer compile. Red evidence: cannot find symbol at
RealValueFileEventStream.java:141 and SimpleEventStreamBuilder.java:44
(RealBasicEventStream.java:64 next in line).

The new tests pin the intended contract: whitespace runs (Unicode)
delimit contexts, NBSP splits, and no empty-string predicate survives
leading, repeated, or trailing runs.
Follows the red test commit. Replaces the removed
StringUtil.splitOnAsciiWhitespace with
WhitespaceTokenizer.INSTANCE.tokenize, matching the TokenSampleStream
precedent: Unicode whitespace runs delimit, empty fields are dropped
instead of becoming empty-string predicates.

Full opennlp-ml-commons and opennlp-ml-maxent suites pass.
…intended

Review pass over the scans on this branch. Where a scan had copied a
String.split or replaceFirst quirk, it now does what the format means:

- Event lines: RealValueFileEventStream.parseEvent takes the outcome as
  the first whitespace separated field, shared with RealBasicEventStream.
  A tab or a run after the outcome no longer breaks the line, an outcome
  only line is an event without contexts instead of the end of the
  stream, and a blank line fails with an InvalidFormatException.
- SimpleEventStreamBuilder splits the outcome at the first slash, so a
  context may contain one, and rejects an event without contexts.
- MascIdentifiers.parseId requires the prefix at the start followed by
  ASCII digits only; parseIds parses link targets separated by whitespace
  runs. A doubled or missing prefix is reported instead of parsed.
- FrequencyDictionaryLoader.splitColumns returns the non-empty columns
  only, so leading and repeated separators make no empty column.

Tests no longer compare a scan with the pattern it replaced; they pin
the accepted and rejected inputs directly. The manual describes the
event file line format, the Leipzig file name rule, and the dictionary
column separators.
… rows (red)

SimpleEventStreamBuilder accepts a valued context with no name before
the separator, and the dictionary loader reports a line of no-break
spaces as malformed although a line of whitespace is documented as
skipped.
… by the toolkit rule

SimpleEventStreamBuilder takes a valued context as the text before the
first semicolon and the number after it, and rejects a context with an
empty name, an empty value, or a further semicolon.

FrequencyDictionaryLoader tests whether a line is blank with
StringUtil.isBlank, so a line of no-break spaces is skipped like a line
of spaces, as the spellcheck chapter of the manual states.
…nary columns

Event lines: the outcome may contain a slash or an equals sign, the
value follows the last equals sign in a context, a context without a
number is kept as written and counts once, exponent and signed values
are read, negative values are rejected with the context named, and CR,
LF, and CRLF line terminators are all accepted by both stream classes.

SimpleEventStreamBuilder: the outcome stops at the first slash, a
valued context name may contain equals signs, the first context
determines whether the event has values, and a value that is not a
number is rejected.

MASC ids: digits of other scripts, superscripts, roman numerals, and a
supplementary digit are rejected in an id and in an id list, as is a
number above Integer.MAX_VALUE.

Leipzig: upper case letters in the language code are rejected by the
check and by the directory scan, and files with CR, LF, or CRLF line
terminators are read.

Dictionary loader: skipped lines count toward the reported line number,
each malformed line names the reason, signed and zero-padded counts are
read, columns after the count are ignored, a comment after the byte
order mark is skipped, and other whitespace remains inside a bigram
word.
… (red)

FrequencyDictionaryLoader reads a count written in Arabic-Indic or
fullwidth digits as a number, where the rest of the stack accepts ASCII
digits only. SimpleEventStreamBuilder accepts a negative value that
RealValueFileEventStream rejects.
… values

FrequencyDictionaryLoader checks the count column with
StringUtil.endOfAsciiDigits after an optional sign, so a count in the
digits of another script, a decimal point, or an exponent is reported
as malformed with the column named, and an overflowing count as out of
range. The spellcheck chapter of the manual states the rule.

SimpleEventStreamBuilder rejects a negative value with the context
named, as RealValueFileEventStream does.
… the mode

RealValueFileEventStream.parseEvent and SimpleEventStreamBuilder.add
split their fields with StringUtil.splitOnUnicodeWhitespace, so the
same file gives the same events in each JVM and the shared
WhitespaceTokenizer instance is not involved. A null line or event is
an IllegalArgumentException, and the builder reports format errors as
IllegalArgumentException as well.

The tests pin U+0085, U+2028, U+3000, U+001C, U+200B and a
supplementary character, show that setKeepNewLines on the shared
tokenizer has no effect, and the maxent stream test keeps one smoke
test instead of a copy of the parsing matrix. The manual paragraph
covers the real-valued streams only.
XML attribute-value normalization has already turned tabs and line
breaks into spaces, so identifier lists and region anchors split on
runs of spaces through one package helper instead of the shared
WhitespaceTokenizer instance; a tab or no-break space kept by a
character reference is malformed. An identifier number that does not
fit an int is reported with the identifier named, the word parser keeps
the cause and message of a failure, and the three parser tests share
one SAX helper.
…ites

The Leipzig language code check reads the first three characters of
the file name in place and is a private instance method; its tests use
real Leipzig file names. The Morfologik file name helper is an instance
method and its test rows are the reachable .info names. The frequency
dictionary loader skips lines of Unicode whitespace independent of the
mode, splits columns in a private instance method with named
separators, and the two loader tests from the serialization test moved
next to the other loader tests with one shared text resource helper.
The spellcheck chapter states what strip() removes at the line ends.
@krickert
krickert force-pushed the OPENNLP-1933-string-regex-calls branch from fff4466 to 9bd19f5 Compare September 16, 2026 06:09
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