This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch feat/GH-1479-tokenizer-character-policy
in repository https://gitbox.apache.org/repos/asf/opennlp.git

commit 44327b054bacc809c3d62c8640ed8bc3402f72d8
Author: Kristian Rickert <[email protected]>
AuthorDate: Fri Oct 23 23:25:31 2026 -0400

    GH-1479: Configure the tokenizer alphanumeric optimization with 
TokenizerCharacterPolicy instead of a regex
    
    TokenizerME, TokSpanEventStream, TokenizerFactory and the language
    Factory take a TokenizerCharacterPolicy for the alphanumeric
    optimization. The Pattern variants of the TokenizerFactory constructor,
    TokenizerFactory.create and the TokSpanEventStream constructor,
    getAlphaNumericPattern(), Factory.getAlphanumeric(String) and
    Factory.DEFAULT_ALPHANUMERIC stay as deprecated for removal in 4.0, and
    only they keep @SuppressForbidden. New overloads without the last
    argument use the policy of the language.
    
    The factory writes the policy to the model manifest as characterPolicy,
    in the text form of TokenizerCharacterPolicy.toString(), which
    TokenizerCharacterPolicy.parse(String) reads; for a language policy it
    also writes the alphaNumericPattern of earlier releases. A model with
    one of the alphaNumericPattern values OpenNLP wrote loads with the
    matching language policy, and a model with a custom pattern loads and
    tests candidate tokens with that regular expression. A manifest without
    either property uses the policy for its language code.
    
    The language policies are tested against their old regular
    expressions, TokenizerME output is pinned with and without the
    optimization, and the manifest tests cover the policy, every legacy
    pattern, a custom and an invalid pattern, a malformed policy and a
    manifest without either, plus the deprecated constructors, create and
    getters. TokenizerCharacterPolicyBenchmark compares the policy with the
    regular expressions. The tokenizer chapter and the README describe the
    policy, the deprecation and the migration.
    
    Fixes #1479
    
    Signed-off-by: Kristian Rickert <[email protected]>
    Co-authored-by: Claude Code 2.1.296 (Opus 5.5) <[email protected]>
---
 README.md                                          |   1 +
 dev/forbidden-regex.txt                            |   2 +-
 .../tokenizer/TokenizerCrossValidatorTool.java     |   2 +-
 .../cmdline/tokenizer/TokenizerTrainerTool.java    |   2 +-
 .../formats/masc/MascTokenSampleStreamTest.java    |   2 +-
 .../TokenizerCharacterPolicyBenchmark.java         | 133 +++++++
 .../tools/tokenize/TokenizerMEBenchmark.java       |   2 +-
 .../opennlp/tools/tokenize/TokSpanEventStream.java |  59 +++-
 .../tools/tokenize/TokenizerCharacterPolicy.java   | 162 +++++++++
 .../opennlp/tools/tokenize/TokenizerFactory.java   | 341 +++++++++++++++---
 .../java/opennlp/tools/tokenize/TokenizerME.java   |  16 +-
 .../java/opennlp/tools/tokenize/lang/Factory.java  | 133 +++++--
 .../opennlp/tools/ThreadSafetyBenchmarkIT.java     |   2 +-
 .../tools/tokenize/DummyTokenizerFactory.java      |   9 +-
 .../tools/tokenize/LegacyAlphaNumericPatterns.java |  55 +++
 .../tools/tokenize/TokSpanEventStreamTest.java     | 114 ++++++
 .../tokenize/TokenizerCharacterPolicyTest.java     | 133 +++++++
 .../tools/tokenize/TokenizerFactoryTest.java       | 386 +++++++++++++++++----
 .../opennlp/tools/tokenize/TokenizerMETest.java    |  63 +++-
 .../opennlp/tools/tokenize/TokenizerTestUtil.java  |  14 +-
 .../opennlp/tools/tokenize/lang/FactoryTest.java   | 164 +++++++++
 opennlp-docs/src/docbkx/tokenizer.xml              |  46 ++-
 .../opennlp/tools/eval/ArvoresDeitadasEval.java    |   2 +-
 .../tools/eval/EnglishTokenSampleStreamEval.java   |   2 +-
 24 files changed, 1668 insertions(+), 177 deletions(-)

diff --git a/README.md b/README.md
index 0a9f83f2c..88454edbd 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,7 @@ The list below covers the changes most likely to affect 
existing code. It is not
 - `RealBasicEventStream`: a line that contains only an outcome is read as an 
event without contexts and no longer ends the stream.
 - Parser: `ParserFactory.create` and the chunking and tree insert `Parser` 
constructors throw `IllegalArgumentException` for a beam size below 1 or an 
advance percentage outside (0, 1] 
([GH-1476](https://github.com/apache/opennlp/issues/1476)).
 - Model resolver: `AbstractClassPathModelFinder.findModels` returns an 
unmodifiable set, so the classpath finders and `DefaultClassPathModelProvider` 
can be shared between threads 
([GH-1506](https://github.com/apache/opennlp/issues/1506)).
+- Tokenizer: the alphanumeric optimization is configured with a 
`TokenizerCharacterPolicy`. The `Pattern` variants of the `TokenizerFactory` 
constructor, `TokenizerFactory.create` and the `TokSpanEventStream` 
constructor, `TokenizerFactory.getAlphaNumericPattern()`, and 
`opennlp.tools.tokenize.lang.Factory.getAlphanumeric(String)` and 
`Factory.DEFAULT_ALPHANUMERIC` are deprecated for removal in 4.0. A call that 
passes a `null` literal as the pattern no longer compiles because it matches  
[...]
 
 ### Thread safety
 
diff --git a/dev/forbidden-regex.txt b/dev/forbidden-regex.txt
index 3eecc2937..2ae65b772 100644
--- a/dev/forbidden-regex.txt
+++ b/dev/forbidden-regex.txt
@@ -16,7 +16,7 @@
 # forbiddenapis signatures for the forbidden-regex-main execution in the root 
pom.xml.
 # Production code scans text with code-point loops and the 
opennlp.tools.util.StringUtil
 # helpers instead of java.util.regex (OPENNLP-1935). A member with a public 
contract that is
-# a user-supplied pattern (RegexNameFinder, the tokenizer factories) is marked 
with
+# a user-supplied pattern (for example RegexNameFinder) is marked with
 # @opennlp.tools.commons.SuppressForbidden and is exempt. Only production 
classes are
 # checked; tests may use regular expressions.
 
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/tokenizer/TokenizerCrossValidatorTool.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/tokenizer/TokenizerCrossValidatorTool.java
index 5dcfc9ffa..77d557773 100644
--- 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/tokenizer/TokenizerCrossValidatorTool.java
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/tokenizer/TokenizerCrossValidatorTool.java
@@ -72,7 +72,7 @@ public final class TokenizerCrossValidatorTool
 
       TokenizerFactory tokFactory = TokenizerFactory.create(
           params.getFactory(), params.getLang(), dict,
-          params.getAlphaNumOpt(), null);
+          params.getAlphaNumOpt());
       validator = new opennlp.tools.tokenize.TokenizerCrossValidator(mlParams,
           tokFactory, listener);
 
diff --git 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/tokenizer/TokenizerTrainerTool.java
 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/tokenizer/TokenizerTrainerTool.java
index 4f5389ab2..a9c2ba12b 100644
--- 
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/tokenizer/TokenizerTrainerTool.java
+++ 
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/tokenizer/TokenizerTrainerTool.java
@@ -100,7 +100,7 @@ public final class TokenizerTrainerTool
 
       TokenizerFactory tokFactory = TokenizerFactory.create(
           params.getFactory(), params.getLang(), dict,
-          params.getAlphaNumOpt(), null);
+          params.getAlphaNumOpt());
       model = opennlp.tools.tokenize.TokenizerME.train(sampleStream,
           tokFactory, mlParams);
 
diff --git 
a/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/masc/MascTokenSampleStreamTest.java
 
b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/masc/MascTokenSampleStreamTest.java
index d5a925eff..0af7c5c83 100644
--- 
a/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/masc/MascTokenSampleStreamTest.java
+++ 
b/opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/masc/MascTokenSampleStreamTest.java
@@ -134,7 +134,7 @@ public class MascTokenSampleStreamTest extends 
AbstractMascSampleStreamTest {
       trainingParameters.put(Parameters.ITERATIONS_PARAM, 20);
 
       TokenizerModel model = TokenizerME.train(trainTokens, new 
TokenizerFactory(
-              "en", null, false, null), trainingParameters);
+              "en", null, false), trainingParameters);
 
       ObjectStream<TokenSample> testTokens = new MascTokenSampleStream(
           new MascDocumentStream(directory, true, fileFilter));
diff --git 
a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/tokenize/TokenizerCharacterPolicyBenchmark.java
 
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/tokenize/TokenizerCharacterPolicyBenchmark.java
new file mode 100644
index 000000000..4a78a0f5d
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/tokenize/TokenizerCharacterPolicyBenchmark.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.tokenize;
+
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+import opennlp.tools.tokenize.lang.Factory;
+
+/**
+ * JMH benchmark for the check of the alphanumeric optimization in {@link 
TokenizerME}: whether a
+ * whitespace-separated candidate token is accepted whole. It compares
+ * {@link TokenizerCharacterPolicy#test(CharSequence)} with the regular 
expression that releases
+ * before 3.0 stored in the model manifest for the same language, on the same 
candidates.
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 5, time = 1)
+@Measurement(iterations = 10, time = 1)
+@Fork(2)
+public class TokenizerCharacterPolicyBenchmark {
+
+  /** Candidate tokens of two or more characters, as the tokenizer checks 
them. */
+  private static final String[] CANDIDATES = {
+      "The", "driver", "injured", "accident.", "Edinburgh", "Edinburgh.", 
"letter", "right",
+      "OpenNLP", "provides", "tools", "NLP.", "Pierre", "Vinken,", "61", 
"years", "old,",
+      "nonexecutive", "Nov.", "29.", "555-1234", "(anytime).", "3.5\"", 
"don't", "it's",
+      "N.V.,", "e-mail", "2026", "acción", "niño", "señor", "árbol", 
"Málaga,", "über",
+      "Straße", "Ärger", "garçon", "Œuvre", "żółć", "Łódź", "città", "perché", 
"naïve",
+      "coöperate", "café", "x86_64", "C++", "@user", "#tag", 
"https://opennlp.apache.org";
+  };
+
+  /** The language whose policy and released expression are compared. */
+  @State(Scope.Benchmark)
+  public static class LanguageState {
+
+    @Param({"en", "es", "de", "pl"})
+    String language;
+
+    TokenizerCharacterPolicy policy;
+
+    Pattern pattern;
+
+    @Setup(Level.Trial)
+    public void setUp() {
+      policy = new Factory().getCharacterPolicy(language);
+      pattern = Pattern.compile(switch (language) {
+        case "es" -> "^[0-9a-záéíóúüýñA-ZÁÉÍÓÚÝÑ]+$";
+        case "de" -> "^[A-Za-z0-9äéöüÄÉÖÜß]+$";
+        case "pl" -> "^[A-Za-z0-9żźćńółęąśŻŹĆĄŚĘŁÓŃ]+$";
+        default -> "^[A-Za-z0-9]+$";
+      });
+      for (String candidate : CANDIDATES) {
+        if (policy.test(candidate) != pattern.matcher(candidate).matches()) {
+          throw new IllegalStateException("policy and expression disagree on " 
+ candidate);
+        }
+      }
+    }
+  }
+
+  /**
+   * Checks every candidate with the character policy.
+   *
+   * @param state The language state.
+   * @param bh The sink for the results.
+   */
+  @Benchmark
+  public void characterPolicy(LanguageState state, Blackhole bh) {
+    for (String candidate : CANDIDATES) {
+      bh.consume(state.policy.test(candidate));
+    }
+  }
+
+  /**
+   * Checks every candidate with the regular expression of earlier releases.
+   *
+   * @param state The language state.
+   * @param bh The sink for the results.
+   */
+  @Benchmark
+  public void regularExpression(LanguageState state, Blackhole bh) {
+    for (String candidate : CANDIDATES) {
+      bh.consume(state.pattern.matcher(candidate).matches());
+    }
+  }
+
+  /**
+   * Quick local iteration only: {@code forks(0)} disables JVM fork isolation
+   * (unlike {@code mvn} with the {@code jmh} profile).
+   * Use the Maven-invoked configuration for publishable numbers.
+   */
+  public static void main(String[] args) throws Exception {
+    Options opt = new OptionsBuilder()
+        .include(TokenizerCharacterPolicyBenchmark.class.getSimpleName())
+        .forks(0)
+        .warmupIterations(3)
+        .measurementIterations(5)
+        .build();
+    new Runner(opt).run();
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/tokenize/TokenizerMEBenchmark.java
 
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/tokenize/TokenizerMEBenchmark.java
index 9442483fa..a8a92c7bd 100644
--- 
a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/tokenize/TokenizerMEBenchmark.java
+++ 
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/tokenize/TokenizerMEBenchmark.java
@@ -82,7 +82,7 @@ public class TokenizerMEBenchmark {
       p.put(Parameters.CUTOFF_PARAM, 0);
       model = TokenizerME.train(samples,
           TokenizerFactory.create(
-              null, "eng", null, true, null), p);
+              null, "eng", null, true), p);
     }
   }
 
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokSpanEventStream.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokSpanEventStream.java
index b5e297aff..4758fadbb 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokSpanEventStream.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokSpanEventStream.java
@@ -21,6 +21,7 @@ package opennlp.tools.tokenize;
 import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
+import java.util.function.Predicate;
 import java.util.regex.Pattern;
 
 import org.slf4j.Logger;
@@ -31,6 +32,7 @@ import opennlp.tools.ml.model.Event;
 import opennlp.tools.tokenize.lang.Factory;
 import opennlp.tools.util.AbstractEventStream;
 import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParamChecks;
 import opennlp.tools.util.Span;
 
 /**
@@ -45,30 +47,66 @@ public class TokSpanEventStream extends 
AbstractEventStream<TokenSample> {
 
   private final boolean skipAlphaNumerics;
 
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
-  private final Pattern alphaNumeric;
+  private final Predicate<CharSequence> alphanumeric;
 
   /**
    * Initializes a new event stream based on the data stream using a {@link 
TokenContextGenerator}.
    *
    * @param tokenSamples The {@link ObjectStream data stream} for this event 
stream.
    * @param skipAlphaNumerics Whether alphanumerics are skipped, or not.
-   * @param alphaNumeric A custom alphanumeric {@link Pattern} or {@code null}.
-   *                     Default is: {@code "^[A-Za-z0-9]+$"}, provided by
-   *                     {@link Factory#DEFAULT_ALPHANUMERIC}.
+   * @param characterPolicy The {@link TokenizerCharacterPolicy} that decides 
which candidate
+   *                        tokens count as alphanumeric. Must not be {@code 
null}.
    * @param cg A {@link TokenContextGenerator} which should be used for the 
event stream {@code d}.
+   * @throws IllegalArgumentException Thrown if {@code characterPolicy} is 
{@code null}.
    */
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
+  public TokSpanEventStream(ObjectStream<TokenSample> tokenSamples, boolean 
skipAlphaNumerics,
+                            TokenizerCharacterPolicy characterPolicy, 
TokenContextGenerator cg) {
+    this(tokenSamples, skipAlphaNumerics,
+        (Predicate<CharSequence>) 
ParamChecks.requireNonNullArg(characterPolicy, "characterPolicy"),
+        cg);
+  }
+
+  /**
+   * Initializes a new event stream that decides which candidate tokens count 
as alphanumeric
+   * with a regular expression.
+   *
+   * @param tokenSamples The {@link ObjectStream data stream} for this event 
stream.
+   * @param skipAlphaNumerics Whether alphanumerics are skipped, or not.
+   * @param alphaNumeric A custom alphanumeric {@link Pattern}, or {@code 
null} for
+   *                     {@link Factory#DEFAULT_CHARACTER_POLICY}.
+   * @param cg A {@link TokenContextGenerator} which should be used for the 
event stream {@code d}.
+   * @deprecated Use
+   *     {@link #TokSpanEventStream(ObjectStream, boolean, 
TokenizerCharacterPolicy, TokenContextGenerator)}.
+   *     This constructor will be removed in 4.0.
+   */
+  @Deprecated(since = "3.0.0", forRemoval = true)
+  @SuppressForbidden("user-supplied alphanumeric pattern, kept for the 
deprecated API")
   public TokSpanEventStream(ObjectStream<TokenSample> tokenSamples, boolean 
skipAlphaNumerics,
                             Pattern alphaNumeric, TokenContextGenerator cg) {
+    this(tokenSamples, skipAlphaNumerics, alphaNumeric == null
+        ? Factory.DEFAULT_CHARACTER_POLICY : new 
TokenizerFactory.PatternTest(alphaNumeric), cg);
+  }
+
+  /**
+   * Initializes a new event stream with the test that decides which candidate 
tokens count as
+   * alphanumeric.
+   *
+   * @param tokenSamples The {@link ObjectStream data stream} for this event 
stream.
+   * @param skipAlphaNumerics Whether alphanumerics are skipped, or not.
+   * @param alphanumeric The test for alphanumeric candidate tokens. Must not 
be {@code null}.
+   * @param cg A {@link TokenContextGenerator} which should be used for the 
event stream {@code d}.
+   */
+  TokSpanEventStream(ObjectStream<TokenSample> tokenSamples, boolean 
skipAlphaNumerics,
+                     Predicate<CharSequence> alphanumeric, 
TokenContextGenerator cg) {
     super(tokenSamples);
-    this.alphaNumeric = alphaNumeric;
+    this.alphanumeric = alphanumeric;
     this.skipAlphaNumerics = skipAlphaNumerics;
     this.cg = cg;
   }
 
   /**
-   * Initializes a new event stream based on the data stream using a {@link 
TokenContextGenerator}.
+   * Initializes a new event stream based on the data stream using a {@link 
TokenContextGenerator}
+   * and {@link Factory#DEFAULT_CHARACTER_POLICY}.
    *
    * @param tokenSamples The {@link ObjectStream data stream} for this event 
stream.
    * @param skipAlphaNumerics Whether alphanumerics are skipped, or not.
@@ -76,7 +114,7 @@ public class TokSpanEventStream extends 
AbstractEventStream<TokenSample> {
    */
   public TokSpanEventStream(ObjectStream<TokenSample> tokenSamples, boolean 
skipAlphaNumerics,
                             TokenContextGenerator cg) {
-    this(tokenSamples, skipAlphaNumerics, new Factory().getAlphanumeric(null), 
cg );
+    this(tokenSamples, skipAlphaNumerics, Factory.DEFAULT_CHARACTER_POLICY, 
cg);
   }
 
   /**
@@ -98,7 +136,6 @@ public class TokSpanEventStream extends 
AbstractEventStream<TokenSample> {
    * @return An {@link Iterator} for text {@link Event events} representing 
the {@code tokenSample}.
    */
   @Override
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
   protected Iterator<Event> createEvents(TokenSample tokenSample) {
 
     List<Event> events = new ArrayList<>(50);
@@ -123,7 +160,7 @@ public class TokSpanEventStream extends 
AbstractEventStream<TokenSample> {
         //adjust cSpan to text offsets
         cSpan = new Span(cSpan.getStart() + start, cSpan.getEnd() + start);
         //should we skip this token
-        if (ctok.length() > 1 && (!skipAlphaNumerics || 
!alphaNumeric.matcher(ctok).matches())) {
+        if (ctok.length() > 1 && (!skipAlphaNumerics || 
!alphanumeric.test(ctok))) {
 
           //find offsets of annotated tokens inside of candidate tokens
           boolean foundTrainingTokens = false;
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerCharacterPolicy.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerCharacterPolicy.java
index d2ff20b5a..37a4f3cef 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerCharacterPolicy.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerCharacterPolicy.java
@@ -16,6 +16,8 @@
  */
 package opennlp.tools.tokenize;
 
+import java.util.HexFormat;
+import java.util.Objects;
 import java.util.function.Predicate;
 
 import opennlp.tools.util.ParamChecks;
@@ -32,10 +34,20 @@ import opennlp.tools.util.normalizer.UnicodeWhitespace;
  *
  * <p>This predicate tests a complete candidate without finding token 
boundaries or changing
  * its text.</p>
+ *
+ * @since 3.0.0
  */
 public final class TokenizerCharacterPolicy implements Predicate<CharSequence> 
{
 
   private static final String CODE_POINT_FORMAT = "U+%04X";
+  private static final String LETTERS_FIELD = "letters=";
+  private static final String DIGITS_FIELD = ";digits=";
+  private static final String MARKS_FIELD = ";marks=";
+  private static final char RANGE_SEPARATOR = ',';
+  private static final char RANGE_DASH = '-';
+  private static final HexFormat UPPER_HEX = HexFormat.of().withUpperCase();
+  private static final int MIN_HEX_DIGITS = 4;
+  private static final int BITS_PER_HEX_DIGIT = 4;
 
   private static final TokenizerCharacterPolicy ASCII = new 
TokenizerCharacterPolicy(
       CodePointSet.ofRange('A', 'Z').union(CodePointSet.ofRange('a', 'z')),
@@ -86,6 +98,30 @@ public final class TokenizerCharacterPolicy implements 
Predicate<CharSequence> {
     return ASCII;
   }
 
+  /**
+   * Parses the text form written by {@link #toString()}: the three fields
+   * {@code letters=}, {@code ;digits=} and {@code ;marks=} in that order, 
each followed by
+   * zero or more comma separated hex code points ({@code 0041}) or inclusive 
ranges
+   * ({@code 0041-005A}). Hex digits may use either case; no prefix or 
whitespace is accepted.
+   *
+   * @param text The text form to read.
+   * @return The policy the text describes.
+   * @throws IllegalArgumentException Thrown if {@code text} is {@code null} 
or not of that
+   *     form, or if the sets it describes are rejected by
+   *     {@link #of(CodePointSet, CodePointSet, CodePointSet)}.
+   */
+  public static TokenizerCharacterPolicy parse(String text) {
+    ParamChecks.requireNonNullArg(text, "text");
+    final int digitsAt = text.indexOf(DIGITS_FIELD);
+    final int marksAt = digitsAt < 0 ? -1 : text.indexOf(MARKS_FIELD, digitsAt 
+ DIGITS_FIELD.length());
+    if (!text.startsWith(LETTERS_FIELD) || digitsAt < 0 || marksAt < 0) {
+      throw malformed(text, "expected the fields " + LETTERS_FIELD + 
DIGITS_FIELD + MARKS_FIELD);
+    }
+    return of(parseRanges(text, LETTERS_FIELD.length(), digitsAt),
+        parseRanges(text, digitsAt + DIGITS_FIELD.length(), marksAt),
+        parseRanges(text, marksAt + MARKS_FIELD.length(), text.length()));
+  }
+
   /**
    * {@inheritDoc}
    *
@@ -141,6 +177,132 @@ public final class TokenizerCharacterPolicy implements 
Predicate<CharSequence> {
     return marks;
   }
 
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Two policies are equal if their letter, digit and mark sets are 
equal.</p>
+   */
+  @Override
+  public boolean equals(Object o) {
+    return o instanceof TokenizerCharacterPolicy other && 
letters.equals(other.letters)
+        && digits.equals(other.digits) && marks.equals(other.marks);
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Consistent with {@link #equals(Object)}: computed from the letter, 
digit and mark sets.</p>
+   */
+  @Override
+  public int hashCode() {
+    return Objects.hash(letters, digits, marks);
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Writes the text form read by {@link #parse(String)}, for example
+   * {@code letters=0041-005A,0061-007A;digits=0030-0039;marks=} for {@link 
#ascii()}: runs of
+   * adjacent code points become ranges, and each code point is written with 
at least four
+   * upper-case hex digits.</p>
+   */
+  @Override
+  public String toString() {
+    final StringBuilder text = new StringBuilder();
+    appendRanges(text.append(LETTERS_FIELD), letters);
+    appendRanges(text.append(DIGITS_FIELD), digits);
+    appendRanges(text.append(MARKS_FIELD), marks);
+    return text.toString();
+  }
+
+  /** Appends the members of {@code set} as comma separated code points and 
ranges. */
+  private static void appendRanges(StringBuilder text, CodePointSet set) {
+    final int[] members = set.toArray();
+    int first = 0;
+    while (first < members.length) {
+      int last = first;
+      while (last + 1 < members.length && members[last + 1] == members[last] + 
1) {
+        last++;
+      }
+      if (first > 0) {
+        text.append(RANGE_SEPARATOR);
+      }
+      appendHex(text, members[first]);
+      if (last > first) {
+        appendHex(text.append(RANGE_DASH), members[last]);
+      }
+      first = last + 1;
+    }
+  }
+
+  /** Appends {@code codePoint} as upper-case hex with at least {@link 
#MIN_HEX_DIGITS} digits. */
+  private static void appendHex(StringBuilder text, int codePoint) {
+    final String hex = UPPER_HEX.toHexDigits(codePoint);
+    int start = 0;
+    while (start < hex.length() - MIN_HEX_DIGITS && hex.charAt(start) == '0') {
+      start++;
+    }
+    text.append(hex, start, hex.length());
+  }
+
+  /** Reads the comma separated code points and ranges in {@code text[from, 
to)}. */
+  private static CodePointSet parseRanges(String text, int from, int to) {
+    CodePointSet members = CodePointSet.of();
+    if (from == to) {
+      return members;
+    }
+    int offset = from;
+    while (true) {
+      int separator = text.indexOf(RANGE_SEPARATOR, offset);
+      if (separator < 0 || separator > to) {
+        separator = to;
+      }
+      members = members.union(parseRange(text, offset, separator));
+      if (separator == to) {
+        return members;
+      }
+      offset = separator + 1;
+    }
+  }
+
+  /** Reads one code point or one inclusive range in {@code text[from, to)}. */
+  private static CodePointSet parseRange(String text, int from, int to) {
+    final int dash = text.indexOf(RANGE_DASH, from);
+    if (dash < 0 || dash >= to) {
+      return CodePointSet.of(parseHex(text, from, to));
+    }
+    final int low = parseHex(text, from, dash);
+    final int high = parseHex(text, dash + 1, to);
+    if (low > high) {
+      throw malformed(text, "descending range '" + text.substring(from, to) + 
"'");
+    }
+    return CodePointSet.ofRange(low, high);
+  }
+
+  /** Reads the hex code point in {@code text[from, to)}. */
+  private static int parseHex(String text, int from, int to) {
+    if (from >= to) {
+      throw malformed(text, "empty code point at offset " + from);
+    }
+    int value = 0;
+    for (int i = from; i < to; i++) {
+      final char c = text.charAt(i);
+      if (!HexFormat.isHexDigit(c)) {
+        throw malformed(text, "'" + text.substring(from, to) + "' is not a hex 
code point");
+      }
+      value = value << BITS_PER_HEX_DIGIT | HexFormat.fromHexDigit(c);
+      if (value > Character.MAX_CODE_POINT) {
+        throw malformed(text, "'" + text.substring(from, to) + "' is beyond 
U+10FFFF");
+      }
+    }
+    return value;
+  }
+
+  /** {@return the exception for a text form that cannot be read} */
+  private static IllegalArgumentException malformed(String text, String 
detail) {
+    return new IllegalArgumentException("Malformed character policy '" + text 
+ "': " + detail);
+  }
+
   /** Throws if {@code set} contains a surrogate code point or {@link 
UnicodeWhitespace} member. */
   private static void validateSet(String name, CodePointSet set) {
     for (int codePoint = Character.MIN_SURROGATE; codePoint <= 
Character.MAX_SURROGATE;
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerFactory.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerFactory.java
index eabc986cb..44d9378bc 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerFactory.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerFactory.java
@@ -18,9 +18,12 @@
 package opennlp.tools.tokenize;
 
 import java.util.Collections;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Predicate;
 import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
 
 import opennlp.tools.commons.SuppressForbidden;
 import opennlp.tools.dictionary.Dictionary;
@@ -39,20 +42,43 @@ public class TokenizerFactory extends BaseToolFactory {
   private String languageCode;
   private Dictionary abbreviationDictionary;
   private Boolean useAlphaNumericOptimization = false;
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
+  private TokenizerCharacterPolicy characterPolicy;
+  @SuppressForbidden("user-supplied alphanumeric pattern, kept for the 
deprecated API")
   private Pattern alphaNumericPattern;
 
   private static final String ABBREVIATIONS_ENTRY_NAME = 
"abbreviations.dictionary";
   private static final String USE_ALPHA_NUMERIC_OPTIMIZATION = 
"useAlphaNumericOptimization";
+  private static final String CHARACTER_POLICY = "characterPolicy";
   private static final String ALPHA_NUMERIC_PATTERN = "alphaNumericPattern";
 
+  /**
+   * The language codes with a dedicated character policy, whose alphanumeric 
expressions releases
+   * before 3.0 wrote to the model manifest; {@code en} stands for the ASCII 
default.
+   */
+  private static final List<String> LEGACY_LANGUAGES =
+      List.of("en", "pt", "fr", "nl", "de", "pl", "it", "es", "ca");
+
   /**
    * Instantiates a {@link TokenizerFactory} that provides the default 
implementation
    * of the resources.
    */
   public TokenizerFactory() {
   }
-  
+
+  /**
+   * Instantiates a {@link TokenizerFactory} with the {@link 
TokenizerCharacterPolicy} of its
+   * language. Use this constructor to programmatically create a factory.
+   *
+   * @param languageCode The ISO language code to be used for this factory.
+   * @param abbreviationDictionary The {@link Dictionary} which holds 
abbreviations.
+   * @param useAlphaNumericOptimization Whether alphanumerics are skipped, or 
not.
+   */
+  public TokenizerFactory(String languageCode, Dictionary 
abbreviationDictionary,
+                          boolean useAlphaNumericOptimization) {
+    this(languageCode, abbreviationDictionary, useAlphaNumericOptimization,
+        (TokenizerCharacterPolicy) null);
+  }
+
   /**
    * Instantiates a {@link TokenizerFactory}. Use this constructor to
    * programmatically create a factory.
@@ -60,31 +86,51 @@ public class TokenizerFactory extends BaseToolFactory {
    * @param languageCode The ISO language code to be used for this factory.
    * @param abbreviationDictionary The {@link Dictionary} which holds 
abbreviations.
    * @param useAlphaNumericOptimization Whether alphanumerics are skipped, or 
not.
-   * @param alphaNumericPattern {@code null} or a custom alphanumeric {@link 
Pattern}
-   *                            (default is: {@code "^[A-Za-z0-9]+$"}, 
provided by
-   *                            {@link Factory#DEFAULT_ALPHANUMERIC}.
+   * @param characterPolicy {@code null} or a custom {@link 
TokenizerCharacterPolicy}
+   *                        (default is the policy for {@code languageCode}, 
provided by
+   *                        {@link Factory#getCharacterPolicy(String)}).
    */
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
   public TokenizerFactory(String languageCode, Dictionary 
abbreviationDictionary,
-                          boolean useAlphaNumericOptimization, Pattern 
alphaNumericPattern) {
+                          boolean useAlphaNumericOptimization,
+                          TokenizerCharacterPolicy characterPolicy) {
     this.init(languageCode, abbreviationDictionary,
-        useAlphaNumericOptimization, alphaNumericPattern);
+        useAlphaNumericOptimization, characterPolicy);
   }
 
   /**
+   * Instantiates a {@link TokenizerFactory} whose alphanumeric optimization 
tests candidate
+   * tokens with a regular expression.
+   *
    * @param languageCode The ISO language code to be used for this factory.
    * @param abbreviationDictionary The {@link Dictionary} which holds 
abbreviations.
    * @param useAlphaNumericOptimization Whether alphanumerics are skipped, or 
not.
-   * @param alphaNumericPattern {@code null} or a custom alphanumeric {@link 
Pattern}
-   *                            (default is: {@code "^[A-Za-z0-9]+$"}, 
provided by
-   *                            {@link Factory#DEFAULT_ALPHANUMERIC}.
+   * @param alphaNumericPattern {@code null} for the policy of {@code 
languageCode}, or a custom
+   *                            alphanumeric {@link Pattern}.
+   * @deprecated Use
+   *     {@link #TokenizerFactory(String, Dictionary, boolean, 
TokenizerCharacterPolicy)}. This
+   *     constructor will be removed in 4.0.
+   */
+  @Deprecated(since = "3.0.0", forRemoval = true)
+  @SuppressForbidden("user-supplied alphanumeric pattern, kept for the 
deprecated API")
+  public TokenizerFactory(String languageCode, Dictionary 
abbreviationDictionary,
+                          boolean useAlphaNumericOptimization, Pattern 
alphaNumericPattern) {
+    this(languageCode, abbreviationDictionary, useAlphaNumericOptimization);
+    this.alphaNumericPattern = alphaNumericPattern;
+  }
+
+  /**
+   * @param languageCode The ISO language code to be used for this factory.
+   * @param abbreviationDictionary The {@link Dictionary} which holds 
abbreviations.
+   * @param useAlphaNumericOptimization Whether alphanumerics are skipped, or 
not.
+   * @param characterPolicy {@code null} or a custom {@link 
TokenizerCharacterPolicy}
+   *                        (default is the policy for {@code languageCode}, 
provided by
+   *                        {@link Factory#getCharacterPolicy(String)}).
    */
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
   protected void init(String languageCode, Dictionary abbreviationDictionary,
-      boolean useAlphaNumericOptimization, Pattern alphaNumericPattern) {
+      boolean useAlphaNumericOptimization, TokenizerCharacterPolicy 
characterPolicy) {
     this.languageCode = languageCode;
     this.useAlphaNumericOptimization = useAlphaNumericOptimization;
-    this.alphaNumericPattern = alphaNumericPattern;
+    this.characterPolicy = characterPolicy;
     this.abbreviationDictionary = abbreviationDictionary;
   }
 
@@ -100,6 +146,14 @@ public class TokenizerFactory extends BaseToolFactory {
       throw new InvalidFormatException("Abbreviations dictionary '" + 
abbreviationsEntry +
               "' has wrong type, needs to be of type Dictionary!");
     }
+
+    try {
+      if (this.characterPolicy == null && this.alphaNumericPattern == null) {
+        readManifest();
+      }
+    } catch (IllegalArgumentException e) {
+      throw new InvalidFormatException(e.getMessage(), e);
+    }
   }
 
   @Override
@@ -115,16 +169,21 @@ public class TokenizerFactory extends BaseToolFactory {
   }
 
   @Override
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
   public Map<String, String> createManifestEntries() {
     Map<String, String> manifestEntries = super.createManifestEntries();
 
     manifestEntries.put(USE_ALPHA_NUMERIC_OPTIMIZATION,
         Boolean.toString(isUseAlphaNumericOptimization()));
-
-    // alphanumeric pattern is optional
-    if (getAlphaNumericPattern() != null) {
-      manifestEntries.put(ALPHA_NUMERIC_PATTERN, 
getAlphaNumericPattern().pattern());
+    resolve();
+    if (this.alphaNumericPattern != null) {
+      manifestEntries.put(ALPHA_NUMERIC_PATTERN, 
patternText(this.alphaNumericPattern));
+    } else {
+      manifestEntries.put(CHARACTER_POLICY, this.characterPolicy.toString());
+      // Releases before 3.0 read only the pattern; give them the one with the 
same characters.
+      final String legacy = legacyExpression(this.characterPolicy);
+      if (legacy != null) {
+        manifestEntries.put(ALPHA_NUMERIC_PATTERN, legacy);
+      }
     }
 
     return manifestEntries;
@@ -138,29 +197,28 @@ public class TokenizerFactory extends BaseToolFactory {
    * @param abbreviationDictionary An optional {@link Dictionary} containing 
abbreviations,
    *                               or {@code null} if not present.
    * @param useAlphaNumericOptimization Whether the alphanumeric optimization 
is be enabled or not.
-   * @param alphaNumericPattern The {@link Pattern} the alphanumeric 
optimization should use,
-   *                            if enabled.
+   * @param characterPolicy The {@link TokenizerCharacterPolicy} the 
alphanumeric optimization
+   *                        should use, if enabled, or {@code null} for the 
language default.
    *
    * @return A valid {@link TokenizerFactory} instance.
    *
    * @throws InvalidFormatException Thrown if one of the input parameters 
doesn't comply the expected format.
    */
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
   public static TokenizerFactory create(String subclassName, String 
languageCode,
                                         Dictionary abbreviationDictionary,
                                         boolean useAlphaNumericOptimization,
-                                        Pattern alphaNumericPattern)
+                                        TokenizerCharacterPolicy 
characterPolicy)
       throws InvalidFormatException {
     if (subclassName == null) {
       // will create the default factory
       return new TokenizerFactory(languageCode, abbreviationDictionary,
-          useAlphaNumericOptimization, alphaNumericPattern);
+          useAlphaNumericOptimization, characterPolicy);
     }
     try {
       TokenizerFactory theFactory = ExtensionLoader.instantiateExtension(
           TokenizerFactory.class, subclassName);
       theFactory.init(languageCode, abbreviationDictionary,
-          useAlphaNumericOptimization, alphaNumericPattern);
+          useAlphaNumericOptimization, characterPolicy);
       return theFactory;
     } catch (Exception e) {
       String msg = "Could not instantiate the " + subclassName
@@ -170,24 +228,101 @@ public class TokenizerFactory extends BaseToolFactory {
   }
 
   /**
-   * @return Retrieves the (user-)specified alphanumeric {@link Pattern} or a 
default.
+   * Factory method the framework uses instantiate a new {@link 
TokenizerFactory} with the
+   * {@link TokenizerCharacterPolicy} of its language.
+   *
+   * @param subclassName The name of the class implementing the {@link 
TokenizerFactory}.
+   * @param languageCode The ISO language code the {@link Tokenizer} should 
use.
+   * @param abbreviationDictionary An optional {@link Dictionary} containing 
abbreviations,
+   *                               or {@code null} if not present.
+   * @param useAlphaNumericOptimization Whether the alphanumeric optimization 
is be enabled or not.
+   *
+   * @return A valid {@link TokenizerFactory} instance.
+   *
+   * @throws InvalidFormatException Thrown if one of the input parameters 
doesn't comply the expected format.
    */
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
+  public static TokenizerFactory create(String subclassName, String 
languageCode,
+                                        Dictionary abbreviationDictionary,
+                                        boolean useAlphaNumericOptimization)
+      throws InvalidFormatException {
+    return create(subclassName, languageCode, abbreviationDictionary,
+        useAlphaNumericOptimization, (TokenizerCharacterPolicy) null);
+  }
+
+  /**
+   * Factory method the framework uses instantiate a new {@link 
TokenizerFactory} whose
+   * alphanumeric optimization tests candidate tokens with a regular 
expression.
+   *
+   * @param subclassName The name of the class implementing the {@link 
TokenizerFactory}.
+   * @param languageCode The ISO language code the {@link Tokenizer} should 
use.
+   * @param abbreviationDictionary An optional {@link Dictionary} containing 
abbreviations,
+   *                               or {@code null} if not present.
+   * @param useAlphaNumericOptimization Whether the alphanumeric optimization 
is be enabled or not.
+   * @param alphaNumericPattern {@code null} for the policy of {@code 
languageCode}, or a custom
+   *                            alphanumeric {@link Pattern}.
+   *
+   * @return A valid {@link TokenizerFactory} instance.
+   *
+   * @throws InvalidFormatException Thrown if one of the input parameters 
doesn't comply the expected format.
+   * @deprecated Use
+   *     {@link #create(String, String, Dictionary, boolean, 
TokenizerCharacterPolicy)}. This
+   *     method will be removed in 4.0.
+   */
+  @Deprecated(since = "3.0.0", forRemoval = true)
+  @SuppressForbidden("user-supplied alphanumeric pattern, kept for the 
deprecated API")
+  public static TokenizerFactory create(String subclassName, String 
languageCode,
+                                        Dictionary abbreviationDictionary,
+                                        boolean useAlphaNumericOptimization,
+                                        Pattern alphaNumericPattern)
+      throws InvalidFormatException {
+    TokenizerFactory factory = create(subclassName, languageCode, 
abbreviationDictionary,
+        useAlphaNumericOptimization);
+    factory.alphaNumericPattern = alphaNumericPattern;
+    return factory;
+  }
+
+  /**
+   * @return Retrieves the (user-)specified {@link TokenizerCharacterPolicy}, 
the one read from
+   *         the model manifest, or the default for the language in use; 
{@code null} if this
+   *         factory tests candidate tokens with a custom alphanumeric {@link 
Pattern}.
+   * @throws IllegalArgumentException Thrown if the manifest has a character 
policy or an
+   *         alphanumeric pattern that cannot be read.
+   */
+  public TokenizerCharacterPolicy getCharacterPolicy() {
+    resolve();
+    return this.characterPolicy;
+  }
+
+  /**
+   * @return Retrieves the custom alphanumeric {@link Pattern} of this 
factory, or else the pattern
+   *         of earlier releases that accepts the same characters as its
+   *         {@link TokenizerCharacterPolicy}; {@code null} for a custom 
policy that no such
+   *         pattern matches.
+   * @throws IllegalArgumentException Thrown if the manifest has a character 
policy or an
+   *         alphanumeric pattern that cannot be read.
+   * @deprecated Use {@link #getCharacterPolicy()}. This method will be 
removed in 4.0.
+   */
+  @Deprecated(since = "3.0.0", forRemoval = true)
+  @SuppressForbidden("user-supplied alphanumeric pattern, kept for the 
deprecated API")
   public Pattern getAlphaNumericPattern() {
-    if (this.alphaNumericPattern == null) {
-      if (this.artifactProvider != null) {
-        String prop = 
this.artifactProvider.getManifestProperty(ALPHA_NUMERIC_PATTERN);
-        if (prop != null) {
-          this.alphaNumericPattern = Pattern.compile(prop);
-        }
-      }
-      // could not load from manifest, will get from language dependent factory
-      if (this.alphaNumericPattern == null) {
-        Factory f = new Factory();
-        this.alphaNumericPattern = f.getAlphanumeric(languageCode);
-      }
+    resolve();
+    if (this.alphaNumericPattern != null) {
+      return this.alphaNumericPattern;
     }
-    return this.alphaNumericPattern;
+    final String legacy = legacyExpression(this.characterPolicy);
+    return legacy == null ? null : Pattern.compile(legacy);
+  }
+
+  /**
+   * {@return the test the alphanumeric optimization applies to a candidate 
token}
+   *
+   * @throws IllegalArgumentException Thrown if the manifest has a character 
policy or an
+   *         alphanumeric pattern that cannot be read.
+   */
+  Predicate<CharSequence> alphanumericTest() {
+    resolve();
+    return this.alphaNumericPattern != null
+        ? new PatternTest(this.alphaNumericPattern) : this.characterPolicy;
   }
 
   /**
@@ -235,4 +370,130 @@ public class TokenizerFactory extends BaseToolFactory {
     }
     return f.createTokenContextGenerator(getLanguageCode(), abbs);
   }
+
+  /**
+   * Sets the character policy or the alphanumeric pattern from the model 
manifest if neither is
+   * set yet, and falls back to the policy of the language.
+   *
+   * @throws IllegalArgumentException Thrown if the manifest has a character 
policy or an
+   *         alphanumeric pattern that cannot be read.
+   */
+  private void resolve() {
+    if (this.characterPolicy == null && this.alphaNumericPattern == null) {
+      if (this.artifactProvider != null) {
+        readManifest();
+      }
+      if (this.characterPolicy == null && this.alphaNumericPattern == null) {
+        this.characterPolicy = new 
Factory().getCharacterPolicy(getLanguageCode());
+      }
+    }
+  }
+
+  /**
+   * Reads the character policy, or else the alphanumeric pattern, from the 
model manifest. A
+   * pattern that releases before 3.0 wrote for a language is replaced by the 
policy that accepts
+   * the same characters; any other pattern is kept and evaluated as a regular 
expression.
+   *
+   * @throws IllegalArgumentException Thrown if the character policy text 
cannot be read, or if
+   *         the alphanumeric pattern is not a valid regular expression.
+   */
+  @SuppressForbidden("user-supplied alphanumeric pattern, kept for the 
deprecated API")
+  private void readManifest() {
+    String text = this.artifactProvider.getManifestProperty(CHARACTER_POLICY);
+    if (text != null) {
+      try {
+        this.characterPolicy = TokenizerCharacterPolicy.parse(text);
+        return;
+      } catch (IllegalArgumentException e) {
+        throw new IllegalArgumentException(describeModel() + " has an 
unreadable "
+            + CHARACTER_POLICY + ": " + e.getMessage(), e);
+      }
+    }
+    String pattern = 
this.artifactProvider.getManifestProperty(ALPHA_NUMERIC_PATTERN);
+    if (pattern == null) {
+      return;
+    }
+    this.characterPolicy = legacyPolicy(pattern);
+    if (this.characterPolicy == null) {
+      try {
+        this.alphaNumericPattern = Pattern.compile(pattern);
+      } catch (PatternSyntaxException e) {
+        throw new IllegalArgumentException(describeModel() + " has an invalid "
+            + ALPHA_NUMERIC_PATTERN + " '" + pattern + "'", e);
+      }
+    }
+  }
+
+  /**
+   * {@return the policy of the language whose expression of an earlier 
release is
+   * {@code expression}, or {@code null} if there is none}
+   *
+   * @param expression An alphanumeric expression from a model manifest.
+   */
+  @SuppressWarnings("removal")
+  @SuppressForbidden("compares with the alphanumeric patterns of earlier 
releases")
+  private static TokenizerCharacterPolicy legacyPolicy(String expression) {
+    final Factory factory = new Factory();
+    for (String language : LEGACY_LANGUAGES) {
+      if (factory.getAlphanumeric(language).pattern().equals(expression)) {
+        return factory.getCharacterPolicy(language);
+      }
+    }
+    return null;
+  }
+
+  /**
+   * {@return the expression of an earlier release that accepts the same 
characters as
+   * {@code policy}, or {@code null} if there is none}
+   *
+   * @param policy A character policy.
+   */
+  @SuppressWarnings("removal")
+  @SuppressForbidden("compares with the alphanumeric patterns of earlier 
releases")
+  private static String legacyExpression(TokenizerCharacterPolicy policy) {
+    final Factory factory = new Factory();
+    for (String language : LEGACY_LANGUAGES) {
+      if (factory.getCharacterPolicy(language).equals(policy)) {
+        return factory.getAlphanumeric(language).pattern();
+      }
+    }
+    return null;
+  }
+
+  /**
+   * {@return the text of {@code pattern}}
+   *
+   * @param pattern An alphanumeric pattern.
+   */
+  @SuppressForbidden("user-supplied alphanumeric pattern, kept for the 
deprecated API")
+  private static String patternText(Pattern pattern) {
+    return pattern.pattern();
+  }
+
+  /**
+   * Tests a candidate token against a whole-match regular expression, for 
factories and event
+   * streams configured with a custom alphanumeric {@link Pattern}.
+   */
+  @SuppressForbidden("user-supplied alphanumeric pattern, kept for the 
deprecated API")
+  static final class PatternTest implements Predicate<CharSequence> {
+
+    private final Pattern pattern;
+
+    /**
+     * @param pattern The alphanumeric pattern.
+     */
+    PatternTest(Pattern pattern) {
+      this.pattern = pattern;
+    }
+
+    @Override
+    public boolean test(CharSequence candidate) {
+      return pattern.matcher(candidate).matches();
+    }
+  }
+
+  /** {@return a description of the loaded model for error messages} */
+  private String describeModel() {
+    return "The tokenizer model for language '" + 
this.artifactProvider.getLanguage() + "'";
+  }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerME.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerME.java
index 256fea895..8aa9fe8fc 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerME.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/TokenizerME.java
@@ -22,9 +22,8 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.regex.Pattern;
+import java.util.function.Predicate;
 
-import opennlp.tools.commons.SuppressForbidden;
 import opennlp.tools.commons.ThreadSafe;
 import opennlp.tools.dictionary.Dictionary;
 import opennlp.tools.ml.ArrayMath;
@@ -103,8 +102,10 @@ public class TokenizerME extends AbstractTokenizer 
implements Probabilistic {
    */
   public static final String NO_SPLIT = "F";
 
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
-  private final Pattern alphanumeric;
+  /*
+   * The test that decides which tokens the alphanumeric optimization accepts.
+   */
+  private final Predicate<CharSequence> alphanumeric;
 
   /*
    * The maximum entropy model to use to evaluate contexts.
@@ -167,7 +168,7 @@ public class TokenizerME extends AbstractTokenizer 
implements Probabilistic {
     this.abbDict = abbDict;
     TokenizerFactory factory = model.getFactory();
     this.cg = factory.getContextGenerator();
-    this.alphanumeric = factory.getAlphaNumericPattern();
+    this.alphanumeric = factory.alphanumericTest();
     this.useAlphaNumericOptimization = factory.isUseAlphaNumericOptimization();
   }
 
@@ -202,7 +203,6 @@ public class TokenizerME extends AbstractTokenizer 
implements Probabilistic {
    * @return   A {@link Span} array containing individual tokens as elements.
    */
   @Override
-  @SuppressForbidden("user-supplied alphanumeric pattern stored in the 
tokenizer model")
   public Span[] tokenizePos(String d) {
     Span[] tokens = (keepNewLines ? WHITESPACE_KEEPING_NEW_LINES : 
WHITESPACE).tokenizePos(d);
     List<Span> localTokens = new ArrayList<>();
@@ -213,7 +213,7 @@ public class TokenizerME extends AbstractTokenizer 
implements Probabilistic {
       if (tok.length() < 2) {
         localTokens.add(s);
         localProbs.add(1d);
-      } else if (useAlphaNumericOptimization() && 
alphanumeric.matcher(tok).matches()) {
+      } else if (useAlphaNumericOptimization() && alphanumeric.test(tok)) {
         localTokens.add(s);
         localProbs.add(1d);
       } else {
@@ -283,7 +283,7 @@ public class TokenizerME extends AbstractTokenizer 
implements Probabilistic {
 
     ObjectStream<Event> eventStream = new TokSpanEventStream(samples,
         factory.isUseAlphaNumericOptimization(),
-        factory.getAlphaNumericPattern(), factory.getContextGenerator());
+        factory.alphanumericTest(), factory.getContextGenerator());
 
     EventTrainer<TrainingParameters> trainer = TrainerFactory.getEventTrainer(
         mlParams, manifestInfoEntries);
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lang/Factory.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lang/Factory.java
index 99c53d8f3..1faf59ee3 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lang/Factory.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lang/Factory.java
@@ -17,64 +17,94 @@
 
 package opennlp.tools.tokenize.lang;
 
+import java.util.Map;
 import java.util.Set;
 import java.util.regex.Pattern;
 
 import opennlp.tools.commons.SuppressForbidden;
 import opennlp.tools.tokenize.DefaultTokenContextGenerator;
 import opennlp.tools.tokenize.TokenContextGenerator;
+import opennlp.tools.tokenize.TokenizerCharacterPolicy;
+import opennlp.tools.util.normalizer.CodePointSet;
 
+/**
+ * Provides the language dependent tokenizer resources: the {@link 
TokenizerCharacterPolicy}
+ * that decides which tokens the alphanumeric optimization accepts without 
further splitting,
+ * and the {@link TokenContextGenerator}.
+ */
 public class Factory {
 
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  public static final Pattern DEFAULT_ALPHANUMERIC = compile("^[A-Za-z0-9]+$");
+  /**
+   * The policy for languages without a dedicated one: ASCII letters and 
digits, no marks.
+   */
+  public static final TokenizerCharacterPolicy DEFAULT_CHARACTER_POLICY =
+      TokenizerCharacterPolicy.ascii();
+
+  private static final String ASCII_EXPRESSION = "^[A-Za-z0-9]+$";
+
+  /**
+   * The alphanumeric pattern of earlier releases for languages without a 
dedicated one.
+   *
+   * @deprecated Use {@link #DEFAULT_CHARACTER_POLICY}, which accepts the same 
characters. This
+   *     constant will be removed in 4.0.
+   */
+  @Deprecated(since = "3.0.0", forRemoval = true)
+  @SuppressForbidden("alphanumeric pattern of earlier releases, kept for the 
deprecated API")
+  public static final Pattern DEFAULT_ALPHANUMERIC = compile(ASCII_EXPRESSION);
+
+  private static final CodePointSet ASCII_LETTERS =
+      CodePointSet.ofRange('A', 'Z').union(CodePointSet.ofRange('a', 'z'));
+  private static final CodePointSet ASCII_DIGITS = CodePointSet.ofRange('0', 
'9');
+  private static final CodePointSet NO_MARKS = CodePointSet.of();
 
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  private static final Pattern PORTUGUESE = 
compile("^[0-9a-záãâàéêíóõôúüçA-ZÁÃÂÀÉÊÍÓÕÔÚÜÇ]+$");
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  private static final Pattern FRENCH = 
compile("^[a-zA-Z0-9àâäèéêëîïôœùûüÿçÀÂÄÈÉÊËÎÏÔŒÙÛÜŸÇ]+$");
+  private static final TokenizerCharacterPolicy PORTUGUESE = 
latinPolicy("áãâàéêíóõôúüçÁÃÂÀÉÊÍÓÕÔÚÜÇ");
+  private static final TokenizerCharacterPolicy FRENCH = 
latinPolicy("àâäèéêëîïôœùûüÿçÀÂÄÈÉÊËÎÏÔŒÙÛÜŸÇ");
 
   // From: https://www.sttmedia.com/characterfrequency-dutch
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  private static final Pattern DUTCH = 
compile("^[A-Za-z0-9äöüëèéïijÄÖÜËÉÈÏIJ]+$");
+  private static final TokenizerCharacterPolicy DUTCH = 
latinPolicy("äöüëèéïijÄÖÜËÉÈÏIJ");
 
   // Note: The extra é and É are included to cover German "Lehnwörter" such as 
"Café"
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  private static final Pattern GERMAN = compile("^[A-Za-z0-9äéöüÄÉÖÜß]+$");
+  private static final TokenizerCharacterPolicy GERMAN = 
latinPolicy("äéöüÄÉÖÜß");
 
   // From: https://en.wikipedia.org/wiki/Polish_alphabet
   //       https://pl.wikipedia.org/wiki/Alfabet_polski
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  private static final Pattern POLISH = 
compile("^[A-Za-z0-9żźćńółęąśŻŹĆĄŚĘŁÓŃ]+$");
+  private static final TokenizerCharacterPolicy POLISH = 
latinPolicy("żźćńółęąśŻŹĆĄŚĘŁÓŃ");
 
   // From: https://it.wikipedia.org/wiki/Alfabeto_italiano
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  private static final Pattern ITALIAN = 
compile("^[0-9a-zàèéìîíòóùüA-ZÀÈÉÌÎÍÒÓÙÜ]+$");
+  private static final TokenizerCharacterPolicy ITALIAN = 
latinPolicy("àèéìîíòóùüÀÈÉÌÎÍÒÓÙÜ");
 
   // From: https://en.wikiversity.org/wiki/Alphabet/Spanish_alphabet &
   //       
https://en.wikipedia.org/wiki/Spanish_orthography#Alphabet_in_Spanish &
   //       https://www.fundeu.es/consulta/tilde-en-la-y-y-griega-o-ye-24786/
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  private static final Pattern SPANISH = 
compile("^[0-9a-záéíóúüýñA-ZÁÉÍÓÚÝÑ]+$");
+  private static final TokenizerCharacterPolicy SPANISH = 
latinPolicy("áéíóúüýñÁÉÍÓÚÝÑ");
 
   // From: https://en.wikipedia.org/wiki/Catalan_orthography#Spelling_patterns
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  private static final Pattern CATALAN = 
compile("^[0-9a-zàèéíïòóúüçA-ZÀÈÉÍÏÒÓÚÜÇ]+$");
-  
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  private static Pattern compile(String regex) {
-    return Pattern.compile(regex);
-  }
+  private static final TokenizerCharacterPolicy CATALAN = 
latinPolicy("àèéíïòóúüçÀÈÉÍÏÒÓÚÜÇ");
+
+  /**
+   * The alphanumeric expressions of releases before 3.0, keyed by the policy 
that accepts the same
+   * characters.
+   */
+  private static final Map<TokenizerCharacterPolicy, String> 
LEGACY_EXPRESSIONS = Map.of(
+      DEFAULT_CHARACTER_POLICY, ASCII_EXPRESSION,
+      PORTUGUESE, "^[0-9a-záãâàéêíóõôúüçA-ZÁÃÂÀÉÊÍÓÕÔÚÜÇ]+$",
+      FRENCH, "^[a-zA-Z0-9àâäèéêëîïôœùûüÿçÀÂÄÈÉÊËÎÏÔŒÙÛÜŸÇ]+$",
+      DUTCH, "^[A-Za-z0-9äöüëèéïijÄÖÜËÉÈÏIJ]+$",
+      GERMAN, "^[A-Za-z0-9äéöüÄÉÖÜß]+$",
+      POLISH, "^[A-Za-z0-9żźćńółęąśŻŹĆĄŚĘŁÓŃ]+$",
+      ITALIAN, "^[0-9a-zàèéìîíòóùüA-ZÀÈÉÌÎÍÒÓÙÜ]+$",
+      SPANISH, "^[0-9a-záéíóúüýñA-ZÁÉÍÓÚÝÑ]+$",
+      CATALAN, "^[0-9a-zàèéíïòóúüçA-ZÀÈÉÍÏÒÓÚÜÇ]+$");
 
   /**
-   * Gets the alphanumeric pattern for a language.
+   * Gets the character policy for a language.
    *
-   * @param languageCode The ISO_639-1 code. If {@code null}, or unknown, the
-   *                     {@link #DEFAULT_ALPHANUMERIC} pattern will be 
returned.
-   * @return The alphanumeric {@link Pattern} for the language, or the default 
pattern.
+   * @param languageCode An ISO 639-1 code, or an ISO 639-2 code in its 
terminology or
+   *                     bibliographic form ({@code deu} or {@code ger}). If 
{@code null}, or unknown, the
+   *                     {@link #DEFAULT_CHARACTER_POLICY} will be returned.
+   * @return The {@link TokenizerCharacterPolicy} for the language, or the 
default policy.
    */
-  @SuppressForbidden("default alphanumeric patterns of the tokenizer model")
-  public Pattern getAlphanumeric(String languageCode) {
+  public TokenizerCharacterPolicy getCharacterPolicy(String languageCode) {
     // For reference: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
     if ("es".equals(languageCode) || "spa".equals(languageCode)) {
       return SPANISH;
@@ -101,12 +131,38 @@ public class Factory {
       return DUTCH;
     }
 
-    return DEFAULT_ALPHANUMERIC;
+    return DEFAULT_CHARACTER_POLICY;
+  }
+
+  /**
+   * Gets the alphanumeric pattern that releases before 3.0 used for a 
language.
+   *
+   * @param languageCode An ISO 639-1 code, or an ISO 639-2 code in its 
terminology or
+   *                     bibliographic form. If {@code null}, or unknown, the
+   *                     {@link #DEFAULT_ALPHANUMERIC} pattern will be 
returned.
+   * @return The {@link Pattern} that accepts the same characters as
+   *     {@link #getCharacterPolicy(String)} for the language.
+   * @deprecated Use {@link #getCharacterPolicy(String)}. This method will be 
removed in 4.0.
+   */
+  @Deprecated(since = "3.0.0", forRemoval = true)
+  @SuppressForbidden("alphanumeric pattern of earlier releases, kept for the 
deprecated API")
+  public Pattern getAlphanumeric(String languageCode) {
+    return compile(LEGACY_EXPRESSIONS.get(getCharacterPolicy(languageCode)));
+  }
+
+  /**
+   * {@return the compiled alphanumeric expression of an earlier release}
+   *
+   * @param expression The expression.
+   */
+  @SuppressForbidden("alphanumeric pattern of earlier releases, kept for the 
deprecated API")
+  private static Pattern compile(String expression) {
+    return Pattern.compile(expression);
   }
 
   /**
    * Initializes a customized {@link TokenContextGenerator} via a set of 
{@code abbreviations}.
-   * 
+   *
    * @param languageCode The ISO_639-1 code to be used.
    * @param abbreviations The abbreviations to be used for new instance.
    */
@@ -114,4 +170,19 @@ public class Factory {
     return new DefaultTokenContextGenerator(abbreviations);
   }
 
+  /**
+   * Builds a policy of the ASCII letters plus {@code extraLetters}, the ASCII 
digits, and no marks.
+   *
+   * @param extraLetters The letters of a Latin script language beyond ASCII.
+   * @return The policy.
+   */
+  private static TokenizerCharacterPolicy latinPolicy(String extraLetters) {
+    CodePointSet letters = ASCII_LETTERS;
+    for (int offset = 0; offset < extraLetters.length();) {
+      final int codePoint = extraLetters.codePointAt(offset);
+      letters = letters.union(CodePointSet.of(codePoint));
+      offset += Character.charCount(codePoint);
+    }
+    return TokenizerCharacterPolicy.of(letters, ASCII_DIGITS, NO_MARKS);
+  }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ThreadSafetyBenchmarkIT.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ThreadSafetyBenchmarkIT.java
index 1077204e2..72d78ab36 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ThreadSafetyBenchmarkIT.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ThreadSafetyBenchmarkIT.java
@@ -386,7 +386,7 @@ public class ThreadSafetyBenchmarkIT {
     TrainingParameters p = new TrainingParameters();
     p.put(Parameters.ITERATIONS_PARAM, 100);
     p.put(Parameters.CUTOFF_PARAM, 0);
-    return TokenizerME.train(samples, TokenizerFactory.create(null, "eng", 
null, true, null), p);
+    return TokenizerME.train(samples, TokenizerFactory.create(null, "eng", 
null, true), p);
   }
 
   /** Trains a sentence model from bundled {@code Sentences.txt}. */
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DummyTokenizerFactory.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DummyTokenizerFactory.java
index dab0577ba..773d18743 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DummyTokenizerFactory.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DummyTokenizerFactory.java
@@ -22,7 +22,6 @@ import java.io.InputStream;
 import java.io.OutputStream;
 import java.util.Map;
 import java.util.Set;
-import java.util.regex.Pattern;
 
 import opennlp.tools.dictionary.Dictionary;
 import opennlp.tools.util.model.ArtifactSerializer;
@@ -37,16 +36,16 @@ public class DummyTokenizerFactory extends TokenizerFactory 
{
 
   public DummyTokenizerFactory(String languageCode,
       Dictionary abbreviationDictionary, boolean useAlphaNumericOptimization,
-      Pattern alphaNumericPattern) {
+      TokenizerCharacterPolicy characterPolicy) {
     super(languageCode, abbreviationDictionary, useAlphaNumericOptimization,
-        alphaNumericPattern);
+        characterPolicy);
   }
 
   @Override
   protected void init(String languageCode, Dictionary abbreviationDictionary,
-      boolean useAlphaNumericOptimization, Pattern alphaNumericPattern) {
+      boolean useAlphaNumericOptimization, TokenizerCharacterPolicy 
characterPolicy) {
     super.init(languageCode, abbreviationDictionary, 
useAlphaNumericOptimization,
-        alphaNumericPattern);
+        characterPolicy);
     this.dict = new DummyDictionary(abbreviationDictionary);
   }
 
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/LegacyAlphaNumericPatterns.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/LegacyAlphaNumericPatterns.java
new file mode 100644
index 000000000..cad3dd093
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/LegacyAlphaNumericPatterns.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.tokenize;
+
+import java.util.Map;
+
+/**
+ * The alphanumeric regular expressions that OpenNLP wrote into tokenizer 
model manifests up
+ * to 2.x, keyed by the language code of the policy that replaces each one. 
Tests use them as
+ * the oracle for the policies and as legacy manifest input.
+ */
+public final class LegacyAlphaNumericPatterns {
+
+  public static final String DEFAULT = "^[A-Za-z0-9]+$";
+  public static final String PORTUGUESE = 
"^[0-9a-záãâàéêíóõôúüçA-ZÁÃÂÀÉÊÍÓÕÔÚÜÇ]+$";
+  public static final String FRENCH = 
"^[a-zA-Z0-9àâäèéêëîïôœùûüÿçÀÂÄÈÉÊËÎÏÔŒÙÛÜŸÇ]+$";
+  public static final String DUTCH = "^[A-Za-z0-9äöüëèéïijÄÖÜËÉÈÏIJ]+$";
+  public static final String GERMAN = "^[A-Za-z0-9äéöüÄÉÖÜß]+$";
+  public static final String POLISH = "^[A-Za-z0-9żźćńółęąśŻŹĆĄŚĘŁÓŃ]+$";
+  public static final String ITALIAN = "^[0-9a-zàèéìîíòóùüA-ZÀÈÉÌÎÍÒÓÙÜ]+$";
+  public static final String SPANISH = "^[0-9a-záéíóúüýñA-ZÁÉÍÓÚÝÑ]+$";
+  public static final String CATALAN = "^[0-9a-zàèéíïòóúüçA-ZÀÈÉÍÏÒÓÚÜÇ]+$";
+
+  /**
+   * Each language code with the regular expression its policy replaces.
+   */
+  public static final Map<String, String> BY_LANGUAGE = Map.of(
+      "en", DEFAULT,
+      "pt", PORTUGUESE,
+      "fr", FRENCH,
+      "nl", DUTCH,
+      "de", GERMAN,
+      "pl", POLISH,
+      "it", ITALIAN,
+      "es", SPANISH,
+      "ca", CATALAN);
+
+  private LegacyAlphaNumericPatterns() {
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokSpanEventStreamTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokSpanEventStreamTest.java
index c9dd6ea90..2442a259b 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokSpanEventStreamTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokSpanEventStreamTest.java
@@ -18,19 +18,32 @@
 package opennlp.tools.tokenize;
 
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import opennlp.tools.ml.model.Event;
+import opennlp.tools.tokenize.lang.Factory;
 import opennlp.tools.util.ObjectStream;
 import opennlp.tools.util.ObjectStreamUtils;
+import opennlp.tools.util.normalizer.CodePointSet;
 
 /**
  * Tests for the {@link TokSpanEventStream} class.
  */
 public class TokSpanEventStreamTest {
 
+  private static final String SPLIT_SAMPLE = "ab<SPLIT>cd";
+  private static final String NON_ASCII_SPLIT_SAMPLE = "\u00E4b<SPLIT>cd";
+  private static final List<String> SPLIT_SAMPLE_OUTCOMES =
+      List.of(TokenizerME.NO_SPLIT, TokenizerME.SPLIT, TokenizerME.NO_SPLIT);
+  private static final TokenizerCharacterPolicy X_ONLY = 
TokenizerCharacterPolicy.of(
+      CodePointSet.of('x'), CodePointSet.of(), CodePointSet.of());
+
   /**
    * Tests the event stream for correctly generated outcomes.
    */
@@ -54,4 +67,105 @@ public class TokSpanEventStreamTest {
       Assertions.assertNull(eventStream.read());
     }
   }
+
+  @Test
+  void testPolicyConstructorSkipsCandidatesThePolicyAccepts() throws 
IOException {
+    Assertions.assertEquals(List.of(), outcomes(SPLIT_SAMPLE, true, 
TokenizerCharacterPolicy.ascii()));
+  }
+
+  @Test
+  void testPolicyConstructorKeepsCandidatesThePolicyRejects() throws 
IOException {
+    Assertions.assertEquals(SPLIT_SAMPLE_OUTCOMES, outcomes(SPLIT_SAMPLE, 
true, X_ONLY));
+  }
+
+  @Test
+  void testPolicyIsIgnoredWhenAlphanumericsAreNotSkipped() throws IOException {
+    Assertions.assertEquals(SPLIT_SAMPLE_OUTCOMES,
+        outcomes(SPLIT_SAMPLE, false, TokenizerCharacterPolicy.ascii()));
+  }
+
+  @ParameterizedTest
+  @CsvSource({
+      SPLIT_SAMPLE + ", 0",
+      NON_ASCII_SPLIT_SAMPLE + ", 3"})
+  void testContextGeneratorConstructorUsesTheDefaultPolicy(String sample, int 
expectedEvents)
+      throws IOException {
+    ObjectStream<TokenSample> samples = new TokenSampleStream(
+        ObjectStreamUtils.createObjectStream(sample));
+
+    try (ObjectStream<Event> eventStream = new TokSpanEventStream(samples, 
true,
+        new DefaultTokenContextGenerator())) {
+      Assertions.assertEquals(expectedEvents, drain(eventStream).size());
+    }
+  }
+
+  @Test
+  void testDefaultPolicyIsTheFactoryDefault() throws IOException {
+    Assertions.assertEquals(outcomes(NON_ASCII_SPLIT_SAMPLE, true, 
Factory.DEFAULT_CHARACTER_POLICY),
+        outcomes(NON_ASCII_SPLIT_SAMPLE, true, 
TokenizerCharacterPolicy.ascii()));
+  }
+
+  @Test
+  void testNullPolicyIsRejected() {
+    ObjectStream<TokenSample> samples = new TokenSampleStream(
+        ObjectStreamUtils.createObjectStream(SPLIT_SAMPLE));
+
+    IllegalArgumentException e = 
Assertions.assertThrows(IllegalArgumentException.class,
+        () -> new TokSpanEventStream(samples, true, (TokenizerCharacterPolicy) 
null,
+            new DefaultTokenContextGenerator()));
+    Assertions.assertEquals("characterPolicy must not be null", 
e.getMessage());
+  }
+
+  /**
+   * The deprecated constructor with the ASCII pattern of earlier releases 
produces the events of
+   * the ASCII policy.
+   */
+  @Test
+  @SuppressWarnings("removal")
+  void testDeprecatedPatternConstructorMatchesTheAsciiPolicy() throws 
IOException {
+    ObjectStream<TokenSample> patternSamples = new TokenSampleStream(
+        ObjectStreamUtils.createObjectStream(SPLIT_SAMPLE, 
NON_ASCII_SPLIT_SAMPLE));
+    ObjectStream<TokenSample> policySamples = new TokenSampleStream(
+        ObjectStreamUtils.createObjectStream(SPLIT_SAMPLE, 
NON_ASCII_SPLIT_SAMPLE));
+    try (ObjectStream<Event> byPattern = new 
TokSpanEventStream(patternSamples, true,
+            java.util.regex.Pattern.compile("^[A-Za-z0-9]+$"), new 
DefaultTokenContextGenerator());
+         ObjectStream<Event> byPolicy = new TokSpanEventStream(policySamples, 
true,
+            TokenizerCharacterPolicy.ascii(), new 
DefaultTokenContextGenerator())) {
+      int events = 0;
+      Event expected;
+      while ((expected = byPolicy.read()) != null) {
+        events++;
+        Event actual = byPattern.read();
+        Assertions.assertNotNull(actual);
+        Assertions.assertEquals(expected.getOutcome(), actual.getOutcome());
+        Assertions.assertArrayEquals(expected.getContext(), 
actual.getContext());
+      }
+      Assertions.assertNull(byPattern.read());
+      Assertions.assertTrue(events > 0, "the non-ASCII sample must produce 
events");
+    }
+  }
+
+  private static List<String> outcomes(String sample, boolean 
skipAlphaNumerics,
+      TokenizerCharacterPolicy policy) throws IOException {
+    ObjectStream<TokenSample> samples = new TokenSampleStream(
+        ObjectStreamUtils.createObjectStream(sample));
+
+    try (ObjectStream<Event> eventStream = new TokSpanEventStream(samples, 
skipAlphaNumerics,
+        policy, new DefaultTokenContextGenerator())) {
+      List<String> outcomes = new ArrayList<>();
+      for (Event event : drain(eventStream)) {
+        outcomes.add(event.getOutcome());
+      }
+      return outcomes;
+    }
+  }
+
+  private static List<Event> drain(ObjectStream<Event> eventStream) throws 
IOException {
+    List<Event> events = new ArrayList<>();
+    Event event;
+    while ((event = eventStream.read()) != null) {
+      events.add(event);
+    }
+    return events;
+  }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerCharacterPolicyTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerCharacterPolicyTest.java
index 853a35860..ff225b5d1 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerCharacterPolicyTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerCharacterPolicyTest.java
@@ -24,6 +24,7 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.NullSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import opennlp.tools.util.normalizer.CodePointSet;
@@ -32,6 +33,7 @@ import opennlp.tools.util.normalizer.UnicodeWhitespace;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -41,6 +43,7 @@ public class TokenizerCharacterPolicyTest {
   private static final int MUSICAL_MARK = 0x1D165;
   private static final int MATHEMATICAL_DIGIT = 0x1D7D8;
   private static final int ACUTE_ACCENT = 0x0301;
+  private static final String ASCII_TEXT = 
"letters=0041-005A,0061-007A;digits=0030-0039;marks=";
 
   private static final CodePointSet A = CodePointSet.of('a');
   private static final CodePointSet SEVEN = CodePointSet.of('7');
@@ -246,6 +249,136 @@ public class TokenizerCharacterPolicyTest {
     }
   }
 
+  @Test
+  void testAsciiPresetHasAFixedTextForm() {
+    assertEquals(ASCII_TEXT, TokenizerCharacterPolicy.ascii().toString());
+  }
+
+  @Test
+  void testTextFormWritesSupplementaryCodePointsWithFiveDigits() {
+    TokenizerCharacterPolicy policy = TokenizerCharacterPolicy.of(
+        CodePointSet.of(DESERET_LETTER), EMPTY, CodePointSet.of(MUSICAL_MARK));
+
+    assertEquals("letters=10400;digits=;marks=1D165", policy.toString());
+  }
+
+  @Test
+  void testTextFormJoinsAdjacentCodePointsIntoRanges() {
+    TokenizerCharacterPolicy policy = TokenizerCharacterPolicy.of(
+        CodePointSet.of('a', 'b', 'c', 'e', 'g', 'h'), CodePointSet.of('7'), 
EMPTY);
+
+    assertEquals("letters=0061-0063,0065,0067-0068;digits=0037;marks=", 
policy.toString());
+  }
+
+  /**
+   * Supplies policies with different set shapes.
+   *
+   * @return Policies to write and read back.
+   */
+  static Stream<TokenizerCharacterPolicy> roundTripPolicies() {
+    return Stream.of(
+        TokenizerCharacterPolicy.ascii(),
+        GRAMMAR,
+        TokenizerCharacterPolicy.of(A, EMPTY, EMPTY),
+        TokenizerCharacterPolicy.of(EMPTY, SEVEN, EMPTY),
+        TokenizerCharacterPolicy.of(CodePointSet.of(DESERET_LETTER),
+            CodePointSet.of(MATHEMATICAL_DIGIT), 
CodePointSet.of(MUSICAL_MARK)),
+        TokenizerCharacterPolicy.of(CodePointSet.ofRange(0x21, 
0x2F).union(CodePointSet.of('a')),
+            CodePointSet.ofRange(Character.MAX_SURROGATE + 1, 
Character.MAX_SURROGATE + 3),
+            CodePointSet.of(Character.MAX_CODE_POINT)));
+  }
+
+  @ParameterizedTest
+  @MethodSource("roundTripPolicies")
+  void testParseReadsTheTextFormBack(TokenizerCharacterPolicy policy) {
+    TokenizerCharacterPolicy parsed = 
TokenizerCharacterPolicy.parse(policy.toString());
+
+    assertEquals(policy, parsed);
+    assertEquals(policy.hashCode(), parsed.hashCode());
+    assertEquals(policy.toString(), parsed.toString());
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "letters=0061;digits=;marks=0301",
+      "letters=61;digits=;marks=301",
+      "letters=0061-0061;digits=;marks=0301-0301",
+      "letters=0000061;digits=;marks=0301"})
+  void testParseAcceptsShortLongAndSingletonRangeSpellings(String text) {
+    TokenizerCharacterPolicy expected = TokenizerCharacterPolicy.of(
+        A, EMPTY, CodePointSet.of(ACUTE_ACCENT));
+
+    assertEquals(expected, TokenizerCharacterPolicy.parse(text));
+  }
+
+  @Test
+  void testParseAcceptsLowerCaseHexDigits() {
+    TokenizerCharacterPolicy expected = TokenizerCharacterPolicy.of(
+        CodePointSet.of(DESERET_LETTER), EMPTY, CodePointSet.of(MUSICAL_MARK));
+
+    assertEquals(expected, 
TokenizerCharacterPolicy.parse("letters=10400;digits=;marks=1d165"));
+  }
+
+  @ParameterizedTest
+  @NullSource
+  @ValueSource(strings = {
+      "",
+      "letters=;digits=;marks=",
+      "letters=0041-005A",
+      "letters=0041;digits=0030",
+      "digits=0030;letters=0041;marks=",
+      "letters=zz;digits=;marks=",
+      "letters=005A-0041;digits=;marks=",
+      "letters=0041;digits=0041;marks=",
+      "letters=D800;digits=;marks=",
+      "letters=0020;digits=;marks=",
+      "letters=0041,;digits=;marks=",
+      "letters=,0041;digits=;marks=",
+      "letters=0041-;digits=;marks=",
+      "letters=-0041;digits=;marks=",
+      "letters=0041-005A-0061;digits=;marks=",
+      "letters=110000;digits=;marks=",
+      "letters=0041;digits=;marks=;extra=",
+      "letters=0041;digits=;marks=0301;",
+      "letters 0041;digits=;marks=",
+      "LETTERS=0041;DIGITS=;MARKS=",
+      "letters=0x41;digits=;marks=",
+      "letters=U+0041;digits=;marks=",
+      "letters= 0041;digits=;marks=",
+      "letters=0041;digits=;marks=\n",
+      "^[A-Za-z0-9]+$"})
+  void testParseRejectsMalformedText(String text) {
+    assertThrows(IllegalArgumentException.class, () -> 
TokenizerCharacterPolicy.parse(text));
+  }
+
+  @Test
+  void testParseErrorNamesTheOffendingText() {
+    IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+        () -> TokenizerCharacterPolicy.parse("letters=zz;digits=;marks="));
+
+    assertTrue(e.getMessage().contains("zz"), e.getMessage());
+  }
+
+  @Test
+  void testEqualsComparesTheThreeSets() {
+    TokenizerCharacterPolicy ascii = TokenizerCharacterPolicy.of(
+        CodePointSet.ofRange('A', 'Z').union(CodePointSet.ofRange('a', 'z')),
+        CodePointSet.ofRange('0', '9'), EMPTY);
+    TokenizerCharacterPolicy swapped = TokenizerCharacterPolicy.of(
+        CodePointSet.ofRange('0', '9'),
+        CodePointSet.ofRange('A', 'Z').union(CodePointSet.ofRange('a', 'z')), 
EMPTY);
+    TokenizerCharacterPolicy marked = TokenizerCharacterPolicy.of(
+        CodePointSet.ofRange('A', 'Z').union(CodePointSet.ofRange('a', 'z')),
+        CodePointSet.ofRange('0', '9'), CodePointSet.of(ACUTE_ACCENT));
+
+    assertEquals(TokenizerCharacterPolicy.ascii(), ascii);
+    assertEquals(TokenizerCharacterPolicy.ascii().hashCode(), 
ascii.hashCode());
+    assertNotEquals(TokenizerCharacterPolicy.ascii(), swapped);
+    assertNotEquals(TokenizerCharacterPolicy.ascii(), marked);
+    assertNotEquals(TokenizerCharacterPolicy.ascii(), null);
+    assertNotEquals(TokenizerCharacterPolicy.ascii(), ASCII_TEXT);
+  }
+
   private static String codePoints(int... codePoints) {
     StringBuilder value = new StringBuilder();
     for (int codePoint : codePoints) {
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerFactoryTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerFactoryTest.java
index de478083c..98bef1ca0 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerFactoryTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerFactoryTest.java
@@ -22,10 +22,20 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.Locale;
-import java.util.regex.Pattern;
+import java.util.Map;
+import java.util.Properties;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
 
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import opennlp.tools.dictionary.Dictionary;
 import opennlp.tools.formats.ResourceAsStreamFactory;
@@ -33,9 +43,11 @@ import 
opennlp.tools.tokenize.DummyTokenizerFactory.DummyContextGenerator;
 import opennlp.tools.tokenize.DummyTokenizerFactory.DummyDictionary;
 import opennlp.tools.tokenize.lang.Factory;
 import opennlp.tools.util.InputStreamFactory;
+import opennlp.tools.util.InvalidFormatException;
 import opennlp.tools.util.ObjectStream;
 import opennlp.tools.util.PlainTextByLineStream;
 import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.normalizer.CodePointSet;
 
 /**
  * Tests for the {@link TokenizerFactory} class.
@@ -47,6 +59,12 @@ public class TokenizerFactoryTest {
   private static final Locale LOCALE_PORTUGUESE = Locale.of("pt");
   private static final Locale LOCALE_SPANISH = Locale.of("es");
 
+  private static final String MANIFEST_ENTRY = "manifest.properties";
+  private static final String CHARACTER_POLICY_KEY = "characterPolicy";
+  private static final String LEGACY_PATTERN_KEY = "alphaNumericPattern";
+  private static final String CUSTOM_LEGACY_PATTERN = "^[A-Za-z0-9_]+$";
+  private static final Factory LANGUAGE_FACTORY = new Factory();
+
   private static ObjectStream<TokenSample> createSampleStream() throws 
IOException {
     InputStreamFactory in = new ResourceAsStreamFactory(
         TokenizerFactoryTest.class, "/opennlp/tools/tokenize/token.train");
@@ -59,6 +77,14 @@ public class TokenizerFactoryTest {
     return TokenizerME.train(createSampleStream(), factory, 
TrainingParameters.defaultParams());
   }
 
+  /* One English model with the alphanumeric optimization, rewritten by the 
manifest tests. */
+  private static TokenizerModel engModel;
+
+  @BeforeAll
+  static void trainEnglishModel() throws IOException {
+    engModel = train(new TokenizerFactory("eng", null, true));
+  }
+
   private static Dictionary loadAbbDictionary(Locale loc) throws IOException {
     final String abbrevDict;
     if (loc.equals(LOCALE_DUTCH)) {
@@ -88,14 +114,13 @@ public class TokenizerFactoryTest {
     Dictionary dic = loadAbbDictionary(Locale.ENGLISH);
     final String lang = "eng";
 
-    TokenizerModel model = train(new TokenizerFactory(lang, dic, false, null));
+    TokenizerModel model = train(new TokenizerFactory(lang, dic, false));
 
     TokenizerFactory factory = model.getFactory();
     Assertions.assertNotNull(factory.getAbbreviationDictionary());
     Assertions.assertInstanceOf(DefaultTokenContextGenerator.class, 
factory.getContextGenerator());
 
-    String defaultPattern = Factory.DEFAULT_ALPHANUMERIC.pattern();
-    Assertions.assertEquals(defaultPattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(Factory.DEFAULT_CHARACTER_POLICY, 
factory.getCharacterPolicy());
     Assertions.assertEquals(lang, factory.getLanguageCode());
     Assertions.assertEquals(lang, model.getLanguage());
     Assertions.assertFalse(factory.isUseAlphaNumericOptimization());
@@ -110,7 +135,7 @@ public class TokenizerFactoryTest {
     Assertions.assertNotNull(factory.getAbbreviationDictionary());
     Assertions.assertInstanceOf(DefaultTokenContextGenerator.class, 
factory.getContextGenerator());
 
-    Assertions.assertEquals(defaultPattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(Factory.DEFAULT_CHARACTER_POLICY, 
factory.getCharacterPolicy());
     Assertions.assertEquals(lang, factory.getLanguageCode());
     Assertions.assertEquals(lang, model.getLanguage());
     Assertions.assertFalse(factory.isUseAlphaNumericOptimization());
@@ -122,14 +147,13 @@ public class TokenizerFactoryTest {
     Dictionary dic = null;
     final String lang = "eng";
 
-    TokenizerModel model = train(new TokenizerFactory(lang, dic, false, null));
+    TokenizerModel model = train(new TokenizerFactory(lang, dic, false));
 
     TokenizerFactory factory = model.getFactory();
     Assertions.assertNull(factory.getAbbreviationDictionary());
     Assertions.assertInstanceOf(DefaultTokenContextGenerator.class, 
factory.getContextGenerator());
 
-    String defaultPattern = Factory.DEFAULT_ALPHANUMERIC.pattern();
-    Assertions.assertEquals(defaultPattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(Factory.DEFAULT_CHARACTER_POLICY, 
factory.getCharacterPolicy());
     Assertions.assertEquals(lang, factory.getLanguageCode());
     Assertions.assertEquals(lang, model.getLanguage());
     Assertions.assertFalse(factory.isUseAlphaNumericOptimization());
@@ -144,27 +168,29 @@ public class TokenizerFactoryTest {
     Assertions.assertNull(factory.getAbbreviationDictionary());
     Assertions.assertInstanceOf(DefaultTokenContextGenerator.class, 
factory.getContextGenerator());
 
-    Assertions.assertEquals(defaultPattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(Factory.DEFAULT_CHARACTER_POLICY, 
factory.getCharacterPolicy());
     Assertions.assertEquals(lang, factory.getLanguageCode());
     Assertions.assertEquals(lang, model.getLanguage());
     Assertions.assertFalse(factory.isUseAlphaNumericOptimization());
   }
 
   @Test
-  void testCustomPatternAndAlphaOpt() throws IOException {
+  void testCustomPolicyAndAlphaOpt() throws IOException {
 
     Dictionary dic = null;
     final String lang = "spa";
-    String pattern = "^[0-9a-záéíóúüýñA-ZÁÉÍÓÚÝÑ]+$";
+    TokenizerCharacterPolicy ascii = TokenizerCharacterPolicy.ascii();
+    TokenizerCharacterPolicy policy = TokenizerCharacterPolicy.of(
+        ascii.getLetters().union(CodePointSet.of('ñ', 'Ñ')), ascii.getDigits(),
+        CodePointSet.of(0x0301));
 
-    TokenizerModel model = train(new TokenizerFactory(lang, dic, true,
-        Pattern.compile(pattern)));
+    TokenizerModel model = train(new TokenizerFactory(lang, dic, true, 
policy));
 
     TokenizerFactory factory = model.getFactory();
     Assertions.assertNull(factory.getAbbreviationDictionary());
     Assertions.assertInstanceOf(DefaultTokenContextGenerator.class, 
factory.getContextGenerator());
 
-    Assertions.assertEquals(pattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(policy, factory.getCharacterPolicy());
     Assertions.assertEquals(lang, factory.getLanguageCode());
     Assertions.assertEquals(lang, model.getLanguage());
     Assertions.assertTrue(factory.isUseAlphaNumericOptimization());
@@ -178,13 +204,250 @@ public class TokenizerFactoryTest {
     factory = fromSerialized.getFactory();
     Assertions.assertNull(factory.getAbbreviationDictionary());
     Assertions.assertInstanceOf(DefaultTokenContextGenerator.class, 
factory.getContextGenerator());
-    Assertions.assertEquals(pattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(policy, factory.getCharacterPolicy());
     Assertions.assertEquals(lang, factory.getLanguageCode());
     Assertions.assertEquals(lang, model.getLanguage());
     Assertions.assertTrue(factory.isUseAlphaNumericOptimization());
   }
 
-  void checkCustomPatternForTokenizerME(String lang, String pattern, String 
sentence,
+  /**
+   * A language policy is written with the pattern of earlier releases that 
accepts the same
+   * characters, so those releases apply the same characters to the model.
+   */
+  @Test
+  void testManifestCarriesTheCharacterPolicyAndItsLegacyPattern() throws 
IOException {
+    final String lang = "spa";
+    TokenizerModel model = train(new TokenizerFactory(lang, null, true));
+
+    Properties manifest = readManifest(serialize(model));
+
+    
Assertions.assertEquals(LANGUAGE_FACTORY.getCharacterPolicy(lang).toString(),
+        manifest.getProperty(CHARACTER_POLICY_KEY));
+    Assertions.assertEquals(LegacyAlphaNumericPatterns.BY_LANGUAGE.get("es"),
+        manifest.getProperty(LEGACY_PATTERN_KEY));
+  }
+
+  /** A custom policy has no pattern of an earlier release, so only the policy 
is written. */
+  @Test
+  void testManifestOfACustomPolicyHasNoLegacyPattern() throws IOException {
+    TokenizerCharacterPolicy policy = TokenizerCharacterPolicy.of(
+        CodePointSet.ofRange('a', 'z'), CodePointSet.ofRange('0', '9'), 
CodePointSet.of());
+    TokenizerModel model = train(new TokenizerFactory("eng", null, true, 
policy));
+
+    Properties manifest = readManifest(serialize(model));
+
+    Assertions.assertEquals(policy.toString(), 
manifest.getProperty(CHARACTER_POLICY_KEY));
+    Assertions.assertNull(manifest.getProperty(LEGACY_PATTERN_KEY));
+  }
+
+  /**
+   * Supplies each legacy regex with the language code of its policy.
+   *
+   * @return Regex and language code pairs.
+   */
+  static Stream<Arguments> legacyPatterns() {
+    return LegacyAlphaNumericPatterns.BY_LANGUAGE.entrySet().stream()
+        .map(entry -> Arguments.of(entry.getValue(), entry.getKey()));
+  }
+
+  @ParameterizedTest
+  @MethodSource("legacyPatterns")
+  void testLegacyPatternInTheManifestMapsToItsPolicy(String pattern, String 
lang)
+      throws IOException {
+    byte[] legacy = rewriteManifest(engModel, Map.of(LEGACY_PATTERN_KEY, 
pattern), CHARACTER_POLICY_KEY);
+
+    TokenizerModel loaded = new TokenizerModel(new 
ByteArrayInputStream(legacy));
+
+    Assertions.assertEquals(LANGUAGE_FACTORY.getCharacterPolicy(lang),
+        loaded.getFactory().getCharacterPolicy());
+    Assertions.assertTrue(loaded.getFactory().isUseAlphaNumericOptimization());
+    Assertions.assertArrayEquals(new String[] {"test", ","}, new 
TokenizerME(loaded).tokenize("test,"));
+  }
+
+  /**
+   * A model whose manifest has a custom pattern loads, and its alphanumeric 
optimization tests
+   * candidate tokens with that pattern: "it's" stays one token only because 
the pattern accepts
+   * the apostrophe; the English model splits it otherwise.
+   */
+  @Test
+  @SuppressWarnings("removal")
+  void testCustomLegacyPatternInTheManifestIsEvaluatedAsARegularExpression() 
throws IOException {
+    final String withApostrophe = "^[A-Za-z0-9']+$";
+    byte[] legacy = rewriteManifest(engModel, Map.of(LEGACY_PATTERN_KEY, 
withApostrophe),
+        CHARACTER_POLICY_KEY);
+
+    TokenizerModel loaded = new TokenizerModel(new 
ByteArrayInputStream(legacy));
+
+    Assertions.assertNull(loaded.getFactory().getCharacterPolicy());
+    Assertions.assertEquals(withApostrophe,
+        loaded.getFactory().getAlphaNumericPattern().pattern());
+    Assertions.assertArrayEquals(new String[] {"it's"}, new 
TokenizerME(loaded).tokenize("it's"));
+    Assertions.assertArrayEquals(new String[] {"it", "'s"},
+        new TokenizerME(engModel).tokenize("it's"));
+  }
+
+  @Test
+  void testInvalidLegacyPatternInTheManifestIsRejected() throws IOException {
+    byte[] invalid = rewriteManifest(engModel, Map.of(LEGACY_PATTERN_KEY, 
"[a-z"),
+        CHARACTER_POLICY_KEY);
+
+    InvalidFormatException e = assertRejectedAtLoad(invalid);
+
+    Assertions.assertEquals("The tokenizer model for language 'eng' has an 
invalid "
+        + LEGACY_PATTERN_KEY + " '[a-z'", e.getMessage());
+  }
+
+  /**
+   * The deprecated constructor with a custom pattern writes only that pattern 
to the manifest,
+   * and the model tokenizes with it after loading.
+   */
+  @Test
+  @SuppressWarnings("removal")
+  void testDeprecatedPatternConstructorRoundTrips() throws IOException {
+    TokenizerModel model = train(new TokenizerFactory("eng", null, true,
+        java.util.regex.Pattern.compile(CUSTOM_LEGACY_PATTERN)));
+    byte[] bytes = serialize(model);
+
+    Properties manifest = readManifest(bytes);
+    TokenizerModel loaded = new TokenizerModel(new 
ByteArrayInputStream(bytes));
+
+    Assertions.assertEquals(CUSTOM_LEGACY_PATTERN, 
manifest.getProperty(LEGACY_PATTERN_KEY));
+    Assertions.assertNull(manifest.getProperty(CHARACTER_POLICY_KEY));
+    Assertions.assertNull(loaded.getFactory().getCharacterPolicy());
+    Assertions.assertEquals(CUSTOM_LEGACY_PATTERN,
+        loaded.getFactory().getAlphaNumericPattern().pattern());
+  }
+
+  /**
+   * The deprecated pattern getter returns the pattern of earlier releases for 
a language policy.
+   *
+   * @param pattern The pattern of the language.
+   * @param lang The language code.
+   */
+  @ParameterizedTest
+  @MethodSource("legacyPatterns")
+  @SuppressWarnings("removal")
+  void testDeprecatedPatternGetterReturnsTheLegacyPatternOfTheLanguage(String 
pattern,
+                                                                       String 
lang) {
+    TokenizerFactory factory = new TokenizerFactory(lang, null, true);
+
+    Assertions.assertEquals(pattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(pattern, 
LANGUAGE_FACTORY.getAlphanumeric(lang).pattern());
+  }
+
+  /** A deprecated create call with a {@code null} pattern uses the policy of 
the language. */
+  @Test
+  @SuppressWarnings("removal")
+  void testDeprecatedCreateWithoutPatternUsesTheLanguagePolicy() throws 
InvalidFormatException {
+    TokenizerFactory factory = TokenizerFactory.create(null, "spa", null, true,
+        (java.util.regex.Pattern) null);
+
+    Assertions.assertEquals(LANGUAGE_FACTORY.getCharacterPolicy("spa"),
+        factory.getCharacterPolicy());
+  }
+
+  @ParameterizedTest
+  @CsvSource({"spa, es", "eng, en", "x-unspecified, en"})
+  void testManifestWithoutPolicyOrPatternUsesTheLanguageDefault(String lang, 
String policyLang)
+      throws IOException {
+    TokenizerModel model = train(new TokenizerFactory(lang, null, true));
+    byte[] bare = rewriteManifest(model, Map.of(), CHARACTER_POLICY_KEY, 
LEGACY_PATTERN_KEY);
+
+    TokenizerModel loaded = new TokenizerModel(new ByteArrayInputStream(bare));
+
+    Assertions.assertEquals(LANGUAGE_FACTORY.getCharacterPolicy(policyLang),
+        loaded.getFactory().getCharacterPolicy());
+  }
+
+  @Test
+  void testMalformedCharacterPolicyInTheManifestIsRejected() throws 
IOException {
+    byte[] malformed = rewriteManifest(engModel,
+        Map.of(CHARACTER_POLICY_KEY, "letters=zz;digits=;marks="));
+
+    InvalidFormatException e = assertRejectedAtLoad(malformed);
+
+    Assertions.assertTrue(e.getMessage().contains(CHARACTER_POLICY_KEY), 
e.getMessage());
+    Assertions.assertTrue(e.getMessage().contains("zz"), e.getMessage());
+  }
+
+  @Test
+  void testCharacterPolicyInTheManifestWinsOverALegacyPattern() throws 
IOException {
+    byte[] both = rewriteManifest(engModel, Map.of(LEGACY_PATTERN_KEY, 
CUSTOM_LEGACY_PATTERN));
+
+    TokenizerModel loaded = new TokenizerModel(new ByteArrayInputStream(both));
+
+    Assertions.assertEquals(Factory.DEFAULT_CHARACTER_POLICY, 
loaded.getFactory().getCharacterPolicy());
+  }
+
+  /**
+   * Loads {@code model} and expects the {@link InvalidFormatException} of the 
factory, which
+   * {@link opennlp.tools.util.model.BaseModel} surfaces as the cause of an
+   * {@link IllegalArgumentException}.
+   *
+   * @param model The model bytes.
+   * @return The exception the factory raised.
+   */
+  private static InvalidFormatException assertRejectedAtLoad(byte[] model) {
+    IllegalArgumentException e = 
Assertions.assertThrows(IllegalArgumentException.class,
+        () -> new TokenizerModel(new ByteArrayInputStream(model)));
+    return Assertions.assertInstanceOf(InvalidFormatException.class, 
e.getCause());
+  }
+
+  private static byte[] serialize(TokenizerModel model) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    model.serialize(out);
+    return out.toByteArray();
+  }
+
+  private static Properties readManifest(byte[] model) throws IOException {
+    try (ZipInputStream zip = new ZipInputStream(new 
ByteArrayInputStream(model))) {
+      ZipEntry entry;
+      while ((entry = zip.getNextEntry()) != null) {
+        if (MANIFEST_ENTRY.equals(entry.getName())) {
+          Properties manifest = new Properties();
+          manifest.load(zip);
+          return manifest;
+        }
+      }
+    }
+    throw new IOException("No " + MANIFEST_ENTRY + " in the model");
+  }
+
+  /**
+   * Serializes {@code model} with its manifest changed, the way a model 
written by an
+   * earlier release would read.
+   *
+   * @param model The model to serialize.
+   * @param put The manifest properties to set.
+   * @param remove The manifest properties to drop.
+   * @return The model bytes.
+   */
+  private static byte[] rewriteManifest(TokenizerModel model, Map<String, 
String> put,
+      String... remove) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    try (ZipInputStream zip = new ZipInputStream(new 
ByteArrayInputStream(serialize(model)));
+         ZipOutputStream rewritten = new ZipOutputStream(out)) {
+      ZipEntry entry;
+      while ((entry = zip.getNextEntry()) != null) {
+        rewritten.putNextEntry(new ZipEntry(entry.getName()));
+        if (MANIFEST_ENTRY.equals(entry.getName())) {
+          Properties manifest = new Properties();
+          manifest.load(zip);
+          for (String key : remove) {
+            manifest.remove(key);
+          }
+          manifest.putAll(put);
+          manifest.store(rewritten, "");
+        } else {
+          zip.transferTo(rewritten);
+        }
+        rewritten.closeEntry();
+      }
+    }
+    return out.toByteArray();
+  }
+
+  void checkLanguagePolicyForTokenizerME(String lang, String sentence,
       int expectedNumTokens) throws IOException {
     Locale loc = Locale.ENGLISH;
     if ("dut".equals(lang) || "nld".equals(lang)) {
@@ -203,7 +466,7 @@ public class TokenizerFactoryTest {
       loc = LOCALE_SPANISH;
     }
     TokenizerModel model = train(new TokenizerFactory(lang, 
loadAbbDictionary(loc), true,
-        Pattern.compile(pattern)));
+        LANGUAGE_FACTORY.getCharacterPolicy(lang)));
 
     TokenizerME tokenizer = new TokenizerME(model);
     String[] tokens = tokenizer.tokenize(sentence);
@@ -222,120 +485,107 @@ public class TokenizerFactoryTest {
     }
   }
 
-  // For language specific patterns see: opennlp.tools.tokenize.lang.Factory
+  // For language specific policies see: opennlp.tools.tokenize.lang.Factory
 
   @Test
-  void testCustomPatternForTokenizerMEWithAbbreviationsDeu() throws 
IOException {
+  void testLanguagePolicyForTokenizerMEWithAbbreviationsDeu() throws 
IOException {
     String lang = "deu";
-    String pattern = "^[A-Za-z0-9äéöüÄÉÖÜß]+$";
     String sentence = "Ich wähle den auf S. 183 ff. mitgeteilten " +
             "Traum von der botanischen Monographie.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 14);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 14);
   }
 
   @Test // to verify OPENNLP-1555
-  void testCustomPatternForTokenizerMEWithMultiDotAbbreviationsDeu() throws 
IOException {
+  void testLanguagePolicyForTokenizerMEWithMultiDotAbbreviationsDeu() throws 
IOException {
     String lang = "deu";
-    String pattern = "^[A-Za-z0-9äéöüÄÉÖÜß]+$";
     // Adds an extra "z.B.", the compact form of "z. B." (zum Beispiel => for 
example)
     String sentence = "Ich wähle z.B. den auf S. 183 ff. mitgeteilten " +
             "Traum von der botanischen Monographie.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 15);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 15);
   }
 
   @Test
-  void testCustomPatternForTokenizerMEWithAbbreviationsDut() throws 
IOException {
+  void testLanguagePolicyForTokenizerMEWithAbbreviationsDut() throws 
IOException {
     String lang = "dut";
-    String pattern = "^[A-Za-z0-9äöüëèéïijÄÖÜËÉÈÏIJ]+$";
     String sentence = "Ik kies voor de droom van de botanische monografie die 
" +
             "op p. 183 en volgende wordt beschreven.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 18);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 18);
   }
 
   @Test // to verify OPENNLP-1555
-  void testCustomPatternForTokenizerMEWithMultiDotAbbreviationsDut() throws 
IOException {
+  void testLanguagePolicyForTokenizerMEWithMultiDotAbbreviationsDut() throws 
IOException {
     String lang = "dut";
-    String pattern = "^[A-Za-z0-9äöüëèéïijÄÖÜËÉÈÏIJ]+$";
     String sentence = "Ik kies voor de droom van de botanische monografie die 
" +
             "op p. 183 e.v. wordt beschreven.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 17);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 17);
   }
 
   @Test
-  void testCustomPatternForTokenizerMEWithAbbreviationsFra() throws 
IOException {
+  void testLanguagePolicyForTokenizerMEWithAbbreviationsFra() throws 
IOException {
     String lang = "fra";
-    String pattern = "^[a-zA-Z0-9àâäèéêëîïôœùûüÿçÀÂÄÈÉÊËÎÏÔŒÙÛÜŸÇ]+$";
     String sentence = "Je choisis le rêve de la monographie botanique 
communiqué à la p. 205.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 14);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 14);
   }
 
   @Test
-  void testCustomPatternForTokenizerMEWithAbbreviationsPol() throws 
IOException {
+  void testLanguagePolicyForTokenizerMEWithAbbreviationsPol() throws 
IOException {
     String lang = "pol";
-    String pattern = "^[A-Za-z0-9żźćńółęąśŻŹĆĄŚĘŁÓŃ]+$";
     String sentence = "W szkicu autobiograficznym pt. moje życie i 
psychoanaliza Freud pisze, że " +
             "jego przodkowie żyli przez wiele lat w Kolonii.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 21);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 21);
   }
 
   @Test
-  void testCustomPatternForTokenizerMEWithAbbreviationsPor() throws 
IOException {
+  void testLanguagePolicyForTokenizerMEWithAbbreviationsPor() throws 
IOException {
     String lang = "por";
-    String pattern = "^[0-9a-záãâàéêíóõôúüçA-ZÁÃÂÀÉÊÍÓÕÔÚÜÇ]+$";
     String sentence = "O povo pernambucano, tradicionalmente inimigo dos 
imperadores, " +
             "lembrava-se do tempo em que o Sr. D. Pedro de Alcantara dava-se 
ao luxo " +
             "de visitar o norte.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 28);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 28);
   }
 
   @Test
-  void testCustomPatternForTokenizerMEWithAbbreviationsSpa() throws 
IOException {
+  void testLanguagePolicyForTokenizerMEWithAbbreviationsSpa() throws 
IOException {
     String lang = "spa";
-    String pattern = "^[0-9a-záéíóúüýñA-ZÁÉÍÓÚÝÑ]+$";
     String sentence = "Elegiremos el de la monografía botánica expuesto antes 
del " +
             "capítulo V en pág. 448 del presente volumen.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 18);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 18);
   }
 
   @Test
-  void testCustomPatternForTokenizerMEPor() throws IOException {
+  void testLanguagePolicyForTokenizerMEPor() throws IOException {
     String lang = "por";
-    String pattern = "^[0-9a-záãâàéêíóõôúüçA-ZÁÃÂÀÉÊÍÓÕÔÚÜÇ]+$";
     String sentence = "Na floresta mágica a raposa dança com unicórnios 
felizes.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 10);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 10);
   }
 
   @Test
-  void testCustomPatternForTokenizerMESpa() throws IOException {
+  void testLanguagePolicyForTokenizerMESpa() throws IOException {
     String lang = "spa";
-    String pattern = "^[0-9a-záéíóúüýñA-ZÁÉÍÓÚÝÑ]+$";
     String sentence = "En el verano los niños juegan en el parque y sus risas 
crean alegría.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 15);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 15);
   }
 
   @Test
-  void testCustomPatternForTokenizerMECat() throws IOException {
+  void testLanguagePolicyForTokenizerMECat() throws IOException {
     String lang = "cat";
-    String pattern = "^[0-9a-zàèéíïòóúüçA-ZÀÈÉÍÏÒÓÚÜÇ]+$";
     String sentence = "Als xiuxiuejants avets l'os blau neda amb cignes i s'ho 
passen bé.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 15);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 15);
   }
 
   @Test
-  void testCustomPatternForTokenizerMEIta() throws IOException {
+  void testLanguagePolicyForTokenizerMEIta() throws IOException {
     String lang = "ita";
-    String pattern = "^[0-9a-zàèéìîíòóùüA-ZÀÈÉÌÎÍÒÓÙÜ]+$";
     String sentence = "Cosa fare di domenica per migliorare il tuo lunedì.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 10);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 10);
   }
 
   @Test
-  void testCustomPatternForTokenizerMEWithAbbreviationsIta() throws 
IOException {
+  void testLanguagePolicyForTokenizerMEWithAbbreviationsIta() throws 
IOException {
     String lang = "ita";
-    String pattern = "^[0-9a-zàèéìîíòóùüA-ZÀÈÉÌÎÍÒÓÙÜ]+$";
     String sentence = "La chiesa fu costruita fra il 1258 ed il 1308 ca. come 
chiesa " +
         "del convento degli Agostiniani.";
-    checkCustomPatternForTokenizerME(lang, pattern, sentence, 18);
+    checkLanguagePolicyForTokenizerME(lang, sentence, 18);
   }
 
   @Test
@@ -343,10 +593,9 @@ public class TokenizerFactoryTest {
 
     Dictionary dic = null;
     String lang = "ita";
-    String pattern = "^[0-9a-zàèéìîíòóùüA-ZÀÈÉÌÎÍÒÓÙÜ]+$";
 
     TokenizerModel model = train(new TokenizerFactory(lang, dic, true,
-        Pattern.compile(pattern)));
+        LANGUAGE_FACTORY.getCharacterPolicy(lang)));
 
     TokenizerME tokenizer = new TokenizerME(model);
     String sentence = "La contrazione di \"dove è\" è \"dov'è\".";
@@ -365,10 +614,9 @@ public class TokenizerFactoryTest {
 
     Dictionary dic = null;
     String lang = "eng";
-    String pattern = "^[A-Za-z0-9]+$";
 
     TokenizerModel model = train(new TokenizerFactory(lang, dic, true,
-        Pattern.compile(pattern)));
+        LANGUAGE_FACTORY.getCharacterPolicy(lang)));
 
     TokenizerME tokenizer = new TokenizerME(model);
     String sentence = "The cat wasn't in the house and the dog wasn't either.";
@@ -387,15 +635,14 @@ public class TokenizerFactoryTest {
 
     Dictionary dic = loadAbbDictionary(Locale.ENGLISH);
     final String lang = "eng";
-    String pattern = "^[0-9A-Za-z]+$";
+    TokenizerCharacterPolicy policy = TokenizerCharacterPolicy.ascii();
 
-    TokenizerModel model = train(new DummyTokenizerFactory(lang, dic, true,
-        Pattern.compile(pattern)));
+    TokenizerModel model = train(new DummyTokenizerFactory(lang, dic, true, 
policy));
 
     TokenizerFactory factory = model.getFactory();
     Assertions.assertInstanceOf(DummyDictionary.class, 
factory.getAbbreviationDictionary());
     Assertions.assertInstanceOf(DummyContextGenerator.class, 
factory.getContextGenerator());
-    Assertions.assertEquals(pattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(policy, factory.getCharacterPolicy());
     Assertions.assertEquals(lang, factory.getLanguageCode());
     Assertions.assertEquals(lang, model.getLanguage());
     Assertions.assertTrue(factory.isUseAlphaNumericOptimization());
@@ -409,7 +656,7 @@ public class TokenizerFactoryTest {
     factory = fromSerialized.getFactory();
     Assertions.assertInstanceOf(DummyDictionary.class, 
factory.getAbbreviationDictionary());
     Assertions.assertInstanceOf(DummyContextGenerator.class, 
factory.getContextGenerator());
-    Assertions.assertEquals(pattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(policy, factory.getCharacterPolicy());
     Assertions.assertEquals(lang, factory.getLanguageCode());
     Assertions.assertEquals(lang, model.getLanguage());
     Assertions.assertTrue(factory.isUseAlphaNumericOptimization());
@@ -419,15 +666,14 @@ public class TokenizerFactoryTest {
   void testCreateDummyFactory() throws IOException {
     Dictionary dic = loadAbbDictionary(Locale.ENGLISH);
     final String lang = "eng";
-    String pattern = "^[0-9A-Za-z]+$";
+    TokenizerCharacterPolicy policy = TokenizerCharacterPolicy.ascii();
 
     TokenizerFactory factory = TokenizerFactory.create(
-        DummyTokenizerFactory.class.getCanonicalName(), lang, dic, true,
-        Pattern.compile(pattern));
+        DummyTokenizerFactory.class.getCanonicalName(), lang, dic, true, 
policy);
 
     Assertions.assertInstanceOf(DummyDictionary.class, 
factory.getAbbreviationDictionary());
     Assertions.assertInstanceOf(DummyContextGenerator.class, 
factory.getContextGenerator());
-    Assertions.assertEquals(pattern, 
factory.getAlphaNumericPattern().pattern());
+    Assertions.assertEquals(policy, factory.getCharacterPolicy());
     Assertions.assertEquals(lang, factory.getLanguageCode());
     Assertions.assertTrue(factory.isUseAlphaNumericOptimization());
   }
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerMETest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerMETest.java
index 6c2e4381a..b943b1876 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerMETest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerMETest.java
@@ -19,9 +19,13 @@ package opennlp.tools.tokenize;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.util.stream.Stream;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import opennlp.tools.formats.ResourceAsStreamFactory;
 import opennlp.tools.util.InputStreamFactory;
@@ -30,6 +34,7 @@ import opennlp.tools.util.ObjectStream;
 import opennlp.tools.util.Parameters;
 import opennlp.tools.util.PlainTextByLineStream;
 import opennlp.tools.util.TrainingParameters;
+import opennlp.tools.util.normalizer.CodePointSet;
 
 /**
  * Tests for the {@link TokenizerME} class.
@@ -42,6 +47,8 @@ import opennlp.tools.util.TrainingParameters;
  */
 public class TokenizerMETest {
 
+  private static final String CONTRACTION = "it's";
+
   @Test
   void testTokenizerSimpleModel() throws IOException {
 
@@ -75,6 +82,60 @@ public class TokenizerMETest {
     Assertions.assertEquals("!", tokens[8]);
   }
 
+  /**
+   * Supplies sentences with the tokens the model trained from token.train 
produces for them.
+   *
+   * @return Sentence and token array pairs.
+   */
+  static Stream<Arguments> pinnedSentences() {
+    return Stream.of(
+        Arguments.of("Sounds like it's not properly thought through!",
+            new String[] {"Sounds", "like", "it", "'s", "not", "properly", 
"thought", "through", "!"}),
+        Arguments.of("Mr. Vinken is chairman of Elsevier N.V., the Dutch 
publishing group.",
+            new String[] {"Mr", ".", "Vinken", "is", "chairman", "of", 
"Elsevier", "N", ".V", ".", ",",
+                "the", "Dutch", "publishing", "group", "."}),
+        Arguments.of("The cat wasn't in the house, and the dog wasn't either.",
+            new String[] {"The", "cat", "wasn", "'t", "in", "the", "house", 
",", "and", "the", "dog",
+                "wasn", "'t", "either", "."}),
+        Arguments.of("Pierre Vinken, 61 years old, will join the board as a 
nonexecutive director Nov. 29.",
+            new String[] {"Pierre", "Vinken", ",", "61", "years", "old", ",", 
"will", "join", "the",
+                "board", "as", "a", "nonexecutive", "director", "Nov", ".", 
"29", "."}),
+        Arguments.of("Call me at 555-1234 (anytime).",
+            new String[] {"Call", "me", "at", "555-1234", "(anytime)", "."}),
+        Arguments.of("It's a 3.5\" disk; don't lose it!",
+            new String[] {"It", "'s", "a", "3.5", "\"", "disk;", "don", "'t", 
"lose", "it!"}));
+  }
+
+  /**
+   * Pins the output of a model trained with and one trained without the 
alphanumeric
+   * optimization: on these sentences the policy only skips tokens the model 
would not split.
+   */
+  @ParameterizedTest
+  @MethodSource("pinnedSentences")
+  void testTokenizationWithAndWithoutAlphaNumericOptimization(String sentence, 
String[] expected)
+      throws IOException {
+    TokenizerME optimized = new 
TokenizerME(TokenizerTestUtil.createMaxentTokenModel(true));
+    TokenizerME plain = new 
TokenizerME(TokenizerTestUtil.createMaxentTokenModel(false));
+
+    Assertions.assertTrue(optimized.useAlphaNumericOptimization());
+    Assertions.assertFalse(plain.useAlphaNumericOptimization());
+    Assertions.assertArrayEquals(expected, optimized.tokenize(sentence));
+    Assertions.assertArrayEquals(expected, plain.tokenize(sentence));
+  }
+
+  @Test
+  void testCharacterPolicyDecidesWhichTokensStayWhole() throws IOException {
+    TokenizerCharacterPolicy ascii = TokenizerCharacterPolicy.ascii();
+    TokenizerCharacterPolicy withApostrophe = TokenizerCharacterPolicy.of(
+        ascii.getLetters().union(CodePointSet.of('\'')), ascii.getDigits(), 
ascii.getMarks());
+    TokenizerME tokenizer = new 
TokenizerME(TokenizerTestUtil.createMaxentTokenModel(
+        new TokenizerFactory("eng", null, true, withApostrophe)));
+
+    Assertions.assertArrayEquals(new String[] {CONTRACTION}, 
tokenizer.tokenize(CONTRACTION));
+    Assertions.assertArrayEquals(new String[] {"it", "'s"},
+        new 
TokenizerME(TokenizerTestUtil.createMaxentTokenModel(true)).tokenize(CONTRACTION));
+  }
+
   @Test
   void testInsufficientData() {
 
@@ -90,7 +151,7 @@ public class TokenizerMETest {
       mlParams.put(Parameters.ITERATIONS_PARAM, 100);
       mlParams.put(Parameters.CUTOFF_PARAM, 5);
 
-      TokenizerME.train(samples, TokenizerFactory.create(null, "eng", null, 
true, null), mlParams);
+      TokenizerME.train(samples, TokenizerFactory.create(null, "eng", null, 
true), mlParams);
 
     });
 
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerTestUtil.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerTestUtil.java
index a085f306e..b6cf38332 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerTestUtil.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/TokenizerTestUtil.java
@@ -59,10 +59,20 @@ public class TokenizerTestUtil {
     mlParams.put(Parameters.CUTOFF_PARAM, 0);
 
     return TokenizerME.train(new CollectionObjectStream<>(samples),
-      TokenizerFactory.create(null, "eng", null, true, null), mlParams);
+      TokenizerFactory.create(null, "eng", null, true), mlParams);
   }
 
   static TokenizerModel createMaxentTokenModel() throws IOException {
+    return createMaxentTokenModel(true);
+  }
+
+  static TokenizerModel createMaxentTokenModel(boolean 
useAlphaNumericOptimization)
+      throws IOException {
+    return createMaxentTokenModel(
+        TokenizerFactory.create(null, "eng", null, 
useAlphaNumericOptimization));
+  }
+
+  static TokenizerModel createMaxentTokenModel(TokenizerFactory factory) 
throws IOException {
 
     InputStreamFactory trainDataIn = new ResourceAsStreamFactory(
         TokenizerModel.class, "/opennlp/tools/tokenize/token.train");
@@ -74,7 +84,7 @@ public class TokenizerTestUtil {
     mlParams.put(Parameters.ITERATIONS_PARAM, 100);
     mlParams.put(Parameters.CUTOFF_PARAM, 0);
 
-    return TokenizerME.train(samples, TokenizerFactory.create(null, "eng", 
null, true, null), mlParams);
+    return TokenizerME.train(samples, factory, mlParams);
   }
 
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lang/FactoryTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lang/FactoryTest.java
new file mode 100644
index 000000000..69845fcb4
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lang/FactoryTest.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.tokenize.lang;
+
+import java.util.List;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.tokenize.LegacyAlphaNumericPatterns;
+import opennlp.tools.tokenize.TokenizerCharacterPolicy;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the {@link Factory} class. The regular expressions in
+ * {@link LegacyAlphaNumericPatterns} are the oracle for the character 
policies that replace them.
+ */
+public class FactoryTest {
+
+  private static final int DIGIT_COUNT = 10;
+
+  private static final List<String> MULTI_CHARACTER_TOKENS = List.of(
+      "abc", "ABC", "0123456789", "a1b2", "Café", "CAFÉ", "straße", "naïve", 
"ijs", "IJs",
+      "żółw", "año", "Niño", "coração", "ÀÉÎÕÜ", "œuvre", "Ÿ", "maçã", "a-b", 
"a b", "a_b",
+      "ab\n", "ab\r\n", "\u00E9\u0301", "e\u0301", "a\u00A0b", "\u3042", 
"\uD801\uDC00",
+      "ab\uD801\uDC00", "a\uD800", "\uDC00a");
+
+  private static final List<String> SUPPLEMENTARY_TOKENS = List.of(
+      "\uD801\uDC00", "\uD835\uDFD8", "\uD83D\uDE00", "a\uD801\uDC00", 
"\uD801\uDC00a");
+
+  private final Factory factory = new Factory();
+
+  /**
+   * Supplies one representative language code per policy with its legacy 
regex.
+   *
+   * @return Language code and regex pairs.
+   */
+  static Stream<Arguments> shippedPolicies() {
+    return LegacyAlphaNumericPatterns.BY_LANGUAGE.entrySet().stream()
+        .map(entry -> Arguments.of(entry.getKey(), entry.getValue()));
+  }
+
+  @ParameterizedTest
+  @MethodSource("shippedPolicies")
+  void testPolicyMatchesLegacyRegexOnEveryBmpCodePoint(String languageCode, 
String regex) {
+    Pattern oracle = Pattern.compile(regex);
+    TokenizerCharacterPolicy policy = factory.getCharacterPolicy(languageCode);
+
+    for (int codePoint = 0; codePoint <= Character.MAX_VALUE; codePoint++) {
+      String token = String.valueOf((char) codePoint);
+      assertEquals(oracle.matcher(token).matches(), policy.test(token),
+          languageCode + " U+" + Integer.toHexString(codePoint));
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("shippedPolicies")
+  void testPolicyMatchesLegacyRegexOnMultiCharacterTokens(String languageCode, 
String regex) {
+    Pattern oracle = Pattern.compile(regex);
+    TokenizerCharacterPolicy policy = factory.getCharacterPolicy(languageCode);
+
+    for (String token : MULTI_CHARACTER_TOKENS) {
+      assertEquals(oracle.matcher(token).matches(), policy.test(token),
+          languageCode + " '" + token + "'");
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("shippedPolicies")
+  void testPolicyRejectsSupplementaryCharactersLikeLegacyRegex(String 
languageCode, String regex) {
+    Pattern oracle = Pattern.compile(regex);
+    TokenizerCharacterPolicy policy = factory.getCharacterPolicy(languageCode);
+
+    for (String token : SUPPLEMENTARY_TOKENS) {
+      assertFalse(oracle.matcher(token).matches(), languageCode + " oracle '" 
+ token + "'");
+      assertFalse(policy.test(token), languageCode + " '" + token + "'");
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("shippedPolicies")
+  void testShippedPoliciesHaveAsciiDigitsAndNoMarks(String languageCode, 
String regex) {
+    TokenizerCharacterPolicy policy = factory.getCharacterPolicy(languageCode);
+
+    assertEquals(DIGIT_COUNT, policy.getDigits().size(), languageCode);
+    for (int digit = '0'; digit <= '9'; digit++) {
+      assertTrue(policy.getDigits().contains(digit), languageCode);
+    }
+    assertTrue(policy.getMarks().isEmpty(), languageCode);
+  }
+
+  @ParameterizedTest
+  @MethodSource("shippedPolicies")
+  void testLetterSetIsExactlyTheLegacyClassMinusDigits(String languageCode, 
String regex) {
+    Pattern oracle = Pattern.compile(regex);
+    TokenizerCharacterPolicy policy = factory.getCharacterPolicy(languageCode);
+
+    int expectedLetters = 0;
+    for (int codePoint = 0; codePoint <= Character.MAX_VALUE; codePoint++) {
+      if (oracle.matcher(String.valueOf((char) codePoint)).matches()
+          && !policy.getDigits().contains(codePoint)) {
+        expectedLetters++;
+        assertTrue(policy.getLetters().contains(codePoint),
+            languageCode + " U+" + Integer.toHexString(codePoint));
+      }
+    }
+    assertEquals(expectedLetters, policy.getLetters().size(), languageCode);
+  }
+
+  @ParameterizedTest
+  @CsvSource({
+      "es, spa",
+      "it, ita",
+      "pt, por",
+      "ca, cat",
+      "pl, pol",
+      "de, deu",
+      "de, ger",
+      "fr, fre",
+      "fr, fra",
+      "nl, nld",
+      "nl, dut"})
+  void testTwoAndThreeLetterCodesShareOnePolicy(String twoLetter, String 
threeLetter) {
+    assertSame(factory.getCharacterPolicy(twoLetter), 
factory.getCharacterPolicy(threeLetter));
+  }
+
+  @ParameterizedTest
+  @NullSource
+  @ValueSource(strings = {"en", "eng", "xx", "", "ES", "x-unspecified"})
+  void testUnknownOrAbsentLanguageUsesTheDefaultPolicy(String languageCode) {
+    assertSame(Factory.DEFAULT_CHARACTER_POLICY, 
factory.getCharacterPolicy(languageCode));
+  }
+
+  @Test
+  void testDefaultPolicyIsTheAsciiPreset() {
+    assertSame(TokenizerCharacterPolicy.ascii(), 
Factory.DEFAULT_CHARACTER_POLICY);
+  }
+}
diff --git a/opennlp-docs/src/docbkx/tokenizer.xml 
b/opennlp-docs/src/docbkx/tokenizer.xml
index 2eb69fad2..7e9a6e476 100644
--- a/opennlp-docs/src/docbkx/tokenizer.xml
+++ b/opennlp-docs/src/docbkx/tokenizer.xml
@@ -213,7 +213,8 @@ double[] tokenProbs = tokenizer.getTokenProbabilities();]]>
                        not infer Unicode character categories, so callers 
choose the exact script
                        coverage. The predicate checks a complete candidate 
without finding token
                        boundaries or changing its text.
-                       This policy does not yet configure tokenizer training 
or inference.
+                       <code>TokenizerME</code> applies the policy of its 
model for the alphanumeric
+                       optimization; the training section explains how a model 
selects one.
                </para>
                <programlisting language="java">
 <![CDATA[TokenizerCharacterPolicy policy = TokenizerCharacterPolicy.of(
@@ -374,6 +375,49 @@ try {
 }]]>
                                </programlisting>
                        </para>
+                       <para>
+                               The last argument of 
<code>TokenizerFactory.create</code> is the
+                               <code>TokenizerCharacterPolicy</code> that the 
alphanumeric optimization uses to
+                               accept a candidate token without consulting the 
model. Leaving it out selects
+                               the policy for the language code: ASCII letters 
and digits for most languages,
+                               and ASCII plus the letters of the language for 
Catalan (ca), Dutch (nl),
+                               French (fr), German (de), Italian (it), Polish 
(pl), Portuguese (pt) and
+                               Spanish (es); the ISO 639-2 codes are accepted 
too. None of these presets has
+                               marks, so a decomposed character such as 
<code>e</code> followed by U+0301 is
+                               not accepted by them; supply a policy with the 
combining marks of your text to
+                               accept it. The policy is written to the model 
manifest under the property
+                               <code>characterPolicy</code> as comma separated 
hex code points and ranges, for
+                               example 
<code>letters=0041-005A,0061-007A;digits=0030-0039;marks=</code> for the
+                               ASCII preset, and read back from there when the 
model is loaded.
+                               
<code>TokenizerCharacterPolicy.toString()</code> produces this form and
+                               
<code>TokenizerCharacterPolicy.parse(String)</code> reads it. For a language
+                               preset the manifest also gets the 
<code>alphaNumericPattern</code> that earlier
+                               releases wrote for that language, so they 
tokenize the model the same way.
+                       </para>
+                       <para>
+                               Models written by earlier releases carry the 
property
+                               <code>alphaNumericPattern</code> with a regular 
expression instead. A model with
+                               one of the expressions OpenNLP itself wrote for 
the languages above loads
+                               unchanged and uses the policy with the same 
characters, so the released models
+                               need no conversion. A model trained with a 
custom pattern loads too and tests
+                               candidate tokens with that regular expression. 
This regular expression support,
+                               with the <code>Pattern</code> arguments of the 
<code>TokenizerFactory</code>
+                               constructor, 
<code>TokenizerFactory.create</code> and the
+                               <code>TokSpanEventStream</code> constructor and
+                               
<code>TokenizerFactory.getAlphaNumericPattern()</code>, is deprecated and will 
be
+                               removed in 4.0. To migrate such a model, 
express the letters, digits and marks of
+                               its pattern as a 
<code>TokenizerCharacterPolicy</code>, then either train the
+                               model again with that policy, or replace 
<code>alphaNumericPattern</code> in the
+                               <code>manifest.properties</code> entry of the 
model file with
+                               <code>characterPolicy</code> set to the text 
form of the policy and zip the model
+                               again.
+                       </para>
+                       <para>
+                               A model with no policy and no pattern in its 
manifest uses the policy for its language
+                               code, as a new factory does. OpenNLP 2.x reads 
only
+                               <code>alphaNumericPattern</code>, so it applies 
its default pattern to a model
+                               written with a custom policy.
+                       </para>
                </section>
        </section>
        
diff --git 
a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/ArvoresDeitadasEval.java 
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/ArvoresDeitadasEval.java
index 1478a95af..e7e3b9ddb 100644
--- 
a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/ArvoresDeitadasEval.java
+++ 
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/ArvoresDeitadasEval.java
@@ -117,7 +117,7 @@ public class ArvoresDeitadasEval extends AbstractEvalTest {
     TokenizerCrossValidator validator;
 
     TokenizerFactory tokFactory = TokenizerFactory.create(null, LANG, null,
-        true, null);
+        true);
     validator = new opennlp.tools.tokenize.TokenizerCrossValidator(params,
         tokFactory);
 
diff --git 
a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/EnglishTokenSampleStreamEval.java
 
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/EnglishTokenSampleStreamEval.java
index 512cc49b6..425bdc978 100644
--- 
a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/EnglishTokenSampleStreamEval.java
+++ 
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/EnglishTokenSampleStreamEval.java
@@ -110,7 +110,7 @@ public class EnglishTokenSampleStreamEval extends 
AbstractEvalTest {
     final TrainingParameters params = 
ModelUtil.createDefaultTrainingParameters();
     final TokenizerModel model = TokenizerME.train(
         new CollectionObjectStream<>(samples(trainLines)),
-        TokenizerFactory.create(null, "eng", null, true, null), params);
+        TokenizerFactory.create(null, "eng", null, true), params);
     final TokenizerEvaluator evaluator = new TokenizerEvaluator(new 
TokenizerME(model));
     evaluator.evaluate(new CollectionObjectStream<>(samples(testLines)));
     final double fMeasure = evaluator.getFMeasure().getFMeasure();

Reply via email to