Skip to content

OPENNLP-1934: Add explicit tokenizer character policies - #1281

Closed
krickert wants to merge 2 commits into
apache:mainfrom
ai-pipestream:OPENNLP-1934-tokenizer-alphanumeric
Closed

krickert wants to merge 2 commits into
apache:mainfrom
ai-pipestream:OPENNLP-1934-tokenizer-alphanumeric

Conversation

@krickert

@krickert krickert commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Add an immutable TokenizerCharacterPolicy that checks complete candidates against explicit letter, digit and mark sets using a code-point scan. Combining marks must follow letters, supplementary characters are supported, and malformed UTF-16 is rejected. The factory validates category sets and provides an ASCII preset. Tests and the manual cover the contract.

This PR introduces the policy API only. Connecting it to tokenizer training, inference and model persistence, and adding Unicode presets, are separate follow-ups. It does not yet replace the existing tokenizer regex configuration.

Validation on main at c36ff62f7:

  • Affected runtime reactor: 3,455 tests reported, zero failures or errors, five skips. Includes 17 policy tests and 19 integration tests.
  • Javadoc and HTML/PDF manual builds pass. Existing Javadoc and PDF font/glyph warnings remain.
  • Original test-first baseline failed compilation because the new API did not exist.

OPENNLP-1934

@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

Request changes. The character-set optimization and null-pattern handling introduce regressions. The model manifest format remains unchanged.

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

if (!isLiteral(to) || to < c) {
return null;
}
characters.set(c, to + 1);

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.

A literal range spanning D800–DFFF includes surrogate code units in the lookup. It consequently accepts emoji that the original regex rejects. Please fall back to regex when a range intersects the surrogate block.

Pattern alphaNumeric, TokenContextGenerator cg) {
super(tokenSamples);
this.alphaNumeric = alphaNumeric;
this.alphaNumeric = AlphaNumericCheck.of(alphaNumeric);

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.

This now rejects a documented null pattern during construction, including when skipAlphaNumerics is false. That configuration previously worked. Please preserve it and add a regression test.

@krickert
krickert force-pushed the OPENNLP-1934-tokenizer-alphanumeric branch from 1fc8ebe to 586adc0 Compare September 15, 2026 16:00
@rzo1

rzo1 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Here are some additional comments. The built-in patterns check out: all 9 give the same result as the regex on every BMP character, every supplementary code point, unpaired surrogates and 200k random tokens each, and tokenization of the released en/de 1.3.0 models is unchanged. Def. still needs the tokenizer eval build, since TokSpanEventStream builds the training events.

Blocking

  1. AlphaNumericCheck.java:157-165. The set lookup skips the surrogate block, so it gives a different answer from the regex engine for unpaired surrogates. There is no reason for this: test() already iterates by code point and rejects anything above U+FFFF. Setting the full range (characters.set(from, to + 1)) matches matches() exactly (fuzz over 20,001 set-classified random patterns: 0 differences). As it stands, a custom model with a pattern like ^[A-�]+$ tokenizes differently after the upgrade; TokSpanEventStream produces 1585 events old vs 1592 new on token.train plus one line with a lone surrogate. Drop SURROGATE_MIN/SURROGATE_MAX, the "one exception" wording (Javadoc :30-32, :149-151, tokenizer.xml:312-313) and UNPAIRED_SURROGATES, and turn testSurrogateSpanningRangeDiffersFromEngineOnlyOnUnpairedSurrogates into a plain agree-with-regex test.

  2. TokenizerME.java:158. AlphaNumericCheck.of(factory.getAlphaNumericPattern()) now throws IAE in the constructor when a TokenizerFactory subclass returns null, even with the optimization off. Before, it tokenized fine with the optimization off and threw NPE with it on. TokSpanEventStream maps null to the default instead. Treat null the same way in both places (or only build the check when useAlphaNumericOptimization is set), and add a test.

  3. PR description. "the result equals pattern.matcher(token).matches() in all cases" is left over from the first commit and contradicts the code and docs (item 1). The description also does not mention either null change: item 2, and TokSpanEventStream.java:62-63, where null with skipAlphaNumerics=true used to NPE and now means DEFAULT_ALPHANUMERIC (a bug fix, but a behavior change). List all of them.

  4. Eval build. TokSpanEventStream builds tokenizer training events. Run ArvoresDeitadasEval tokenizer cross-validation (pt, alphaNumOpt=true, null pattern, so the Portuguese class now goes through the set lookup) and the SourceForgeModelEval tokenizer before merge, and list them in the PR as done.

Minor

  • AlphaNumericCheck.java:44-45, :53-61. Two mutually exclusive nullable fields, with pattern nulled out when a set exists. Keep pattern always non-null and make only characters nullable; then the private constructor plus of() can become one validating constructor.
  • AlphaNumericCheck.java:107. isCharacterSet() exists only for tests. Say so in the Javadoc, or assert the behavior through test().
  • AlphaNumericCheck.java:56-57, :161-162 vs :141. } / else { on separate lines next to } else {. Pick one style.
  • AlphaNumericCheck (whole class). opennlp.tools.util.normalizer.CodePointSet in opennlp-api is already an immutable BitSet-backed code point set with of/ofRange/union/contains. Reuse it instead of a raw BitSet, or say why not.
  • AlphaNumericCheckTest.java:35-62. The built-in comparison uses a 54-token table, which misses most of the BMP, and "en" and "xx" both resolve to the default. Loop over all chars 0..0xFFFF per language instead; it is cheaper and exhaustive.
  • AlphaNumericCheckTest.java:95-101. Add reject rows ^[\\-a]+$, ^[a-z]++$, ^[a-z]+?$, ^[a-z]{1,}$, ^[a^b]+$, ^[a&b]+$, and ^[a-z]+$ with UNICODE_CHARACTER_CLASS.
  • TokSpanEventStream.java:77 vs :63. The same default is spelled new Factory().getAlphanumeric(null) and Factory.DEFAULT_ALPHANUMERIC. Use the constant in both places.
  • TokSpanEventStreamTest.java:72. testNullPatternMeansDefaultPattern does not pin "default": "now" matches any plausible class, only outcomes are compared, and the false case never touches the pattern. Use a token only the ASCII default rejects (café), compare Event.toString(), and drop the false case. Also add a stream-level surrogate case and a TokenizerME null-factory case.
  • TokSpanEventStreamTest.java:47/:92, :86, :89. Duplicated sample literal (declare a constant), magic 2, and readOutcomes does not need to be static.
  • tokenizer.xml:303-304. "a null pattern means the default" is wrong for the factory and the CLI, where null means the built-in pattern for the model language. Only TokSpanEventStream uses ^[A-Za-z0-9]+$.
  • tokenizer.xml:305-306, :312. Only the pattern text is stored in the manifest (TokenizerFactory.createManifestEntries), so a CASE_INSENSITIVE flag is lost when the model is loaded. Say that, instead of listing "a flag" as if it persists.
  • tokenizer.xml:308-317. The set-lookup vs regex-engine paragraph is implementation detail. If item 1 is fixed, users can't see a difference; drop it or cut it to the \p{L} hint.
  • tokenizer.xml:301-303. "extend the class with the accented letters of that language": the Polish class in lang/Factory.java lacks lowercase ł (existing bug, separate JIRA), and Dutch ij is a ligature. Soften the wording.
  • PR description / title. "What changes is the hot path" has no numbers, and no shipped model uses this path (en/de/ru 1.3.0 ship useAlphaNumericOptimization=false). Add a JMH figure or drop the claim. Suggested title: "OPENNLP-1934: Speed up the tokenizer alphanumeric optimization for plain character-class patterns".

Verified: thread safety is fine (final fields, BitSet read-only after construction), no public API or serialization change, and a model with no pattern still resolves to the language's built-in class, as before.

@rzo1

rzo1 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Correction to my previous comment: the tokenizer.xml:301-303 point claimed the Polish class in lang/Factory.java lacks lowercase ł. That was wrong: POLISH contains ł (U+0142), and łódź matches. Please disregard that part. The Dutch ij ligature remark still stands.

@krickert
krickert force-pushed the OPENNLP-1934-tokenizer-alphanumeric branch from 980deac to 202309b Compare September 16, 2026 00:43
@krickert krickert changed the title OPENNLP-1934: Evaluate the tokenizer alphanumeric pattern as a character set OPENNLP-1934: Speed up the tokenizer alphanumeric optimization for plain character-class patterns Sep 16, 2026
@krickert
krickert force-pushed the OPENNLP-1934-tokenizer-alphanumeric branch 2 times, most recently from f84acf8 to 9c33819 Compare September 16, 2026 06:09
@rzo1 rzo1 changed the title OPENNLP-1934: Speed up the tokenizer alphanumeric optimization for plain character-class patterns OPENNLP-1934: Regex removal (6/8): Check tokenizer alphanumeric patterns as a code point set Sep 18, 2026
@rzo1 rzo1 changed the title OPENNLP-1934: Regex removal (6/8): Check tokenizer alphanumeric patterns as a code point set OPENNLP-1934: Regex removal (6/10): Check tokenizer alphanumeric patterns as a code point set Sep 18, 2026
@rzo1

rzo1 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Please rebase onto main. #1275 is closed and CompatibilityMode is in main via #1312; since b3c79c1 this PR no longer uses it, so only the OPENNLP-1934 commits are needed. Please also update the description ("Based on #1275" and the LEGACY surrogate note).

@krickert
krickert force-pushed the OPENNLP-1934-tokenizer-alphanumeric branch from b3c79c1 to 9ca624b Compare September 18, 2026 21:38
…ter set

The alphanumeric pattern stays a Pattern in TokenizerFactory and
lang.Factory: it is written to the model manifest as a regex string and
read back with Pattern.compile, so the type is part of the model contract.

A package-private AlphaNumericCheck reads a pattern of the form ^[...]+$
with a class of plain characters and simple ranges, the form of every
built-in language pattern, into a CodePointSet and tests tokens by lookup.
Any other pattern, and any pattern compiled with flags, is evaluated by the
regular expression engine. TokenizerME and TokSpanEventStream hold the
check instead of the Pattern and build it only when the optimization is on.

A token is alphanumeric when every character in it is in the class the
pattern defines. An unpaired surrogate is not a character, so a token that
is not well-formed UTF-16 is not alphanumeric under any pattern: the
check walks the token by code point before either evaluation, and such a
token goes to the model at inference and keeps its events in training. The
regular expression engine's own acceptance of a lone surrogate inside a
range is an artifact of how it walks code units and is not copied.

Behavior changes:
- TokenizerME with the optimization on and a factory that returns
  null from getAlphaNumericPattern() threw NullPointerException at the
  first token; it now uses Factory.DEFAULT_ALPHANUMERIC.
- TokSpanEventStream with a null pattern and skipAlphaNumerics=true threw
  NullPointerException; null now means Factory.DEFAULT_ALPHANUMERIC, as
  the constructor Javadoc stated.
- A custom pattern that accepts a lone surrogate code unit (a range over
  the surrogate block, a negated class, \p{Cs}, or .) let a malformed
  token skip the model and lose its training events. No built-in pattern
  accepts one, so well-formed input is unchanged.

Tests use the regex as the oracle for well-formed characters: the set
lookup is compared with it on every code point from U+0000 to U+10FFFF
for each built-in language pattern and for 21 set-eligible custom
patterns. They pin 17 pattern shapes to the engine and cover whole-token,
flag, null, and malformed-token cases, events from TokSpanEventStream,
and context counts in TokenizerME. The manual adds
an "Alphanumeric optimization" section in the tokenizer chapter.

Red evidence, tests in place: with the null substitution removed,
TokSpanEventStreamTest.testNullPatternMeansAsciiDefault and
TokenizerMETest.testNullAlphaNumericPatternMeansAsciiDefault[optimization
on] error with "pattern must not be null"; with the surrogate check
removed, 13 of 131 targeted cases fail (10 in AlphaNumericCheckTest, 2 in
TokSpanEventStreamTest, 1 in TokenizerMETest). All pass with both.

Eval: ArvoresDeitadasEval tokenizer cross-validation (pt, optimization
on, five trainer variants) at the pinned F-measures, and
SourceForgeModelEval.evalTokenModel with the output hash unchanged.
@krickert
krickert force-pushed the OPENNLP-1934-tokenizer-alphanumeric branch from 9ca624b to 263999e Compare September 19, 2026 20:05
Prepare the alphanumeric checker regardless of the stored model setting so subclasses can enable or disable the shortcut safely. Reject null checker tokens with IllegalArgumentException and retain malformed Unicode handling.

Four regressions failed before the fix. Full runtime verification, 156 affected tokenizer tests, Portuguese GIS cross-validation, the SourceForge tokenizer evaluation, Javadoc and the manual build pass.
@krickert

Copy link
Copy Markdown
Contributor Author

Keep this in draft, it's incomplete

@krickert

Copy link
Copy Markdown
Contributor Author

This entire PR is cheating and it isn't performant. Closing to open additional PRs to fix it right.

@krickert krickert closed this Sep 19, 2026
@krickert

Copy link
Copy Markdown
Contributor Author

I'll reopen this when it's ready. This PR still had a large regex surface, but I'm planning out how to properly fix this by looking at the other algorithms out there and NLP packages that do it right.

We'll take the best of all world.. It'll be about (SMALL!) 9 PRs total - promise to be human measurable. Functionality will remain intact with each one, I recommend we try but I don't wanna burn out the review cycle - so I will work hard to ensure they are small and quickly reviewable.

Not my fault :) It's just the code surface and the profiler guiding me.

This doesn't change the other PRs, it's just missed areas I didn't see before.

@krickert krickert changed the title OPENNLP-1934: Regex removal (6/10): Check tokenizer alphanumeric patterns as a code point set OPENNLP-1934: Add explicit tokenizer character policies Sep 20, 2026
@krickert

Copy link
Copy Markdown
Contributor Author

Re-opened via #1325 - I did this because the ticket was getting confusing, #1325 is far smaller and easier to understand

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