OPENNLP-1931: Regex removal (3a/10): Read opennlp-dl vocab and config JSON with a strict scanner - #1277
OPENNLP-1931: Regex removal (3a/10): Read opennlp-dl vocab and config JSON with a strict scanner#1277krickert wants to merge 1 commit into
Conversation
9ba4095 to
944ab39
Compare
rzo1
left a comment
There was a problem hiding this comment.
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.
944ab39 to
fed91ba
Compare
|
Here are some additional comments. Def. needs an eval build ( Blocking
Minor
Verified:
|
1 similar comment
|
Here are some additional comments. Def. needs an eval build ( Blocking
Minor
Verified:
|
|
Follow-up: the pre-existing BOM problem in |
8699305 to
a4ed75a
Compare
|
CI is red. |
f632f55 to
ed9dd7e
Compare
|
Thanks for the update. CI is green now, and the description is refreshed. I have three points on the code. 1. Please move the Doing it properly needs more than this PR should carry. For this PR, please:
Nothing is released between the two PRs, since 2. Mark
3. Reference OPENNLP-1953 The byte order mark fix in The eval build ( |
3c7012f to
399e50d
Compare
rzo1
left a comment
There was a problem hiding this comment.
Blocking
-
AbstractDL.java
loadJsonVocab: behavior change that isn't documented. The old regex picked up every"token": idpair anywhere in the file, so atokenizer.jsonpassed as the vocabulary happened to load. It is now rejected, and so is anyvocab.jsonwith non-integer members. That's the right call, but users will only see it as a newInvalidFormatException. Please say indoccat.xmlthattokenizer.jsonisn't accepted as a vocabulary yet, with a pointer to OPENNLP-1988. -
Eval build. This changes how DL vocabularies and labels are loaded. Please trigger an eval build on the final head before merge. The local
DocumentCategorizerDLEvalrun is good, but not enough.
Minor
- AbstractDL.java:56, :196 and JsonScan.java:59, :286. Byte-order-mark handling is duplicated:
AbstractDLstrips the BOM, and thenJsonScanchecks for it again.FrequencyDictionaryLoader(#1320) andHunspellDictionarydo the same. Please add oneStringUtil.stripByteOrderMarkand use it everywhere. - JsonScan.java:98, :124, :200.
"key must not be null"is repeated three times, whileTEXT_MUST_NOT_BE_NULLis already a constant. DeclareKEY_MUST_NOT_BE_NULL. - JsonScan.java:350-351.
indexOf(BACKSLASH, start, end)is already bounded, sofirstBackslash >= endcan never be true. Drop it. - JsonScan.java:48-49.
@Internaldoesn't change that this ships as a public type. It's public only becauseDocumentCategorizerConfigsits inopennlp.dl.doccat. Move theid2labellookup intoopennlp.dland makeJsonScanpackage-private, or say why it has to be public. - JsonScan.java:34-46. The class Javadoc explains the design ("in one pass", "Nesting is bounded by memory, not by the call stack", "the only API of this class"). Cut it down to what the class does, RFC 8259 plus the documented deviations, and move the rationale to the JIRA ticket.
- doccat.xml:176-199. Both paragraphs are too detailed ("Thus a BOM-prefixed…", "U+FEFF inside a JSON string is content…", "so a damaged vocabulary is reported instead of read in part", Python's
NaN). Condense each to two or three sentences. :185 is also much longer than its neighbours; please rewrap it. - LoadVocabTest.java. The
File.createTempFile+Files.writeStringsetup is repeated in several tests, andDocumentCategorizerDLTest:211already has aconfigFile(...)helper. Use@TempDirwith one shared helper. - Description. "Configuration whitespace handling is independent of
opennlp.whitespace.mode" only makes sense next to the other regex-removal PRs. Drop it.
OPENNLP-1931: Strip byte order marks through StringUtil (for apache apache#1277)
…inors OPENNLP-1931: Address rzo1's remaining review asks (for apache apache#1277) Human sign-off
… JSON with a strict scanner Replaces the regular expressions in `AbstractDL.loadJsonVocab` and `DocumentCategorizerConfig.fromJson` with `JsonScan`, a cursor-based JSON scanner that follows RFC 8259 for structure, whitespace, and string escapes, and reports the offset of a malformed document. I've followed all the points made from the review, hand-reviewed with full comments public and in the upstream fork. The suggestions were all good - and the implementation was checked on every level. All of it is well tested, and approved in 3 PRs on the ai-pipestream fork. Edited AI summary below: - A JSON vocabulary is one object mapping tokens to non-negative integer ids. Fractional, negative, and overflowing ids are rejected with the token named. Any other layout, including tokenizer.json, is rejected with an error describing the expected format; tokenizer.json support is deferred to OPENNLP-1988. - id2label is read from the top-level configuration object. Keys and 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. - Malformed token and configuration files surface as InvalidFormatException from loadVocabFile and readCategories. - A leading byte order mark is skipped in JSON and plain-text vocabularies and in config.json, as RFC 8259 section 8.1 allows. This fixes OPENNLP-1953. A mark at any later offset is malformed, and a configuration that is only the mark and whitespace has no labels. StringUtil gains startsWithByteOrderMark and stripByteOrderMark, which AbstractDL and JsonScan share. - Nested values are skipped with an explicit stack, so a deeply nested or truncated file is rejected at its offset instead of overflowing the stack. Escape searches are bounded to the current string. NaN, Infinity, and -Infinity are accepted in skipped values, as Python's json module writes them, and rejected where a value is read. - JsonScan is annotated @internal(since = "3.0.0"); it is public only because DocumentCategorizerConfig uses stringObject. - The document categorizer chapter states the accepted layouts, the integer id rule, the byte order mark rule, the non-finite values, and the InvalidFormatException. Tests cover offsets and reasons for text cut off after each token, content after the object, separators and non-JSON whitespace, bad escapes at any depth, values nested 500 levels deep, CR and CRLF inside and outside strings, keys written with escapes, ids that do not fit an int, leading zeros, tokenizer.json rejection, and the byte order mark in both file formats. The vocabulary and categorizer tests write their files under a JUnit temporary directory. Assisted-by: Claude Code 2.1.281 Co-authored-by: Kristian Rickert <kristian@apache.org> Co-authored-by: Claude <noreply@anthropic.com> Reviewed-by: Kristian Rickert <kristian@apache.org> Signed-off-by: Kristian Rickert <kristian@apache.org>
399e50d to
16148bb
Compare
|
Once green, going to run the eval build |
|
@krickert Please link the eval build here. |
|
Thanks for the update. Maintainer edits don't work on the
Apply it with 1277.patchFrom fb41615b1b87be53ed9398367978f17be16f26c0 Mon Sep 17 00:00:00 2001
From: Richard Zowalla <rzo1@apache.org>
Date: Fri, 25 Sep 2026 21:10:17 +0200
Subject: [PATCH] OPENNLP-1931: Trim commentary and name the bad id2label key
- JsonScan: reduce the class Javadoc to what is read and the three
deviations from RFC 8259.
- DocumentCategorizerDL: report a non-integer id2label key by name
instead of the bare NumberFormatException message.
- StringUtilByteOrderMarkTest: use escapes instead of invisible
characters.
- doccat.xml: condense the vocabulary and configuration paragraphs and
link OPENNLP-1988.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
.../util/StringUtilByteOrderMarkTest.java | 4 +--
.../src/main/java/opennlp/dl/JsonScan.java | 26 +++++++++----------
.../dl/doccat/DocumentCategorizerDL.java | 17 +++++++++++-
opennlp-docs/src/docbkx/doccat.xml | 23 ++++++----------
4 files changed, 38 insertions(+), 32 deletions(-)
diff --git a/opennlp-api/src/test/java/opennlp/tools/util/StringUtilByteOrderMarkTest.java b/opennlp-api/src/test/java/opennlp/tools/util/StringUtilByteOrderMarkTest.java
index 0914c7673..8f34a0a90 100644
--- a/opennlp-api/src/test/java/opennlp/tools/util/StringUtilByteOrderMarkTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/util/StringUtilByteOrderMarkTest.java
@@ -25,7 +25,7 @@ import org.junit.jupiter.api.Test;
*/
public class StringUtilByteOrderMarkTest {
- private static final String BOM = "";
+ private static final String BOM = "\uFEFF";
@Test
void testStripsOneLeadingMark() {
@@ -49,7 +49,7 @@ public class StringUtilByteOrderMarkTest {
Assertions.assertFalse(StringUtil.startsWithByteOrderMark(""));
Assertions.assertFalse(StringUtil.startsWithByteOrderMark("{}" + BOM));
// U+FFFE is the byte-swapped mark, not a mark itself
- Assertions.assertFalse(StringUtil.startsWithByteOrderMark("�{}"));
+ Assertions.assertFalse(StringUtil.startsWithByteOrderMark("\uFFFE{}"));
}
@Test
diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/JsonScan.java b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/JsonScan.java
index a5363c93f..4a0246a86 100644
--- a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/JsonScan.java
+++ b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/JsonScan.java
@@ -30,20 +30,18 @@ import opennlp.tools.commons.Internal;
import opennlp.tools.util.StringUtil;
/**
- * Reads the JSON files of the deep-learning components, vocabularies and model configurations,
- * in one pass over the text. Structure, whitespace, numbers, and string escapes follow
- * <a href="https://www.rfc-editor.org/rfc/rfc8259">RFC 8259</a>, with three additions: a byte
- * order mark as the first character is skipped, as
- * <a href="https://www.rfc-editor.org/rfc/rfc8259#section-8.1">section 8.1</a> allows; the
- * control characters {@code U+0000} to {@code U+001F} are accepted as content inside a string,
- * in keys as well as in values; and the values {@code NaN}, {@code Infinity}, and
- * {@code -Infinity}, which Python's {@code json} module writes by default, are accepted where a
- * value is skipped, never where one is read. Nesting is bounded by memory, not by the call
- * stack. Malformed text is reported as an {@link IllegalArgumentException} whose message names
- * the offset at which reading stopped.
- *
- * <p>{@link #stringObject(String, String)} is the only API of this class; its other members
- * serve the classes of this package.
+ * Reads the JSON files of the deep-learning components, vocabularies and model configurations.
+ * Structure, whitespace, numbers, and string escapes follow
+ * <a href="https://www.rfc-editor.org/rfc/rfc8259">RFC 8259</a>, with three additions:
+ * <ul>
+ * <li>a byte order mark as the first character is skipped, as
+ * <a href="https://www.rfc-editor.org/rfc/rfc8259#section-8.1">section 8.1</a> allows;</li>
+ * <li>the control characters {@code U+0000} to {@code U+001F} are accepted inside a string;</li>
+ * <li>the values {@code NaN}, {@code Infinity}, and {@code -Infinity} are accepted where a
+ * value is skipped, never where one is read.</li>
+ * </ul>
+ * Malformed text is reported as an {@link IllegalArgumentException} whose message names the
+ * offset at which reading stopped.
*/
@Internal(since = "3.0.0")
public final class JsonScan {
diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
index ca9fd0667..208cb573e 100644
--- a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
+++ b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
@@ -453,7 +453,7 @@ public class DocumentCategorizerDL extends AbstractDL implements DocumentCategor
final Map<Integer, String> categories = new HashMap<>();
try {
for (Map.Entry<String, String> label : DocumentCategorizerConfig.fromJson(json).id2label().entrySet()) {
- categories.put(Integer.valueOf(label.getKey()), label.getValue());
+ categories.put(parseIndex(label.getKey()), label.getValue());
}
} catch (IllegalArgumentException e) {
throw new InvalidFormatException(
@@ -462,4 +462,19 @@ public class DocumentCategorizerDL extends AbstractDL implements DocumentCategor
return categories;
}
+ /**
+ * Parses an {@code id2label} key as an output index.
+ *
+ * @param key The key to parse.
+ * @return The output index.
+ * @throws IllegalArgumentException Thrown if {@code key} is not an integer.
+ */
+ private static int parseIndex(String key) {
+ try {
+ return Integer.parseInt(key);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("id2label key must be an integer: " + key, e);
+ }
+ }
+
}
diff --git a/opennlp-docs/src/docbkx/doccat.xml b/opennlp-docs/src/docbkx/doccat.xml
index 7b74274c1..b41729334 100644
--- a/opennlp-docs/src/docbkx/doccat.xml
+++ b/opennlp-docs/src/docbkx/doccat.xml
@@ -173,24 +173,17 @@ String category = myCategorizer.getBestCategory(outcomes);]]>
For additional examples, refer to the <code>DocumentCategorizerDLEval</code> class.
</para>
<para>
- The vocabulary file is either plain text, with one token per line and the line number as
- the token ID, as in <code>vocab.txt</code>, or a JSON object that maps each token to a
- non-negative integer ID, as in <code>vocab.json</code>. A file is read as JSON when its
- first non-whitespace character is an opening brace, and a leading byte order mark is
- ignored. A Hugging Face <code>tokenizer.json</code> is not a vocabulary file and is
- rejected; support for it is tracked in OPENNLP-1988. A vocabulary that cannot be read is
- rejected with an <code>InvalidFormatException</code> that names the file and the position
- of the problem.
+ The vocabulary file is either plain text with one token per line, as in
+ <code>vocab.txt</code>, or a JSON object mapping each token to an integer ID, as in
+ <code>vocab.json</code>. A Hugging Face <code>tokenizer.json</code> is not supported yet
+ (<ulink url="https://issues.apache.org/jira/browse/OPENNLP-1988">OPENNLP-1988</ulink>).
+ An unreadable vocabulary is rejected with an <code>InvalidFormatException</code>.
</para>
<para>
When a configuration file is given in place of the categories map, its top-level
- <code>id2label</code> object supplies the categories, mapping each output index to a
- label. A configuration without a top-level <code>id2label</code> gives no categories.
- A configuration that is not well-formed JSON, or whose <code>id2label</code> is not an
- object of strings keyed by integers, is rejected with an
- <code>InvalidFormatException</code>. The values <code>NaN</code> and
- <code>Infinity</code>, which Python writes into some configurations, are accepted
- outside <code>id2label</code>.
+ <code>id2label</code> object maps each output index to a category label. A configuration
+ that is not well-formed JSON, or whose <code>id2label</code> is not an object of strings
+ keyed by integers, is rejected with an <code>InvalidFormatException</code>.
</para>
<para>
Like <code>NameFinderDL</code>, long input is split into overlapping chunks on the full
--
2.55.0
Still open on your side:
As mentioned on #1278: pushing your branches to |
|
Thanks. I'll push all of them to Apache branches tonight |
Replaces the regular expressions in
AbstractDL.loadJsonVocabandDocumentCategorizerConfig.fromJsonwithJsonScan, a cursor-based JSON scanner that follows RFC 8259 for structure, whitespace, and string escapes.AI assisted edit (reviewed and edited):