diff --git a/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/CategoryTable.java b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/CategoryTable.java index 7b9cc78129..48229674d5 100644 --- a/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/CategoryTable.java +++ b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/CategoryTable.java @@ -20,14 +20,17 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import opennlp.tools.tokenize.lattice.MecabDictionary.Category; /** - * The {@code char.def} code point to category name mapping over the Unicode - * code point range. + * The {@code char.def} code point to category mappings over the Unicode code point + * range. The first category on a mapping supplies the unknown-word settings. Each + * listed category can keep a group running while following characters also list it. * *
The Basic Multilingual Plane is stored in a directly indexed array. The
* supplementary planes are stored as a sorted, non-overlapping range table searched by
@@ -35,13 +38,21 @@
*/
final class CategoryTable {
- private final Category[] bmp;
+ private final CategoryAssignment[] bmp;
private final int[] rangeStart;
private final int[] rangeEnd;
- private final Category[] rangeCategory;
+ private final CategoryAssignment[] rangeCategory;
- private CategoryTable(Category[] bmp, int[] rangeStart, int[] rangeEnd,
- Category[] rangeCategory) {
+ /**
+ * Creates a table from resolved BMP entries and supplementary ranges.
+ *
+ * @param bmp The directly indexed BMP assignments.
+ * @param rangeStart The inclusive starts of the supplementary ranges.
+ * @param rangeEnd The inclusive upper bounds of the supplementary ranges.
+ * @param rangeCategory The assignment for each supplementary range.
+ */
+ private CategoryTable(CategoryAssignment[] bmp, int[] rangeStart, int[] rangeEnd,
+ CategoryAssignment[] rangeCategory) {
this.bmp = bmp;
this.rangeStart = rangeStart;
this.rangeEnd = rangeEnd;
@@ -49,14 +60,12 @@ private CategoryTable(Category[] bmp, int[] rangeStart, int[] rangeEnd,
}
/**
- * Looks up the category a {@code char.def} mapping gives a code point. The table
- * contains the {@link Category} instances themselves, and two code points of one
- * category share one instance, so categories may be compared by identity.
+ * Looks up the categories a {@code char.def} mapping gives a code point.
*
* @param codePoint The code point to classify.
- * @return The category, or {@code null} when no mapping covers the code point.
+ * @return The assignment, or {@code null} when no mapping covers the code point.
*/
- Category categoryOf(int codePoint) {
+ CategoryAssignment categoriesOf(int codePoint) {
if (codePoint <= Character.MAX_VALUE) {
return bmp[codePoint];
}
@@ -75,7 +84,62 @@ Category categoryOf(int codePoint) {
return null;
}
- private static final String CHARACTER_DEFINITION_FILE = "char.def";
+ /**
+ * The categories assigned to one code point, stored both in mapping order and as a
+ * mask over the dictionary's dense category ids.
+ */
+ static final class CategoryAssignment {
+
+ private final Category[] categories;
+ private final int categoryMask;
+
+ /**
+ * Creates an assignment with the first entry as the primary category.
+ *
+ * @param categories The categories in mapping order. Must not be empty.
+ * @throws IllegalArgumentException Thrown if {@code categories} is {@code null} or
+ * empty.
+ */
+ CategoryAssignment(Category[] categories) {
+ if (categories == null || categories.length == 0) {
+ throw new IllegalArgumentException("categories must not be null or empty");
+ }
+ this.categories = categories.clone();
+ int mask = 0;
+ for (final Category category : categories) {
+ mask |= 1 << category.id();
+ }
+ this.categoryMask = mask;
+ }
+
+ /**
+ * Returns the first category on the mapping.
+ *
+ * @return The category that supplies unknown-word settings.
+ */
+ Category primary() {
+ return categories[0];
+ }
+
+ /**
+ * Computes the run end after comparing this assignment with the next character.
+ * MeCab's
+ *
+ * {@code seekToOtherType} replaces the current mask after each accepted
+ * character, so successive assignments must overlap.
+ *
+ * @param next The next character's assignment, or {@code null} at the end of text.
+ * @param nextRunEnd The run end calculated at the next character.
+ * @param characterEnd The exclusive end of the current character.
+ * @return {@code nextRunEnd} when the assignments intersect;
+ * {@code characterEnd} otherwise.
+ */
+ int continuedRunEnd(CategoryAssignment next, int nextRunEnd,
+ int characterEnd) {
+ return next != null && (categoryMask & next.categoryMask) != 0
+ ? nextRunEnd : characterEnd;
+ }
+ }
/**
* Collects {@code char.def} mappings in file order and builds a
@@ -84,24 +148,39 @@ Category categoryOf(int codePoint) {
*/
static final class Builder {
- private final String[] bmp = new String[Character.MAX_VALUE + 1];
+ /**
+ * One mapping line retained for validation after all categories have been read.
+ *
+ * @param sourceStart The first code point on the mapping line, used in error
+ * messages even if a later mapping replaces it.
+ * @param categories The category names from the mapping, primary first.
+ */
+ private record Mapping(int sourceStart, String[] categories) {
+ }
+
+ private final String[][] bmp = new String[Character.MAX_VALUE + 1][];
private final List Unknown text is handled through the dictionary's character categories: where the
* lexicon has no entry, or a category always invokes them, unknown-word candidates are
* generated per category template, grouping runs of same-category characters when the
- * category says so. Whitespace never joins a morpheme and is never reported as one.
+ * category requests it. A multi-category run continues while successive assignments
+ * overlap. Whitespace cannot join or appear as a morpheme.
* Every reported span is in original text coordinates. {@link #analyze(String)} returns full morphemes with their dictionary features;
@@ -80,6 +82,14 @@ private static final class Node {
private Node previous;
private Node nextEndingHere;
+ /**
+ * Creates one lattice candidate.
+ *
+ * @param start The candidate start in original text coordinates.
+ * @param end The exclusive candidate end.
+ * @param entry The dictionary entry.
+ * @param unknown Whether unknown-word handling generated the candidate.
+ */
private Node(int start, int end, WordEntry entry, boolean unknown) {
this.start = start;
this.end = end;
@@ -171,7 +181,7 @@ private void decode(String text, int from, int to, List Every candidate stays inside the same-category run, so an unknown word never
- * glues characters of different categories together, and every length counts whole
- * characters rather than code units. Candidates remain inside a run connected by overlapping category assignments.
+ * Lengths count code points, not UTF-16 code units. Each instance keeps about 0.75 MB of category tables keyed by the 16-bit code-unit
- * space, so load once and share. Lexicon CSV files under the dictionary directory are
+ * Each instance uses about 0.75 MB for category tables indexed by the 16-bit
+ * code-unit space, so load once and share. Lexicon CSV files under the directory are
* read in sorted path order so tie-breaking is stable across file systems. Connection
- * costs must cover every declared matrix cell; missing pairs are rejected rather than
- * treated as cost zero. Matrix dimensions and the lexicon entry count are bounded by
- * {@link ResourceLimits#MAX_ENTRIES}, and the matrix cell count by
- * {@link ResourceLimits#MAX_MATRIX_CELLS}. Lexicon CSV fields may be
- * MeCab-quoted with {@code ""} escapes. An {@code unk.def} template must name a
- * category {@code char.def} defined.
Instances are immutable and safe to share between threads.
* @@ -73,16 +77,30 @@ public final class MecabDictionary { static final String DEFAULT_CATEGORY = "DEFAULT"; private static final String MATRIX_DEF = "matrix.def"; - private static final String CHAR_DEF = "char.def"; + static final String CHAR_DEF = "char.def"; private static final String UNK_DEF = "unk.def"; + /** + * Maximum category count accepted by MeCab's + * + * character-property compiler. + */ + private static final int MAX_CATEGORY_COUNT = 17; + + /** + * Maximum value of MeCab's + * + * 4-bit category length field. + */ + private static final int MAX_CATEGORY_LENGTH = 15; + static final String LEXICON_EXTENSION = ".csv"; static final String DEFINITION_EXTENSION = ".def"; static final String CONFIGURATION_FILE = "dicrc"; private static final String LEXICON_GLOB = "*" + LEXICON_EXTENSION; private static final char COMMENT_MARKER = '#'; - /** The prefix a {@code char.def} code point field carries, in either letter case. */ + /** The code point prefix used by {@code char.def}, in either letter case. */ private static final String HEX_PREFIX = "0x"; /** The separator between the two ends of a {@code char.def} code point range. */ @@ -108,14 +126,15 @@ record WordEntry(int leftId, int rightId, int cost, ListThe lexicon format is one entry per line: the word, its count, and optionally a
* tag, separated by whitespace. The lexicon file is user-supplied; no lexicon data is
@@ -69,6 +71,13 @@ private static final class WordTrie {
private final WordTrie[] nodes;
private final double logProbability;
+ /**
+ * Creates an immutable trie node.
+ *
+ * @param keys The sorted child labels.
+ * @param nodes The child nodes, parallel to {@code keys}.
+ * @param logProbability The word score, or {@link Double#NaN} for a nonterminal node.
+ */
private WordTrie(char[] keys, WordTrie[] nodes, double logProbability) {
this.keys = keys;
this.nodes = nodes;
@@ -92,22 +101,61 @@ private static final class WordTrieBuilder {
private final Mapopennlp.install.max.entry.bytes and
opennlp.install.max.total.bytes for larger dictionaries such as
UniDic.
- Load also rejects an unk.def template for a category
- char.def did not define, and accepts MeCab-quoted CSV fields.
+ Dictionary loading rejects empty lexicon surfaces, duplicate
+ matrix.def entries, duplicate char.def
+ categories, costs outside the signed 16-bit range, malformed text in the
+ selected encoding, and unk.def templates for undefined
+ categories. MeCab-quoted CSV fields are accepted.
+ A char.def may define up to 17 categories, and category
+ lengths range from 0 through 15, matching the MeCab character-property
+ compiler.
+ If a char.def mapping lists multiple categories, the first
+ provides unknown-word settings. Grouping continues while successive
+ assignments overlap. Unknown words use their category's unk.def
+ template first, with DEFAULT used when no category template exists.
Tar headers are checksum-validated before extraction. Files are staged on
the target filesystem and published after the archive passes validation. The
installer does not replace files already present in the target directory.
-