Skip to content

OPENNLP-1931: Regex removal (3a/10): Read opennlp-dl vocab and config JSON with a strict scanner - #1277

Open
krickert wants to merge 20 commits into
apache:mainfrom
ai-pipestream:OPENNLP-1931-dl-json-scan
Open

krickert wants to merge 20 commits into
apache:mainfrom
ai-pipestream:OPENNLP-1931-dl-json-scan

Conversation

@krickert

@krickert krickert commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Replaces regex parsing in AbstractDL.loadJsonVocab and DocumentCategorizerConfig.fromJson with a shared JSON scanner.

  • JSON vocabularies must be a single object mapping tokens to non-negative integer IDs. Unsupported layouts, including tokenizer.json, are rejected with an error describing the expected format.
  • Configuration labels come from the top-level id2label object. Escaped keys and labels are decoded, including quotes, Unicode escapes, and braces within strings.
  • Malformed resource files surface as InvalidFormatException. Fractional, negative, and overflowing vocabulary IDs are rejected.
  • A leading byte order mark is ignored in JSON and plain-text vocabularies, addressing OPENNLP-1953.
  • Nested values are scanned without recursion. Escape searches are bounded to the current string. JsonScan is marked @Internal(since = "3.0.0").

The scanner accepts raw control characters inside strings and Python's NaN, Infinity, and -Infinity in skipped values. Configuration whitespace handling is independent of opennlp.whitespace.mode.

tokenizer.json support is deferred to OPENNLP-1988 and a separate PR. The document categorizer manual describes the supported formats and error behavior.

Local validation: 593 DL tests passed with no failures or skips; dependency suites passed. Javadoc generation and manual XML checks passed, with Javadoc warnings in unchanged files.

DocumentCategorizerDLEval passed all eight enabled tests with the nlptown BERT sentiment model, including automatic labels and concurrent inference. The GPU test is disabled and was skipped.

OPENNLP-1931

@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 API or parsing regression found. The old and new JSON implementations agreed across 60,000 generated cases.

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

@krickert
krickert force-pushed the OPENNLP-1931-dl-json-scan branch from 944ab39 to fed91ba Compare September 15, 2026 16:00
@rzo1

rzo1 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Here are some additional comments. Def. needs an eval build (DocumentCategorizerDLEval reads its label config through the changed fromJson). Compatibility on well-formed files is good: 26 real HF classification config.json files and several vocab.json files gave identical maps old vs new. The problems are hostile or slightly non-strict files, undocumented behavior changes, and the API surface.

Blocking

  1. JsonScan.java:291, :294, :331, :358. endOfValueendOfObject/endOfArray is recursive, so untrusted input throws StackOverflowError, not IAE. A 20 KB file of [ or {"a": + 5000×{"a": is enough, truncated input included, and the Error reaches the AbstractDL/DocumentCategorizerDL constructors. Skip nested values iteratively (depth counter plus a bracket-kind stack), or cap nesting (Jackson's default is 1000) and throw. Add deep and truncated-deep tests.

  2. DocumentCategorizerConfig.java:55-56. The whole config.json is now validated, so config files that loaded before throw, although only id2label is needed:

    • A UTF-8 BOM (Files.readString keeps U+FEFF): old {0=negative,1=positive}, new IAE offset 0 expected '{'. RFC 8259 §8.1 allows ignoring a BOM.
    • Python's json.dumps default output with NaN/Infinity in an unrelated member: old OK, new IAE expected a value.
    • A trailing comma or a // comment line: old OK, new IAE.

    Skip a leading U+FEFF and accept NaN/Infinity/-Infinity in skipped values (config.json is written by Python), or document the break in the release notes and JIRA. Pin the choice with tests.

  3. DocumentCategorizerConfig.java:56-63. Category names returned by getBestCategory/scoreMap change for existing models. It is a fix, but a silent one:

    • {"0":"négatif","1":"say \"hi\"","2":"x}y","3":"z"}: old {0=négatif, 1=say \} with labels 2 and 3 dropped, new {0=négatif, 1=say "hi", 2=x}y, 3=z}.
    • A config with id2label nested (e.g. text_config.id2label) used to give labels and now silently gives {}. Decide between an empty map and throwing, and test it.
  4. AbstractDL.java:499-547. tokenizer.json vocabulary support is a new feature inside a "regex removal" PR, and not mentioned in the title, description or JIRA. On bert-base-uncased the old map had 30524 entries including junk (type_id=1, max_input_chars_per_word=100), the new one has 30522; all-MiniLM-L6-v2 drops 6 junk keys (Fixed=128, …); roberta-base is unchanged. added_tokens absent from model.vocab are silently not entries. Split it into its own JIRA/PR, or at least describe it and document added_tokens.

  5. DocumentCategorizerDL.java:156, AbstractDL.java:91/:168. A malformed resource file now surfaces as unchecked IAE from constructors that declare IOException, so callers catching IOException no longer catch it. Before this PR a bad config never threw. Wrap at the file boundary (loadVocabFile, readCategoriesFromFile) into InvalidFormatException and add @throws.

  6. JsonScan.java:29-31, :61-62 vs :176-186, :283-289. The Javadoc says escapes follow RFC 8259 and document throws if the text "is malformed at any position". Not true: closingQuote doesn't check escapes, so {"a":["\q"]}, {"a":{"\q":1}} and {"a":"\q"} pass. Check escapes in closingQuote (one pass), or fix both Javadocs and pin the nested and array cases.

  7. JsonScan.java:37, :53, :88-165. New public API in the exported opennlp.dl package (6 static methods plus a public record with a public constructor) for 2 callers; @Internal doesn't stop it from being frozen. Arguments are not validated: members("{}", -1) and a hand-built Member(…, 5, 9) throw SIOOBE, member(null, …) and stringValue(text, null) throw NPE. Keep it package-private, move the id2label reading into opennlp.dl, or expose one narrow reader. Validate with IAE and add @throws.

  8. PR description. It says the opposite of the head: "Behavior is unchanged, including the quirks of the old patterns … a decimal fraction gives its integer prefix, a negative id is skipped, the id2label object is cut at the first closing brace", with "400,000 generated inputs with zero differences". Commit ae1aa71 replaced that; 1.5 and -1 now throw (LoadVocabTest:165-170), braces inside labels are kept, and the test counts are from the old design. Rewrite it to list every change above. Suggested title: "OPENNLP-1931: Reject malformed JSON vocabulary and config files in opennlp-dl; read the vocabulary of tokenizer.json".

Minor

  • DocumentCategorizerConfig.java:55. StringUtil.isBlank depends on the whitespace mode, while JsonScan uses RFC whitespace, so a single U+001C gives {} under legacy and IAE under unicode (U+0085 the other way round). Use JsonScan.skipWhitespace(json, 0) == json.length().
  • DocumentCategorizerConfig.java:51-53. fromJson(null) changed from NPE to IAE on a public record method. Mention it.
  • NameFinderDL.java:130-135/153-158, SentenceVectorsDL.java:72-75/90-93, DocumentCategorizerDL.java:119-122. These constructors also go through loadVocabFile but didn't get the new @throws. Align all public DL constructors.
  • AbstractDL.java:532-547. A Unigram tokenizer.json (model.vocab is an array) falls back to the top-level object and fails with Value of "version" must be a non-negative integer: "1.0" (papluca/xlm-roberta-base-language-detection). Throw a clear "unsupported tokenizer.json model.vocab layout" error and test it.
  • AbstractDL.java:187 (pre-existing). A vocab.json with a BOM fails startsWith("{") and is silently read as plain text with wrong ids. Worth fixing here, since the new Javadoc at :160 promises "first non-whitespace character is a brace".
  • JsonScan.java:299. new String[] {TRUE, FALSE, NULL} per literal. Declare a constant.
  • JsonScan.java:460-468, :222-225. Inline hex ranges and magic 6/4/2/20. Use HexFormat.isHexDigit/HexFormat.fromHexDigits and name the lengths.
  • JsonScan.java:327-329. afterColon returns -1 and the error path calls skipWhitespace again. Call expect(text, colon, ':') inline.
  • JsonScan.java:198-199. unescape allocates a StringBuilder per key even without a backslash. Fast path via indexOf('\\', start) >= end.
  • JsonScan.java:32-33. "so a label wrapped over two lines still reads" is rationale; state the contract, and say U+0000-U+001F are also accepted in keys ({"a<U+0001>":1} passes).
  • JsonScan.java:176, :198, :244, :261, :278. Package-private only for tests. Make them private and test through document/members.
  • JsonScanTest.java:97-101. One test covers null for two methods. Split or parameterize it.
  • JsonScanTest.java:37-39, :124-126, :195-197, :222-224. Remove the banner comments.
  • Tests. Add deep nesting, BOM, NaN/Infinity, bad escapes in nested keys and arrays, -01/-0 inside a document, negative offsets, a Unigram tokenizer.json, nested id2label, and ensure_ascii labels.
  • doccat.xml:176-198. Verbose parser internals; "a line break inside a token is kept as content" contradicts RFC 8259. Cut to the user-visible rules: accepted layouts, ids must be non-negative ints, malformed files throw.
  • namefinder.xml:153-156. Re-wraps an unchanged sentence. Revert.

Verified:

  • JsonScan vs jackson-core 2.20.1 on 1M generated inputs: 0 differences on valid JSON; numbers, literals, trailing commas, truncation and whitespace all match.
  • Scanning is linear (100 MB vocab in 384 ms).
  • No JSON library is on the opennlp-dl classpath, so a small scanner is defensible if items 1, 6 and 7 are fixed.

1 similar comment
@rzo1

rzo1 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Here are some additional comments. Def. needs an eval build (DocumentCategorizerDLEval reads its label config through the changed fromJson). Compatibility on well-formed files is good: 26 real HF classification config.json files and several vocab.json files gave identical maps old vs new. The problems are hostile or slightly non-strict files, undocumented behavior changes, and the API surface.

Blocking

  1. JsonScan.java:291, :294, :331, :358. endOfValueendOfObject/endOfArray is recursive, so untrusted input throws StackOverflowError, not IAE. A 20 KB file of [ or {"a": + 5000×{"a": is enough, truncated input included, and the Error reaches the AbstractDL/DocumentCategorizerDL constructors. Skip nested values iteratively (depth counter plus a bracket-kind stack), or cap nesting (Jackson's default is 1000) and throw. Add deep and truncated-deep tests.

  2. DocumentCategorizerConfig.java:55-56. The whole config.json is now validated, so config files that loaded before throw, although only id2label is needed:

    • A UTF-8 BOM (Files.readString keeps U+FEFF): old {0=negative,1=positive}, new IAE offset 0 expected '{'. RFC 8259 §8.1 allows ignoring a BOM.
    • Python's json.dumps default output with NaN/Infinity in an unrelated member: old OK, new IAE expected a value.
    • A trailing comma or a // comment line: old OK, new IAE.

    Skip a leading U+FEFF and accept NaN/Infinity/-Infinity in skipped values (config.json is written by Python), or document the break in the release notes and JIRA. Pin the choice with tests.

  3. DocumentCategorizerConfig.java:56-63. Category names returned by getBestCategory/scoreMap change for existing models. It is a fix, but a silent one:

    • {"0":"négatif","1":"say \"hi\"","2":"x}y","3":"z"}: old {0=négatif, 1=say \} with labels 2 and 3 dropped, new {0=négatif, 1=say "hi", 2=x}y, 3=z}.
    • A config with id2label nested (e.g. text_config.id2label) used to give labels and now silently gives {}. Decide between an empty map and throwing, and test it.
  4. AbstractDL.java:499-547. tokenizer.json vocabulary support is a new feature inside a "regex removal" PR, and not mentioned in the title, description or JIRA. On bert-base-uncased the old map had 30524 entries including junk (type_id=1, max_input_chars_per_word=100), the new one has 30522; all-MiniLM-L6-v2 drops 6 junk keys (Fixed=128, …); roberta-base is unchanged. added_tokens absent from model.vocab are silently not entries. Split it into its own JIRA/PR, or at least describe it and document added_tokens.

  5. DocumentCategorizerDL.java:156, AbstractDL.java:91/:168. A malformed resource file now surfaces as unchecked IAE from constructors that declare IOException, so callers catching IOException no longer catch it. Before this PR a bad config never threw. Wrap at the file boundary (loadVocabFile, readCategoriesFromFile) into InvalidFormatException and add @throws.

  6. JsonScan.java:29-31, :61-62 vs :176-186, :283-289. The Javadoc says escapes follow RFC 8259 and document throws if the text "is malformed at any position". Not true: closingQuote doesn't check escapes, so {"a":["\q"]}, {"a":{"\q":1}} and {"a":"\q"} pass. Check escapes in closingQuote (one pass), or fix both Javadocs and pin the nested and array cases.

  7. JsonScan.java:37, :53, :88-165. New public API in the exported opennlp.dl package (6 static methods plus a public record with a public constructor) for 2 callers; @Internal doesn't stop it from being frozen. Arguments are not validated: members("{}", -1) and a hand-built Member(…, 5, 9) throw SIOOBE, member(null, …) and stringValue(text, null) throw NPE. Keep it package-private, move the id2label reading into opennlp.dl, or expose one narrow reader. Validate with IAE and add @throws.

  8. PR description. It says the opposite of the head: "Behavior is unchanged, including the quirks of the old patterns … a decimal fraction gives its integer prefix, a negative id is skipped, the id2label object is cut at the first closing brace", with "400,000 generated inputs with zero differences". Commit ae1aa71 replaced that; 1.5 and -1 now throw (LoadVocabTest:165-170), braces inside labels are kept, and the test counts are from the old design. Rewrite it to list every change above. Suggested title: "OPENNLP-1931: Reject malformed JSON vocabulary and config files in opennlp-dl; read the vocabulary of tokenizer.json".

Minor

  • DocumentCategorizerConfig.java:55. StringUtil.isBlank depends on the whitespace mode, while JsonScan uses RFC whitespace, so a single U+001C gives {} under legacy and IAE under unicode (U+0085 the other way round). Use JsonScan.skipWhitespace(json, 0) == json.length().
  • DocumentCategorizerConfig.java:51-53. fromJson(null) changed from NPE to IAE on a public record method. Mention it.
  • NameFinderDL.java:130-135/153-158, SentenceVectorsDL.java:72-75/90-93, DocumentCategorizerDL.java:119-122. These constructors also go through loadVocabFile but didn't get the new @throws. Align all public DL constructors.
  • AbstractDL.java:532-547. A Unigram tokenizer.json (model.vocab is an array) falls back to the top-level object and fails with Value of "version" must be a non-negative integer: "1.0" (papluca/xlm-roberta-base-language-detection). Throw a clear "unsupported tokenizer.json model.vocab layout" error and test it.
  • AbstractDL.java:187 (pre-existing). A vocab.json with a BOM fails startsWith("{") and is silently read as plain text with wrong ids. Worth fixing here, since the new Javadoc at :160 promises "first non-whitespace character is a brace".
  • JsonScan.java:299. new String[] {TRUE, FALSE, NULL} per literal. Declare a constant.
  • JsonScan.java:460-468, :222-225. Inline hex ranges and magic 6/4/2/20. Use HexFormat.isHexDigit/HexFormat.fromHexDigits and name the lengths.
  • JsonScan.java:327-329. afterColon returns -1 and the error path calls skipWhitespace again. Call expect(text, colon, ':') inline.
  • JsonScan.java:198-199. unescape allocates a StringBuilder per key even without a backslash. Fast path via indexOf('\\', start) >= end.
  • JsonScan.java:32-33. "so a label wrapped over two lines still reads" is rationale; state the contract, and say U+0000-U+001F are also accepted in keys ({"a<U+0001>":1} passes).
  • JsonScan.java:176, :198, :244, :261, :278. Package-private only for tests. Make them private and test through document/members.
  • JsonScanTest.java:97-101. One test covers null for two methods. Split or parameterize it.
  • JsonScanTest.java:37-39, :124-126, :195-197, :222-224. Remove the banner comments.
  • Tests. Add deep nesting, BOM, NaN/Infinity, bad escapes in nested keys and arrays, -01/-0 inside a document, negative offsets, a Unigram tokenizer.json, nested id2label, and ensure_ascii labels.
  • doccat.xml:176-198. Verbose parser internals; "a line break inside a token is kept as content" contradicts RFC 8259. Cut to the user-visible rules: accepted layouts, ids must be non-negative ints, malformed files throw.
  • namefinder.xml:153-156. Re-wraps an unchanged sentence. Revert.

Verified:

  • JsonScan vs jackson-core 2.20.1 on 1M generated inputs: 0 differences on valid JSON; numbers, literals, trailing commas, truncation and whitespace all match.
  • Scanning is linear (100 MB vocab in 384 ms).
  • No JSON library is on the opennlp-dl classpath, so a small scanner is defensible if items 1, 6 and 7 are fixed.

@rzo1

rzo1 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Follow-up: the pre-existing BOM problem in AbstractDL.loadVocabFile is filed as OPENNLP-1953. It is related to the BOM point for config.json in my review.

@krickert krickert changed the title OPENNLP-1931: Scan the opennlp-dl JSON vocabulary and id2label without patterns OPENNLP-1931: Reject malformed JSON vocabulary and config files in opennlp-dl; read the vocabulary of tokenizer.json Sep 16, 2026
@krickert
krickert force-pushed the OPENNLP-1931-dl-json-scan branch 2 times, most recently from 8699305 to a4ed75a Compare September 16, 2026 06:09
@rzo1

rzo1 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

CI is red.

@rzo1 rzo1 changed the title OPENNLP-1931: Reject malformed JSON vocabulary and config files in opennlp-dl; read the vocabulary of tokenizer.json OPENNLP-1931: Regex removal (3/8): Read opennlp-dl vocab and config JSON with a strict scanner Sep 18, 2026
@rzo1 rzo1 changed the title OPENNLP-1931: Regex removal (3/8): Read opennlp-dl vocab and config JSON with a strict scanner OPENNLP-1931: Regex removal (3/10): Read opennlp-dl vocab and config JSON with a strict scanner Sep 18, 2026
@krickert
krickert force-pushed the OPENNLP-1931-dl-json-scan branch from 886b1e8 to f632f55 Compare September 18, 2026 19:12
The deep-learning module reads two HuggingFace-shaped JSON files, the
vocabulary and the model configuration, with regular expressions that
look for a string literal, a colon with optional ASCII whitespace, and
then either a digit run or another string literal. JsonScan collects
the offset helpers those two readers need: the closing quote of a
literal honoring backslash escapes, the closing quote of a literal that
must stay on one line, a colon surrounded by whitespace, a whitespace
run, and a digit run. The class is public so the doccat subpackage can
reach it and is marked Internal. The tests cover accept and reject
sides of every helper, including non-ASCII spaces and digits, the five
line terminators, and supplementary-plane characters.
AbstractDL.loadJsonVocab used a find() loop over a pattern matching a
string literal with backslash escapes, optional ASCII whitespace around
a colon, and a run of ASCII digits, anywhere in the text. The loop now
walks the text with JsonScan: from each quote it finds the closing
quote, the colon, and the digit run; on success it records the entry
and resumes after the digits, otherwise it resumes at the character
after the quote exactly like the matcher did, so a quote inside a
skipped literal can open the next candidate. The method is now
package-private so tests feed it text directly. The added parameterized
tests pin the odd inputs: a value that is not an integer is skipped, a
fractional value keeps its integer prefix, escaped and unicode-escaped
keys, keys spanning a line, a backslash before a line terminator, an
empty key, whitespace and newlines around the colon, a non-ASCII space
after the colon, and a later entry overwriting an earlier one.
DocumentCategorizerConfig.fromJson used a DOTALL pattern to cut the
text between the brace after "id2label" and the first closing brace,
and a second pattern to pull "key": "value" pairs out of that text with
the key running to the next quote and the value, lazily, to the next
quote on the same line. Both are now cursor scans over JsonScan: the
key literal is located with indexOf and retried at the next occurrence
when no colon and brace follow it, the content ends at the first
closing brace even when that brace belongs to a nested value, and the
entry loop resumes after a matched value or at the character after a
quote that opened no entry. The parameterized tests pin the nested
brace cut, a brace inside a value, a missing or non-object id2label,
whitespace and newlines around colons, an escaped quote inside a
value, a value spanning a line, an empty key, a numeric value, an
overlapping key literal, and supplementary-plane keys and values.
Rebase onto ccea670: skipWhitespace now takes only space, tab, LF, and
CR (vertical tab and form feed stop the run, as JSON requires); endOfDigits
and the line-terminator check delegate to the shared StringUtil versions.
The affected unit cases now pin the four-character set. Verified: dl suite
129 run, 0 failures.
…g the old patterns

The scans that replaced the regular expressions had kept their gaps: a
decimal value yielded an integer prefix, a negative id was skipped, the
id2label object was cut at the first closing brace inside a label, a key
could span a line while a label could not, and a "key": digits entry was
taken as a token wherever it appeared in the file.

JsonScan is now a small cursor-based JSON parser that follows RFC 8259
for structure, whitespace and string escapes, reports the offset of a
malformed document, and returns an object as a list of members. It uses
the shared StringUtil.isAsciiDigit and endOfAsciiDigits.

- A JSON token file is one object that maps tokens to non-negative
  integer ids; any other value, a missing comma or trailing text is an
  IllegalArgumentException that names the token or the offset.
- id2label is taken from the top-level object of the configuration;
  labels are decoded from their escapes, a brace inside a label does not
  end the object, and a value that is not a string is rejected.
- A control character inside a string is kept as content, so a label
  wrapped over 2 lines still parses.
- fromJson(null) throws IllegalArgumentException like the other parsers.

The manual describes both token file layouts and the configuration
rules in the document categorizer and name finder chapters. Tests cover
the accept and reject side of each parser, escapes and surrogate pairs,
nested values, duplicate keys, and the RFC 8259 whitespace set.
…vocab (red)

The old pattern took a tokenizer.json file as well, with the added_tokens
ids and vocab_size landing in the map. The strict parser rejects the
file. The test fixes the intended result: the entries of model.vocab,
and no other member, form the token map.
…reen)

A top-level object without integer members that has a model object with
a vocab object is the tokenizer.json layout; the tokens of model.vocab
form the token map under the same strict rules. Any other layout keeps
the current error. The manual names the layout in the document
categorizer chapter.
A vocab.json file that starts with U+FEFF is read as plain text with
the full content as one token, and a config.json with the mark is
rejected as malformed at offset 0. RFC 8259 section 8.1 lets a parser
ignore the mark, so JsonScan.document and the vocab file reader must
skip it, and reject it at any later offset.
…(green)

JsonScan.document skips U+FEFF as the first character, per RFC 8259
section 8.1, and reports it as malformed at any later offset. The vocab
file reader drops the mark before it looks for the opening brace, so a
vocab.json written with a mark is read as JSON, and a plain vocab.txt
with a mark gives the first token without it. The file is read once
and split into lines from memory.
…layouts

JsonScan: the offset and reason in the message for text cut off after
each token of a member, for content after the object, for separators
and non-JSON whitespace, and for bad escapes; values nested 500 levels
deep are skipped in full and rejected at the end when cut off; CR and
CRLF outside strings are whitespace and inside strings are content;
keys written with escapes decode.

Vocabulary: tokenizer.json with CRLF or no whitespace, added_tokens ids
that collide with vocab ids before or after the model, an empty vocab
object, vocab objects at other depths, a later model, vocab, or token
winning, member names written as escapes, ids that do not fit into an
int naming the token, leading zeros, each proper prefix of a
tokenizer.json, content after it, and files with CRLF in both formats.

Configuration: CRLF layouts, id2label written with an escape, nested
members around it, labels with line breaks, each proper prefix of a
config, and content after it, with the offset in the message.
The manual states that a byte order mark at the start of a vocab or
configuration file is skipped in both layouts, as RFC 8259 allows.
U+FEFF appeared in the test sources as a raw character, which no
editor shows. It is now written as a Unicode escape.
…uration (red)

A configuration text that is only the mark, or the mark followed by
whitespace, is rejected as malformed at offset 1. Since the mark is
skipped as content, such text must read as blank: a configuration
without labels. A mark at any later offset is still malformed.
DocumentCategorizerConfig.fromJson drops one leading U+FEFF before it
tests for blank text, so a file with the mark and whitespace is a
configuration without labels, the same as an empty file. JsonScan
reports a mark at any later offset as before.
…s, and file errors (red)

Deeply nested values must be skipped without a stack overflow, a bad
escape inside a nested string or an array must be rejected, NaN and the
infinities that Python writes must be skipped in members that are not
read, offsets and members outside the text must be rejected with an
IllegalArgumentException, a malformed vocabulary file must surface as
InvalidFormatException, a Unigram tokenizer.json must be named as an
unsupported layout, and a lone control or space character is not blank
configuration text.
…rrow the JSON API (green)

JsonScan skips objects and arrays with an explicit stack, so a deeply
nested or truncated file is rejected with an IllegalArgumentException
that names the offset instead of a StackOverflowError. Each string is
checked for bad escapes when it is passed, in keys and values at any
depth. NaN, Infinity, and -Infinity are accepted where a value is
skipped, as Python's json module writes them, and rejected where a
value is read.

The class exposes one reader, stringObject, which DocumentCategorizerConfig
uses for id2label; the other members are package-private, arguments are
validated, and Member checks the range it is given. Blank configuration
text is JSON whitespace after an optional byte order mark, so it no
longer depends on the whitespace mode.

A malformed token or configuration file is reported as
InvalidFormatException from loadVocabFile and readCategories, so the
constructors that declare IOException keep that contract. A Unigram
tokenizer.json, in which model.vocab is a list, is rejected with a
message that names the layout. HexFormat decodes the \u escapes, the
keyword array and the key names are constants, and unescape returns
the text unchanged when there is no backslash in it.
The vocabulary and configuration paragraphs list the accepted layouts,
the integer id rule, the byte order mark, the non-finite values, the
Unigram exclusion, and the InvalidFormatException, without parser
internals. The name finder chapter is back to the base text.
@krickert
krickert force-pushed the OPENNLP-1931-dl-json-scan branch from f632f55 to ed9dd7e Compare September 18, 2026 21:16
@rzo1

rzo1 commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Thanks for the update. CI is green now, and the description is refreshed. I have three points on the code.

1. Please move the tokenizer.json support to its own issue and PR

Doing it properly needs more than this PR should carry. AbstractDL always builds a WordpieceEncoder (AbstractDL.java:111, :142), yet the Javadoc (:541) and the Unigram error message (:592) say BPE models are supported, and LoadVocabTest:259 loads a "type": "BPE" file. A RoBERTa tokenizer.json loads without error and then gets WordPiece over byte-level BPE tokens, so predictions go wrong without any error. The support needs a model.type == "WordPiece" gate, and possibly continuing_subword_prefix and the normalizer's lowercase flag as well. That's a feature of its own, not regex removal.

For this PR, please:

  • drop the tokenizer.json commits (912b434d, c1f98f5f) and the tokenizer.json cases from 16c40928;
  • remove the tokenizer.json sentences from doccat.xml and from the description;
  • have the rejection of a tokenizer.json say what went wrong. At the moment it would read like "version is not a non-negative integer". Something like "expected one object mapping tokens to integer ids, as in vocab.json" would be clearer.

Nothing is released between the two PRs, since main is 3.0.0-SNAPSHOT, so if the follow-up lands before 3.0.0 no user ever sees the gap. Please file an issue for it.

2. Mark JsonScan as @Internal

JsonScan is public only because DocumentCategorizerConfig in opennlp.dl.doccat calls stringObject. opennlp-dl has no module-info, so the class becomes visible API as soon as it ships. Please annotate it with @Internal(since = "3.0.0") (opennlp.tools.commons.Internal).

3. Reference OPENNLP-1953

The byte order mark fix in loadVocabFile is OPENNLP-1953. Please mention it in those commits or in the squash message so the issue can be closed when this merges.

The eval build (DocumentCategorizerDLEval) is still needed before merge.

Defer tokenizer.json support to OPENNLP-1988 and reject its layouts with
an error describing the expected vocabulary format. Mark JsonScan internal
and bound each escape search to the current string.

Retain the OPENNLP-1953 leading BOM fix for JSON and plain-text vocabularies.

Validation: the four new tokenizer rejection cases failed before the change
and pass afterward. All 593 DL tests and dependency suites pass. Javadoc
and manual XML checks pass. DocumentCategorizerDLEval passes eight enabled
tests; its disabled GPU test is skipped. The 100,000-entry scanner probe
improves from about 1,468 ms to 4 ms with the bounded search.
@krickert
krickert marked this pull request as ready for review September 19, 2026 17:55
@krickert krickert changed the title OPENNLP-1931: Regex removal (3/10): Read opennlp-dl vocab and config JSON with a strict scanner OPENNLP-1931: Regex removal (3a/10): Read opennlp-dl vocab and config JSON with a strict scanner Sep 19, 2026
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