entry : metadata.entrySet()) {
+ if (entry.getKey() == null || entry.getValue() == null) {
+ throw new IllegalArgumentException("metadata must not contain null keys or values");
+ }
+ }
+ if (dependencies == null) {
+ throw new IllegalArgumentException("dependencies must not be null");
+ }
+ for (final WnLmfDependency dependency : dependencies) {
+ if (dependency == null) {
+ throw new IllegalArgumentException("dependencies must not contain null");
+ }
+ }
+ if (knowledgeBase == null) {
+ throw new IllegalArgumentException("knowledgeBase must not be null");
+ }
+ metadata = Map.copyOf(metadata);
+ dependencies = List.copyOf(dependencies);
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfReader.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfReader.java
new file mode 100644
index 0000000000..abf8bee4a8
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfReader.java
@@ -0,0 +1,1104 @@
+/*
+ * 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.wordnet;
+
+import java.io.BufferedInputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.xml.XMLConstants;
+import javax.xml.namespace.QName;
+import javax.xml.stream.Location;
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.wordnet.LexicalKnowledgeBase;
+import opennlp.tools.wordnet.Synset;
+import opennlp.tools.wordnet.WordNetPOS;
+import opennlp.tools.wordnet.WordNetRelation;
+
+/**
+ * Reads a WN-LMF XML document (the Global WordNet Association
+ * interchange format, used by
+ * Open English WordNet and many
+ * other language wordnets) into a {@link LexicalKnowledgeBase} using the JDK StAX parser.
+ *
+ * It reads lexical entries, synsets with their definitions, every typed synset relation in
+ * WN-LMF 1.4, and sense relations, which are represented at the synset level as documented on
+ * {@link WordNetRelation}. Elements outside that subset are skipped. Relations of type
+ * {@code other} are also skipped because the format supplies no relation type to retain. Unknown
+ * typed relations cause an
+ * {@link InvalidFormatException}. Multiple definitions are joined with {@code "; "} in document
+ * order.
+ *
+ * Lexicons must be direct children of the {@code LexicalResource} root.
+ * Parsed elements must have their WN-LMF parent, and lexical entries must have
+ * a lemma. Skipped elements include all descendants, which cannot add lexical
+ * content or dependencies. The reader does not perform full DTD validation.
+ *
+ * A sense must point to a synset with the same part of speech and a member
+ * lemma matching after case and underscore normalization. Missing targets or members
+ * and mismatched parts of speech raise {@link InvalidFormatException} with the source
+ * name and line.
+ *
+ * The parser is hardened against XXE: DTD processing and external entities are disabled, so a
+ * DOCTYPE is skipped but nothing it names is fetched or resolved.
+ *
+ * Malformed structure causes an {@link InvalidFormatException} naming the resource and, where
+ * the parser provides one, the line; I/O failures propagate as {@link IOException}.
+ * Part-of-speech code {@code s} normalizes to {@link WordNetPOS#ADJECTIVE}, and a {@code similar}
+ * relation on a verb synset maps to {@link WordNetRelation#VERB_GROUP} rather than
+ * {@link WordNetRelation#SIMILAR_TO}. Use {@link #readResource(Path)} when a document contains
+ * several lexicons; the single-lexicon {@code read} methods reject that document instead of merging
+ * language-specific indexes. WN-LMF {@code Requires} declarations are preserved as dependency
+ * metadata but are not resolved or loaded. Returned resources and lexicons are immutable and safe
+ * for concurrent lookups.
+ *
+ * @since 3.0.0
+ */
+public final class WnLmfReader {
+
+ /** The WN-LMF relation names this reader accepts, mapped to the contract relations. */
+ private static final Map RELATION_NAMES = relationNames();
+
+ /** Relations declared only for SynsetRelation in WN-LMF 1.4. */
+ private static final Set SYNSET_ONLY_RELATIONS = Set.of(
+ "attribute", "be_in_state", "causes", "classified_by", "classifies",
+ "co_agent_instrument", "co_agent_patient", "co_agent_result", "co_instrument_agent",
+ "co_instrument_patient", "co_instrument_result", "co_patient_agent",
+ "co_patient_instrument", "co_result_agent", "co_result_instrument", "co_role",
+ "direction", "entails", "eq_synonym", "holo_location", "holo_member", "holo_part",
+ "holo_portion", "holo_substance", "holonym", "hypernym", "hyponym", "in_manner",
+ "instance_hypernym", "instance_hyponym", "involved", "involved_agent",
+ "involved_direction", "involved_instrument", "involved_location", "involved_patient",
+ "involved_result", "involved_source_direction", "involved_target_direction",
+ "ir_synonym", "is_caused_by", "is_entailed_by", "is_subevent_of", "manner_of",
+ "mero_location", "mero_member", "mero_part", "mero_portion", "mero_substance",
+ "meronym", "patient", "restricted_by", "restricts", "role", "source_direction",
+ "state_of", "subevent", "target_direction");
+
+ /** Relations declared only for SenseRelation in WN-LMF 1.4. */
+ private static final Set SENSE_ONLY_RELATIONS = Set.of(
+ "body_part", "by_means_of", "derivation", "destination", "event", "has_metaphor",
+ "has_metonym", "material", "metaphor", "metonym", "participle", "pertainym",
+ "property", "secondary_aspect_ip", "secondary_aspect_pi", "simple_aspect_ip",
+ "simple_aspect_pi", "state", "undergoer", "uses", "vehicle");
+
+ /** The untyped relation that has no corresponding {@link WordNetRelation}. */
+ private static final String OTHER_RELATION = "other";
+
+ /** The element declaring a lexical entry; opened and closed by the same handlers. */
+ private static final String LEXICAL_ENTRY_ELEMENT = "LexicalEntry";
+
+ /** The element declaring a sense; opened and closed by the same handlers. */
+ private static final String SENSE_ELEMENT = "Sense";
+
+ /** The element declaring a synset; opened and closed by the same handlers. */
+ private static final String SYNSET_ELEMENT = "Synset";
+
+ /** The element declaring one independently queryable lexicon. */
+ private static final String LEXICON_ELEMENT = "Lexicon";
+
+ /** The document root containing lexicons. */
+ private static final String LEXICAL_RESOURCE_ELEMENT = "LexicalResource";
+
+ /** The resource extension form, not represented by the current knowledge-base contract. */
+ private static final String LEXICON_EXTENSION_ELEMENT = "LexiconExtension";
+
+ /** The element declaring a lexicon dependency. */
+ private static final String REQUIRES_ELEMENT = "Requires";
+
+ /** The identifier attribute shared by entries, senses, and synsets. */
+ private static final String ID_ATTRIBUTE = "id";
+
+ /** The human-readable Lexicon label attribute. */
+ private static final String LABEL_ATTRIBUTE = "label";
+
+ /** The BCP 47 Lexicon language attribute. */
+ private static final String LANGUAGE_ATTRIBUTE = "language";
+
+ /** The Lexicon version attribute. */
+ private static final String VERSION_ATTRIBUTE = "version";
+
+ /** The required lexicon reference attribute. */
+ private static final String REF_ATTRIBUTE = "ref";
+
+ /** The part-of-speech attribute shared by lemmas and synsets. */
+ private static final String PART_OF_SPEECH_ATTRIBUTE = "partOfSpeech";
+
+ /** The relation-type attribute shared by sense and synset relations. */
+ private static final String REL_TYPE_ATTRIBUTE = "relType";
+
+ /** The relation-target attribute shared by sense and synset relations. */
+ private static final String TARGET_ATTRIBUTE = "target";
+
+ /** The opening of every malformed-document message, before the resource name. */
+ private static final String MALFORMED_PREFIX = "Malformed WN-LMF document ";
+
+ /** Not instantiable. */
+ private WnLmfReader() {
+ }
+
+ /**
+ * Reads a WN-LMF XML file.
+ *
+ * @param file The XML file. Must not be {@code null} and must exist.
+ * @return The loaded lexicon.
+ * @throws IllegalArgumentException Thrown if {@code file} is {@code null} or missing.
+ * @throws InvalidFormatException Thrown if the document is malformed, contains an unsupported
+ * {@code LexiconExtension}, or contains more than one lexicon. The message names the file
+ * and, where available, the line.
+ * @throws IOException Thrown if reading the file fails.
+ */
+ public static LexicalKnowledgeBase read(Path file) throws IOException {
+ final WnLmfResource resource = readResource(file);
+ return onlyLexicon(resource, file.toString());
+ }
+
+ /**
+ * Reads every lexicon in a WN-LMF XML file without merging their lookup indexes.
+ *
+ * @param file The XML file. Must not be {@code null} and must exist.
+ * @return The lexical resource in document order.
+ * @throws IllegalArgumentException Thrown if {@code file} is {@code null} or missing.
+ * @throws InvalidFormatException Thrown if the document is malformed or contains an unsupported
+ * {@code LexiconExtension}.
+ * @throws IOException Thrown if reading the file fails.
+ */
+ public static WnLmfResource readResource(Path file) throws IOException {
+ if (file == null) {
+ throw new IllegalArgumentException("file must not be null");
+ }
+ if (!Files.isRegularFile(file)) {
+ throw new IllegalArgumentException("file does not exist or is not a regular file: " + file);
+ }
+ try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
+ return readResource(in, file.toString());
+ }
+ }
+
+ /**
+ * Reads a WN-LMF XML document from a stream. The stream is not closed.
+ *
+ * @param in The document stream. Must not be {@code null}.
+ * @param resourceName The name used in error messages. Must not be {@code null}.
+ * @return The loaded lexicon.
+ * @throws IllegalArgumentException Thrown if an argument is {@code null}.
+ * @throws InvalidFormatException Thrown if the document is malformed, contains an unsupported
+ * {@code LexiconExtension}, or contains more than one lexicon. The message names the
+ * resource and, where available, the line.
+ * @throws IOException Thrown if reading the stream fails.
+ */
+ public static LexicalKnowledgeBase read(InputStream in, String resourceName) throws IOException {
+ final WnLmfResource resource = readResource(in, resourceName);
+ return onlyLexicon(resource, resourceName);
+ }
+
+ /**
+ * Reads every lexicon in a WN-LMF document without merging their lookup indexes. The stream is
+ * not closed.
+ *
+ * @param in The document stream. Must not be {@code null}.
+ * @param resourceName The name used in error messages. Must not be {@code null}.
+ * @return The lexical resource in document order.
+ * @throws IllegalArgumentException Thrown if an argument is {@code null}.
+ * @throws InvalidFormatException Thrown if the document is malformed or contains an unsupported
+ * {@code LexiconExtension}.
+ * @throws IOException Thrown if reading the stream fails.
+ */
+ public static WnLmfResource readResource(InputStream in, String resourceName) throws IOException {
+ if (in == null) {
+ throw new IllegalArgumentException("in must not be null");
+ }
+ if (resourceName == null) {
+ throw new IllegalArgumentException("resourceName must not be null");
+ }
+ final Parser parser = new Parser(resourceName);
+ try {
+ final XMLStreamReader reader =
+ hardenedFactory().createXMLStreamReader(new NonClosingInputStream(in));
+ try {
+ parser.parse(reader);
+ } finally {
+ reader.close();
+ }
+ } catch (XMLStreamException e) {
+ // StAX wraps a failing stream read in an XMLStreamException; surface it as the I/O failure.
+ final Throwable nested = e.getNestedException() == null ? e.getCause()
+ : e.getNestedException();
+ if (nested instanceof IOException io) {
+ throw io;
+ }
+ throw parser.malformed(e.getLocation(), "XML error: " + e.getMessage(), e);
+ }
+ return parser.resource();
+ }
+
+ /**
+ * Returns the only knowledge base in a resource.
+ *
+ * @param resource The parsed resource.
+ * @param resourceName The resource name used in the rejection message.
+ * @return The resource's only knowledge base.
+ * @throws InvalidFormatException Thrown if the resource contains more than one lexicon.
+ */
+ private static LexicalKnowledgeBase onlyLexicon(WnLmfResource resource, String resourceName)
+ throws InvalidFormatException {
+ if (resource.lexicons().size() != 1) {
+ throw new InvalidFormatException("WN-LMF resource " + resourceName + " contains "
+ + resource.lexicons().size() + " lexicons; use WnLmfReader.readResource to preserve "
+ + "their boundaries");
+ }
+ return resource.lexicons().get(0).knowledgeBase();
+ }
+
+ /**
+ * Builds an XXE-hardened StAX factory: the DTD internal subset is not processed and external
+ * entities and the external DTD subset are denied, so a DOCTYPE is skipped without resolution.
+ *
+ * @return The hardened factory.
+ */
+ private static XMLInputFactory hardenedFactory() {
+ final XMLInputFactory factory = XMLInputFactory.newFactory();
+ factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE);
+ factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE);
+ factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+ factory.setProperty(XMLInputFactory.IS_COALESCING, Boolean.TRUE);
+ factory.setXMLResolver((publicId, systemId, baseUri, namespace) -> {
+ throw new XMLStreamException("External entity resolution is disabled: " + systemId);
+ });
+ return factory;
+ }
+
+ /** Prevents a StAX reader from closing the stream owned by its caller. */
+ private static final class NonClosingInputStream extends FilterInputStream {
+
+ /** Wraps the caller-owned stream. */
+ NonClosingInputStream(InputStream in) {
+ super(in);
+ }
+
+ /** Leaves the wrapped stream open. */
+ @Override
+ public void close() {
+ }
+ }
+
+ /** Holds the streaming parse state and performs post-parse resolution. */
+ private static final class Parser {
+
+ private final String resourceName;
+ private final List lexicons = new ArrayList<>();
+ private final Set lexiconIds = new HashSet<>();
+ private final Set documentIds = new HashSet<>();
+ private final Deque elements = new ArrayDeque<>();
+
+ // Current Lexicon metadata.
+ private String currentLexiconId;
+ private String currentLexiconLabel;
+ private String currentLexiconLanguage;
+ private String currentLexiconVersion;
+ private Map currentLexiconMetadata;
+ private final List currentDependencies = new ArrayList<>();
+
+ // Entry state.
+ private final Set entryIds = new HashSet<>();
+ private final Map lemmaByEntryId = new HashMap<>();
+ private final Map posByEntryId = new HashMap<>();
+ private final Map sensesById = new HashMap<>();
+ private final Map> senseOrder =
+ new LinkedHashMap<>();
+ private final List senseRelations = new ArrayList<>();
+ private final Map rawSynsets = new LinkedHashMap<>();
+ // Sense identifiers grouped by synset in source order.
+ private final Map> senseIdsBySynset = new HashMap<>();
+
+ // Cursor state.
+ private String currentEntryId;
+ private String currentEntryLemma;
+ private WordNetPOS currentEntryPos;
+ private String currentSenseId;
+ private RawSynset currentSynset;
+
+ /**
+ * Creates a parser.
+ *
+ * @param resourceName The name used in error messages.
+ */
+ private Parser(String resourceName) {
+ this.resourceName = resourceName;
+ }
+
+ /**
+ * Streams the document, dispatching start and end elements.
+ *
+ * @param reader The StAX reader.
+ * @throws XMLStreamException Thrown if the stream read fails.
+ * @throws InvalidFormatException Thrown if the document is malformed.
+ */
+ private void parse(XMLStreamReader reader) throws XMLStreamException, InvalidFormatException {
+ while (reader.hasNext()) {
+ final int event = reader.next();
+ // A DTD event has no content used after DTD processing and external entities are disabled.
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ final String name = reader.getLocalName();
+ startElement(reader);
+ // Text and skipped elements are consumed through the closing event.
+ if (reader.getEventType() == XMLStreamConstants.START_ELEMENT) {
+ elements.push(name);
+ }
+ } else if (event == XMLStreamConstants.END_ELEMENT) {
+ endElement(reader);
+ elements.pop();
+ }
+ }
+ if (currentLexiconId != null) {
+ throw malformed(reader.getLocation(), "Unclosed Lexicon " + currentLexiconId, null);
+ }
+ if (lexicons.isEmpty()) {
+ throw malformed(reader.getLocation(), "Document contains no Lexicon", null);
+ }
+ }
+
+ /**
+ * Handles one start element, updating cursor state and collecting raw entries, senses, and
+ * synsets.
+ *
+ * @param reader The StAX reader positioned on the start element.
+ * @throws XMLStreamException Thrown if reading element text fails.
+ * @throws InvalidFormatException Thrown if the element violates the format.
+ */
+ private void startElement(XMLStreamReader reader)
+ throws XMLStreamException, InvalidFormatException {
+ final String name = reader.getLocalName();
+ switch (name) {
+ case LEXICAL_RESOURCE_ELEMENT -> {
+ if (!elements.isEmpty()) {
+ throw malformed(reader.getLocation(), "Nested LexicalResource", null);
+ }
+ }
+ case LEXICON_ELEMENT -> openLexicon(reader);
+ case LEXICON_EXTENSION_ELEMENT -> throw malformed(reader.getLocation(),
+ "LexiconExtension is not supported by WnLmfReader", null);
+ case REQUIRES_ELEMENT -> {
+ requireLexicon(reader, REQUIRES_ELEMENT);
+ currentDependencies.add(new WnLmfDependency(
+ requireAttribute(reader, REF_ATTRIBUTE),
+ requireAttribute(reader, VERSION_ATTRIBUTE)));
+ }
+ case LEXICAL_ENTRY_ELEMENT -> {
+ if (currentEntryId != null) {
+ throw malformed(reader.getLocation(),
+ "Nested LexicalEntry inside " + currentEntryId, null);
+ }
+ requireLexicon(reader, LEXICAL_ENTRY_ELEMENT);
+ currentEntryId = requireAttribute(reader, ID_ATTRIBUTE);
+ if (!entryIds.add(currentEntryId)) {
+ throw malformed(reader.getLocation(),
+ "Duplicate lexical entry id " + currentEntryId, null);
+ }
+ claimDocumentId(currentEntryId, "lexical entry", reader.getLocation());
+ currentEntryLemma = null;
+ currentEntryPos = null;
+ }
+ case "Lemma" -> {
+ if (currentEntryId == null) {
+ throw malformed(reader.getLocation(), "Lemma outside a LexicalEntry", null);
+ }
+ if (currentEntryLemma != null) {
+ throw malformed(reader.getLocation(),
+ "Duplicate Lemma in LexicalEntry " + currentEntryId, null);
+ }
+ requireParent(reader, LEXICAL_ENTRY_ELEMENT);
+ currentEntryLemma = requireAttribute(reader, "writtenForm");
+ currentEntryPos = parsePos(requireAttribute(reader, PART_OF_SPEECH_ATTRIBUTE),
+ reader.getLocation());
+ lemmaByEntryId.put(currentEntryId, currentEntryLemma);
+ posByEntryId.put(currentEntryId, currentEntryPos);
+ }
+ case SENSE_ELEMENT -> {
+ if (currentSenseId != null) {
+ throw malformed(reader.getLocation(), "Nested Sense inside " + currentSenseId, null);
+ }
+ if (currentEntryLemma == null) {
+ throw malformed(reader.getLocation(),
+ "Sense before its entry's Lemma in LexicalEntry " + currentEntryId, null);
+ }
+ requireParent(reader, LEXICAL_ENTRY_ELEMENT);
+ currentSenseId = requireAttribute(reader, ID_ATTRIBUTE);
+ final String synsetId = requireAttribute(reader, "synset");
+ final RawSense sense = new RawSense(currentEntryId, synsetId, line(reader.getLocation()));
+ if (sensesById.putIfAbsent(currentSenseId, sense) != null) {
+ throw malformed(reader.getLocation(), "Duplicate sense id " + currentSenseId, null);
+ }
+ claimDocumentId(currentSenseId, "sense", reader.getLocation());
+ senseIdsBySynset.computeIfAbsent(synsetId, unused -> new ArrayList<>(2))
+ .add(currentSenseId);
+ final List order = senseOrder.computeIfAbsent(
+ InMemoryWordNetLexicon.LemmaKey.of(currentEntryLemma, currentEntryPos),
+ unused -> new ArrayList<>(2));
+ if (!order.contains(synsetId)) {
+ order.add(synsetId);
+ }
+ }
+ case "SenseRelation" -> {
+ if (currentSenseId == null) {
+ throw malformed(reader.getLocation(), "SenseRelation outside a Sense", null);
+ }
+ requireParent(reader, SENSE_ELEMENT);
+ senseRelations.add(new RawSenseRelation(currentSenseId,
+ requireAttribute(reader, REL_TYPE_ATTRIBUTE),
+ requireAttribute(reader, TARGET_ATTRIBUTE), line(reader.getLocation())));
+ }
+ case SYNSET_ELEMENT -> {
+ if (currentSynset != null) {
+ throw malformed(reader.getLocation(), "Nested Synset inside " + currentSynset.id,
+ null);
+ }
+ requireLexicon(reader, SYNSET_ELEMENT);
+ final String id = requireAttribute(reader, ID_ATTRIBUTE);
+ final WordNetPOS pos = parsePos(requireAttribute(reader, PART_OF_SPEECH_ATTRIBUTE),
+ reader.getLocation());
+ currentSynset = new RawSynset(id, pos, reader.getAttributeValue(null, "members"),
+ line(reader.getLocation()));
+ if (rawSynsets.putIfAbsent(id, currentSynset) != null) {
+ throw malformed(reader.getLocation(), "Duplicate synset id " + id, null);
+ }
+ claimDocumentId(id, "synset", reader.getLocation());
+ }
+ case "Definition" -> {
+ if (currentSynset == null) {
+ throw malformed(reader.getLocation(), "Definition outside a Synset", null);
+ }
+ requireParent(reader, SYNSET_ELEMENT);
+ currentSynset.definitions.add(reader.getElementText());
+ }
+ case "SynsetRelation" -> {
+ if (currentSynset == null) {
+ throw malformed(reader.getLocation(), "SynsetRelation outside a Synset", null);
+ }
+ requireParent(reader, SYNSET_ELEMENT);
+ final String relType = requireAttribute(reader, REL_TYPE_ATTRIBUTE);
+ final String target = requireAttribute(reader, TARGET_ATTRIBUTE);
+ // The escape-hatch type is a documented skip, not a rejection.
+ if (!OTHER_RELATION.equals(relType)) {
+ currentSynset.relations.add(
+ new RawRelation(relType, target, line(reader.getLocation()), false));
+ }
+ }
+ default -> skipElement(reader);
+ }
+ }
+
+ /**
+ * Skips an element and all descendants without changing parse state.
+ *
+ * @param reader The reader positioned on the start element.
+ * @throws XMLStreamException If the ignored XML cannot be read.
+ */
+ private void skipElement(XMLStreamReader reader) throws XMLStreamException {
+ final String name = reader.getLocalName();
+ int depth = 1;
+ while (depth > 0 && reader.hasNext()) {
+ final int event = reader.next();
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ depth++;
+ } else if (event == XMLStreamConstants.END_ELEMENT) {
+ depth--;
+ }
+ }
+ if (depth != 0) {
+ throw new XMLStreamException("Unclosed " + name, reader.getLocation());
+ }
+ }
+
+ /**
+ * Clears cursor state when a tracked element closes.
+ *
+ * @param reader The reader positioned on the closing element.
+ * @throws InvalidFormatException If an entry has no lemma or a lexicon is invalid.
+ */
+ private void endElement(XMLStreamReader reader) throws InvalidFormatException {
+ switch (reader.getLocalName()) {
+ case LEXICON_ELEMENT -> closeLexicon();
+ case LEXICAL_ENTRY_ELEMENT -> {
+ if (currentEntryLemma == null) {
+ throw malformed(reader.getLocation(),
+ "LexicalEntry " + currentEntryId + " has no Lemma", null);
+ }
+ currentEntryId = null;
+ currentEntryLemma = null;
+ currentEntryPos = null;
+ }
+ case SENSE_ELEMENT -> currentSenseId = null;
+ case SYNSET_ELEMENT -> currentSynset = null;
+ default -> {
+ // Nothing to close for skipped elements.
+ }
+ }
+ }
+
+ /**
+ * Opens one Lexicon and captures its identity and metadata.
+ *
+ * @param reader The reader positioned on the Lexicon start element.
+ * @throws InvalidFormatException Thrown if the Lexicon is nested, repeats an id, or omits a
+ * required attribute.
+ */
+ private void openLexicon(XMLStreamReader reader) throws InvalidFormatException {
+ if (currentLexiconId != null) {
+ throw malformed(reader.getLocation(), "Nested Lexicon inside " + currentLexiconId, null);
+ }
+ requireParent(reader, LEXICAL_RESOURCE_ELEMENT);
+ final String id = requireAttribute(reader, ID_ATTRIBUTE);
+ if (!lexiconIds.add(id)) {
+ throw malformed(reader.getLocation(), "Duplicate lexicon id " + id, null);
+ }
+ claimDocumentId(id, "lexicon", reader.getLocation());
+ currentLexiconId = id;
+ currentLexiconLabel = requireAttribute(reader, LABEL_ATTRIBUTE);
+ currentLexiconLanguage = requireAttribute(reader, LANGUAGE_ATTRIBUTE);
+ currentLexiconVersion = requireAttribute(reader, VERSION_ATTRIBUTE);
+ final Map metadata = new LinkedHashMap<>();
+ for (int i = 0; i < reader.getAttributeCount(); i++) {
+ final QName attribute = reader.getAttributeName(i);
+ if (!isIdentityAttribute(attribute)) {
+ metadata.put(attribute, reader.getAttributeValue(i));
+ }
+ }
+ currentLexiconMetadata = Map.copyOf(metadata);
+ }
+
+ /**
+ * Builds the current Lexicon and clears its parse state.
+ *
+ * @throws InvalidFormatException Thrown if the Lexicon content is invalid.
+ */
+ private void closeLexicon() throws InvalidFormatException {
+ if (currentLexiconId == null) {
+ return;
+ }
+ try {
+ lexicons.add(new WnLmfLexicon(currentLexiconId, currentLexiconLabel,
+ currentLexiconLanguage, currentLexiconVersion, currentLexiconMetadata,
+ currentDependencies, buildKnowledgeBase()));
+ } finally {
+ clearLexiconState();
+ }
+ }
+
+ /**
+ * Requires lexical content to be directly enclosed by a Lexicon element.
+ *
+ * @param reader The reader positioned on the content element.
+ * @param element The element name used in the rejection message.
+ * @throws InvalidFormatException If the element has no lexicon parent.
+ */
+ private void requireLexicon(XMLStreamReader reader, String element)
+ throws InvalidFormatException {
+ if (currentLexiconId == null) {
+ throw malformed(reader.getLocation(), element + " outside a Lexicon", null);
+ }
+ requireParent(reader, LEXICON_ELEMENT);
+ }
+
+ /**
+ * Checks the element's direct parent.
+ *
+ * @param reader The reader positioned on the child element.
+ * @param expected The required parent name.
+ * @throws InvalidFormatException If the parent does not match.
+ */
+ private void requireParent(XMLStreamReader reader, String expected)
+ throws InvalidFormatException {
+ final String actual = elements.peek();
+ if (!expected.equals(actual)) {
+ throw malformed(reader.getLocation(), reader.getLocalName() + " must be a direct child of "
+ + expected + "; found " + (actual == null ? "document root" : actual), null);
+ }
+ }
+
+ /**
+ * Claims an XML ID across the complete LexicalResource.
+ *
+ * @param id The identifier to claim.
+ * @param kind The element kind used in a duplicate error.
+ * @param location The source location.
+ * @throws InvalidFormatException Thrown if the id is already assigned to another element.
+ */
+ private void claimDocumentId(String id, String kind, Location location)
+ throws InvalidFormatException {
+ if (!documentIds.add(id)) {
+ throw malformed(location, "Duplicate " + kind + " id " + id, null);
+ }
+ }
+
+ /**
+ * Tests whether an attribute is exposed directly on {@link WnLmfLexicon}.
+ *
+ * @param attribute The attribute name.
+ * @return {@code true} for an unqualified id, label, language, or version attribute.
+ */
+ private boolean isIdentityAttribute(QName attribute) {
+ if (!attribute.getNamespaceURI().isEmpty()) {
+ return false;
+ }
+ return switch (attribute.getLocalPart()) {
+ case ID_ATTRIBUTE, LABEL_ATTRIBUTE, LANGUAGE_ATTRIBUTE, VERSION_ATTRIBUTE -> true;
+ default -> false;
+ };
+ }
+
+ /** Clears every field whose scope is one Lexicon element. */
+ private void clearLexiconState() {
+ currentLexiconId = null;
+ currentLexiconLabel = null;
+ currentLexiconLanguage = null;
+ currentLexiconVersion = null;
+ currentLexiconMetadata = null;
+ currentDependencies.clear();
+ entryIds.clear();
+ lemmaByEntryId.clear();
+ posByEntryId.clear();
+ sensesById.clear();
+ senseOrder.clear();
+ senseRelations.clear();
+ rawSynsets.clear();
+ senseIdsBySynset.clear();
+ currentEntryId = null;
+ currentEntryLemma = null;
+ currentEntryPos = null;
+ currentSenseId = null;
+ currentSynset = null;
+ }
+
+ /**
+ * Resolves the parsed state into an immutable lexicon, validates sense targets, represents
+ * sense relations at the synset level, and builds the public synset values.
+ *
+ * @return The loaded lexicon.
+ * @throws InvalidFormatException If a reference, member or part of speech is invalid.
+ */
+ private LexicalKnowledgeBase buildKnowledgeBase() throws InvalidFormatException {
+ for (final Map.Entry entry : sensesById.entrySet()) {
+ final RawSense sense = entry.getValue();
+ final RawSynset target = rawSynsets.get(sense.synsetId);
+ if (target == null) {
+ throw malformed(null, "Sense " + entry.getKey() + " at line " + sense.line
+ + " references undeclared synset " + sense.synsetId, null);
+ }
+ final WordNetPOS pos = posByEntryId.get(sense.entryId);
+ if (target.pos != pos) {
+ throw malformed(null, "Sense " + entry.getKey() + " at line " + sense.line
+ + " has part of speech " + pos + " but synset " + target.id + " has "
+ + target.pos, null);
+ }
+ }
+ // Lift sense relations to the synset level.
+ for (final RawSenseRelation relation : senseRelations) {
+ if (OTHER_RELATION.equals(relation.relType)) {
+ continue;
+ }
+ final RawSense sourceSense = sensesById.get(relation.sourceSenseId);
+ final RawSense targetSense = sensesById.get(relation.targetSenseId);
+ if (targetSense == null) {
+ throw malformed(null, "SenseRelation at line " + relation.line + " from sense "
+ + relation.sourceSenseId + " references undeclared sense " + relation.targetSenseId,
+ null);
+ }
+ final RawSynset source = rawSynsets.get(sourceSense.synsetId);
+ source.relations.add(
+ new RawRelation(relation.relType, targetSense.synsetId, relation.line, true));
+ }
+ // Resolve raw synsets into contract synsets.
+ final Map synsetsById =
+ LinkedHashMap.newLinkedHashMap(rawSynsets.size());
+ for (final RawSynset raw : rawSynsets.values()) {
+ final Map> relations = resolveRelations(raw);
+ synsetsById.put(raw.id,
+ new Synset(raw.id, raw.pos, memberLemmas(raw), String.join("; ", raw.definitions),
+ relations));
+ }
+ return new InMemoryWordNetLexicon(synsetsById, senseOrder);
+ }
+
+ /**
+ * Returns the parsed resource.
+ *
+ * @return The immutable resource in document order.
+ */
+ private WnLmfResource resource() {
+ return new WnLmfResource(lexicons);
+ }
+
+ /**
+ * Resolves a raw synset's relations into typed target-id lists, deduplicated in source order.
+ *
+ * @param raw The raw synset.
+ * @return The typed relations for the contract synset.
+ * @throws InvalidFormatException Thrown if a relation type is unknown or its target is
+ * undeclared.
+ */
+ private Map> resolveRelations(RawSynset raw)
+ throws InvalidFormatException {
+ final Map> typed = new LinkedHashMap<>();
+ for (final RawRelation relation : raw.relations) {
+ final WordNetRelation type = parseRelation(
+ relation.relType, raw.pos, relation.line, relation.senseRelation);
+ final RawSynset target = rawSynsets.get(relation.target);
+ if (target == null) {
+ throw malformed(null, "Relation " + relation.relType + " at line " + relation.line
+ + " on synset " + raw.id + " references undeclared synset " + relation.target, null);
+ }
+ // Share the synset table's id instance so only one copy of each id is retained.
+ typed.computeIfAbsent(type, unused -> new LinkedHashSet<>()).add(target.id);
+ }
+ final Map> relations =
+ LinkedHashMap.newLinkedHashMap(typed.size());
+ for (final Map.Entry> entry : typed.entrySet()) {
+ relations.put(entry.getKey(), List.copyOf(entry.getValue()));
+ }
+ return relations;
+ }
+
+ /**
+ * Resolves a synset's member sense ids to their entry lemmas, from the {@code members}
+ * attribute when present and otherwise from the senses that pointed at the synset. Legacy
+ * documents that put lexical-entry ids in {@code members} remain accepted.
+ *
+ * @param raw The raw synset.
+ * @return The member lemmas in source order, deduplicated.
+ * @throws InvalidFormatException Thrown if the synset names an undeclared member or a
+ * member has a different part of speech, or a sense's lemma is missing.
+ */
+ private List memberLemmas(RawSynset raw) throws InvalidFormatException {
+ final List senseIds = senseIdsBySynset.getOrDefault(raw.id, List.of());
+ final List memberIds;
+ if (raw.members != null && !raw.members.isEmpty()) {
+ memberIds = LemmaFolding.splitOnSpaces(raw.members);
+ } else {
+ memberIds = senseIds;
+ }
+ final List lemmas = new ArrayList<>(memberIds.size());
+ final Set foldedLemmas = new HashSet<>();
+ for (final String memberId : memberIds) {
+ final RawSense sense = sensesById.get(memberId);
+ if (sense != null && !raw.id.equals(sense.synsetId)) {
+ throw malformed(null, "Synset " + raw.id + " at line " + raw.line
+ + " lists sense " + memberId + " assigned to synset " + sense.synsetId, null);
+ }
+ final String entryId = sense == null ? memberId : sense.entryId;
+ final String lemma = lemmaByEntryId.get(entryId);
+ if (lemma == null) {
+ throw malformed(null, "Synset " + raw.id + " at line " + raw.line
+ + " lists undeclared member sense or entry " + memberId, null);
+ }
+ if (raw.pos != posByEntryId.get(entryId)) {
+ throw malformed(null, "Synset " + raw.id + " at line " + raw.line
+ + " has part of speech " + raw.pos + " but member entry " + entryId
+ + " has " + posByEntryId.get(entryId), null);
+ }
+ if (!lemmas.contains(lemma)) {
+ lemmas.add(lemma);
+ foldedLemmas.add(LemmaFolding.fold(lemma));
+ }
+ }
+ for (final String senseId : senseIds) {
+ final RawSense sense = sensesById.get(senseId);
+ final String lemma = lemmaByEntryId.get(sense.entryId);
+ if (!foldedLemmas.contains(LemmaFolding.fold(lemma))) {
+ throw malformed(null, "Sense " + senseId + " at line " + sense.line + " has lemma "
+ + lemma + " missing from members of synset " + raw.id, null);
+ }
+ }
+ return lemmas;
+ }
+
+ /**
+ * Maps a WN-LMF part-of-speech code to a {@link WordNetPOS}; code {@code s} normalizes to
+ * {@link WordNetPOS#ADJECTIVE}.
+ *
+ * @param code The part-of-speech code.
+ * @param location The parser location, for error reporting.
+ * @return The part of speech.
+ * @throws InvalidFormatException Thrown if the code is unknown.
+ */
+ private WordNetPOS parsePos(String code, Location location) throws InvalidFormatException {
+ return switch (code) {
+ case "n" -> WordNetPOS.NOUN;
+ case "v" -> WordNetPOS.VERB;
+ case "a", "s" -> WordNetPOS.ADJECTIVE;
+ case "r" -> WordNetPOS.ADVERB;
+ default -> throw malformed(location, "Unknown part-of-speech code: " + code, null);
+ };
+ }
+
+ /**
+ * Maps a WN-LMF relation name to a {@link WordNetRelation}, enforcing whether the DTD permits
+ * it on a SenseRelation or SynsetRelation. A {@code similar} relation on a verb synset maps to
+ * {@link WordNetRelation#VERB_GROUP}, otherwise to {@link WordNetRelation#SIMILAR_TO}.
+ *
+ * @param relType The relation name.
+ * @param sourcePos The part of speech of the source synset.
+ * @param line The document line, for error reporting.
+ * @param senseRelation Whether the relation originated on a Sense.
+ * @return The mapped relation.
+ * @throws InvalidFormatException Thrown if the relation name is unknown.
+ */
+ private WordNetRelation parseRelation(String relType, WordNetPOS sourcePos, int line,
+ boolean senseRelation)
+ throws InvalidFormatException {
+ if (senseRelation && SYNSET_ONLY_RELATIONS.contains(relType)) {
+ throw malformed(null, "Relation type " + relType
+ + " is not legal on SenseRelation at line " + line, null);
+ }
+ if (!senseRelation && SENSE_ONLY_RELATIONS.contains(relType)) {
+ throw malformed(null, "Relation type " + relType
+ + " is not legal on SynsetRelation at line " + line, null);
+ }
+ if ("similar".equals(relType)) {
+ return !senseRelation && sourcePos == WordNetPOS.VERB ? WordNetRelation.VERB_GROUP
+ : WordNetRelation.SIMILAR_TO;
+ }
+ final WordNetRelation relation = RELATION_NAMES.get(relType);
+ if (relation == null) {
+ throw malformed(null, "Unknown relation type " + relType + " at line " + line, null);
+ }
+ return relation;
+ }
+
+ /**
+ * Reads a required attribute from the current element.
+ *
+ * @param reader The StAX reader.
+ * @param attribute The attribute name.
+ * @return The non-empty attribute value.
+ * @throws InvalidFormatException Thrown if the attribute is absent or empty.
+ */
+ private String requireAttribute(XMLStreamReader reader, String attribute)
+ throws InvalidFormatException {
+ final String value = reader.getAttributeValue(null, attribute);
+ if (value == null || value.isEmpty()) {
+ throw malformed(reader.getLocation(), "Element " + reader.getLocalName()
+ + " is missing required attribute " + attribute, null);
+ }
+ return value;
+ }
+
+ /**
+ * Builds a malformed-document exception naming the resource and, when known, the line.
+ *
+ * @param location The parser location, or {@code null} when unavailable.
+ * @param message The failure detail.
+ * @param cause The underlying cause, or {@code null}.
+ * @return The exception to throw.
+ */
+ private InvalidFormatException malformed(Location location, String message, Throwable cause) {
+ final int line = line(location);
+ final String prefix = line < 0 ? MALFORMED_PREFIX + resourceName + ": "
+ : MALFORMED_PREFIX + resourceName + " at line " + line + ": ";
+ return cause == null ? new InvalidFormatException(prefix + message)
+ : new InvalidFormatException(prefix + message, cause);
+ }
+
+ /**
+ * Extracts a line number from a parser location.
+ *
+ * @param location The location, or {@code null}.
+ * @return The line number, or {@code -1} when unknown.
+ */
+ private int line(Location location) {
+ return location == null ? -1 : location.getLineNumber();
+ }
+ }
+
+ /**
+ * A parsed sense with references and a source line for validation.
+ *
+ * @param entryId The owning entry identifier.
+ * @param synsetId The target synset identifier.
+ * @param line The source line.
+ */
+ private record RawSense(String entryId, String synsetId, int line) {
+ }
+
+ /** A parsed synset, kept until its members and relation targets can be resolved. */
+ private static final class RawSynset {
+ private final String id;
+ private final WordNetPOS pos;
+ private final String members;
+ private final int line;
+ private final List relations = new ArrayList<>(4);
+ private final List definitions = new ArrayList<>(1);
+
+ /**
+ * Creates a raw synset gathered during parsing.
+ *
+ * @param id The synset id.
+ * @param pos The part of speech.
+ * @param members The {@code members} attribute value, or {@code null} when absent.
+ * @param line The document line.
+ */
+ private RawSynset(String id, WordNetPOS pos, String members, int line) {
+ this.id = id;
+ this.pos = pos;
+ this.members = members;
+ this.line = line;
+ }
+ }
+
+ /** A parsed synset relation, kept until the target synset is known. */
+ private record RawRelation(String relType, String target, int line, boolean senseRelation) {
+ }
+
+ /** A parsed sense relation, kept until both sense ids are known. */
+ private record RawSenseRelation(String sourceSenseId, String relType, String targetSenseId,
+ int line) {
+ }
+
+ /**
+ * Builds the WN-LMF relation-name to {@link WordNetRelation} table.
+ *
+ * @return The immutable name table.
+ */
+ private static Map relationNames() {
+ final Map names = new HashMap<>();
+ names.put("agent", WordNetRelation.AGENT);
+ names.put("also", WordNetRelation.ALSO_SEE);
+ names.put("anto_converse", WordNetRelation.ANTO_CONVERSE);
+ names.put("anto_gradable", WordNetRelation.ANTO_GRADABLE);
+ names.put("anto_simple", WordNetRelation.ANTO_SIMPLE);
+ names.put("antonym", WordNetRelation.ANTONYM);
+ names.put("attribute", WordNetRelation.ATTRIBUTE);
+ names.put("augmentative", WordNetRelation.AUGMENTATIVE);
+ names.put("be_in_state", WordNetRelation.BE_IN_STATE);
+ names.put("body_part", WordNetRelation.BODY_PART);
+ names.put("by_means_of", WordNetRelation.BY_MEANS_OF);
+ names.put("causes", WordNetRelation.CAUSE);
+ names.put("classified_by", WordNetRelation.CLASSIFIED_BY);
+ names.put("classifies", WordNetRelation.CLASSIFIES);
+ names.put("co_agent_instrument", WordNetRelation.CO_AGENT_INSTRUMENT);
+ names.put("co_agent_patient", WordNetRelation.CO_AGENT_PATIENT);
+ names.put("co_agent_result", WordNetRelation.CO_AGENT_RESULT);
+ names.put("co_instrument_agent", WordNetRelation.CO_INSTRUMENT_AGENT);
+ names.put("co_instrument_patient", WordNetRelation.CO_INSTRUMENT_PATIENT);
+ names.put("co_instrument_result", WordNetRelation.CO_INSTRUMENT_RESULT);
+ names.put("co_patient_agent", WordNetRelation.CO_PATIENT_AGENT);
+ names.put("co_patient_instrument", WordNetRelation.CO_PATIENT_INSTRUMENT);
+ names.put("co_result_agent", WordNetRelation.CO_RESULT_AGENT);
+ names.put("co_result_instrument", WordNetRelation.CO_RESULT_INSTRUMENT);
+ names.put("co_role", WordNetRelation.CO_ROLE);
+ names.put("derivation", WordNetRelation.DERIVATIONALLY_RELATED);
+ names.put("destination", WordNetRelation.DESTINATION);
+ names.put("diminutive", WordNetRelation.DIMINUTIVE);
+ names.put("direction", WordNetRelation.DIRECTION);
+ names.put("domain_region", WordNetRelation.DOMAIN_REGION);
+ names.put("domain_topic", WordNetRelation.DOMAIN_TOPIC);
+ names.put("entails", WordNetRelation.ENTAILMENT);
+ names.put("eq_synonym", WordNetRelation.EQ_SYNONYM);
+ names.put("event", WordNetRelation.EVENT);
+ names.put("exemplifies", WordNetRelation.DOMAIN_USAGE);
+ names.put("feminine", WordNetRelation.FEMININE);
+ names.put("has_augmentative", WordNetRelation.HAS_AUGMENTATIVE);
+ names.put("has_diminutive", WordNetRelation.HAS_DIMINUTIVE);
+ names.put("has_domain_region", WordNetRelation.MEMBER_OF_DOMAIN_REGION);
+ names.put("has_domain_topic", WordNetRelation.MEMBER_OF_DOMAIN_TOPIC);
+ names.put("has_feminine", WordNetRelation.HAS_FEMININE);
+ names.put("has_masculine", WordNetRelation.HAS_MASCULINE);
+ names.put("has_metaphor", WordNetRelation.HAS_METAPHOR);
+ names.put("has_metonym", WordNetRelation.HAS_METONYM);
+ names.put("has_young", WordNetRelation.HAS_YOUNG);
+ names.put("holo_location", WordNetRelation.LOCATION_HOLONYM);
+ names.put("holo_member", WordNetRelation.MEMBER_HOLONYM);
+ names.put("holo_part", WordNetRelation.PART_HOLONYM);
+ names.put("holo_portion", WordNetRelation.PORTION_HOLONYM);
+ names.put("holo_substance", WordNetRelation.SUBSTANCE_HOLONYM);
+ names.put("holonym", WordNetRelation.HOLONYM);
+ names.put("hypernym", WordNetRelation.HYPERNYM);
+ names.put("instance_hypernym", WordNetRelation.INSTANCE_HYPERNYM);
+ names.put("hyponym", WordNetRelation.HYPONYM);
+ names.put("instance_hyponym", WordNetRelation.INSTANCE_HYPONYM);
+ names.put("in_manner", WordNetRelation.IN_MANNER);
+ names.put("instrument", WordNetRelation.INSTRUMENT);
+ names.put("involved", WordNetRelation.INVOLVED);
+ names.put("involved_agent", WordNetRelation.INVOLVED_AGENT);
+ names.put("involved_direction", WordNetRelation.INVOLVED_DIRECTION);
+ names.put("involved_instrument", WordNetRelation.INVOLVED_INSTRUMENT);
+ names.put("involved_location", WordNetRelation.INVOLVED_LOCATION);
+ names.put("involved_patient", WordNetRelation.INVOLVED_PATIENT);
+ names.put("involved_result", WordNetRelation.INVOLVED_RESULT);
+ names.put("involved_source_direction", WordNetRelation.INVOLVED_SOURCE_DIRECTION);
+ names.put("involved_target_direction", WordNetRelation.INVOLVED_TARGET_DIRECTION);
+ names.put("ir_synonym", WordNetRelation.IR_SYNONYM);
+ names.put("is_caused_by", WordNetRelation.CAUSED_BY);
+ names.put("is_entailed_by", WordNetRelation.ENTAILED_BY);
+ names.put("is_exemplified_by", WordNetRelation.MEMBER_OF_DOMAIN_USAGE);
+ names.put("is_subevent_of", WordNetRelation.IS_SUBEVENT_OF);
+ names.put("location", WordNetRelation.LOCATION);
+ names.put("manner_of", WordNetRelation.MANNER_OF);
+ names.put("masculine", WordNetRelation.MASCULINE);
+ names.put("material", WordNetRelation.MATERIAL);
+ names.put("mero_location", WordNetRelation.LOCATION_MERONYM);
+ names.put("mero_member", WordNetRelation.MEMBER_MERONYM);
+ names.put("mero_part", WordNetRelation.PART_MERONYM);
+ names.put("mero_portion", WordNetRelation.PORTION_MERONYM);
+ names.put("mero_substance", WordNetRelation.SUBSTANCE_MERONYM);
+ names.put("meronym", WordNetRelation.MERONYM);
+ names.put("metaphor", WordNetRelation.METAPHOR);
+ names.put("metonym", WordNetRelation.METONYM);
+ names.put("participle", WordNetRelation.PARTICIPLE);
+ names.put("patient", WordNetRelation.PATIENT);
+ names.put("pertainym", WordNetRelation.PERTAINYM);
+ names.put("property", WordNetRelation.PROPERTY);
+ names.put("restricted_by", WordNetRelation.RESTRICTED_BY);
+ names.put("restricts", WordNetRelation.RESTRICTS);
+ names.put("result", WordNetRelation.RESULT);
+ names.put("role", WordNetRelation.ROLE);
+ names.put("secondary_aspect_ip", WordNetRelation.SECONDARY_ASPECT_IP);
+ names.put("secondary_aspect_pi", WordNetRelation.SECONDARY_ASPECT_PI);
+ names.put("simple_aspect_ip", WordNetRelation.SIMPLE_ASPECT_IP);
+ names.put("simple_aspect_pi", WordNetRelation.SIMPLE_ASPECT_PI);
+ names.put("source_direction", WordNetRelation.SOURCE_DIRECTION);
+ names.put("state", WordNetRelation.STATE);
+ names.put("state_of", WordNetRelation.STATE_OF);
+ names.put("subevent", WordNetRelation.SUBEVENT);
+ names.put("target_direction", WordNetRelation.TARGET_DIRECTION);
+ names.put("undergoer", WordNetRelation.UNDERGOER);
+ names.put("uses", WordNetRelation.USES);
+ names.put("vehicle", WordNetRelation.VEHICLE);
+ names.put("young", WordNetRelation.YOUNG);
+ // Additional names used by WN-LMF producers.
+ names.put("domain_usage", WordNetRelation.DOMAIN_USAGE);
+ names.put("has_domain_usage", WordNetRelation.MEMBER_OF_DOMAIN_USAGE);
+ return Map.copyOf(names);
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfResource.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfResource.java
new file mode 100644
index 0000000000..c503ff2e12
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfResource.java
@@ -0,0 +1,74 @@
+/*
+ * 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.wordnet;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * An ordered WN-LMF lexical resource containing one or more independently queryable lexicons.
+ * Each lexicon has an independent lookup index.
+ *
+ * @param lexicons The lexicons in document order. Must not be {@code null} or empty, must not
+ * contain {@code null}, and ids must be unique.
+ * @since 3.0.0
+ */
+public record WnLmfResource(List lexicons) {
+
+ /**
+ * Creates a WN-LMF resource.
+ *
+ * @throws IllegalArgumentException Thrown if {@code lexicons} violates its documented
+ * constraint.
+ */
+ public WnLmfResource {
+ if (lexicons == null || lexicons.isEmpty()) {
+ throw new IllegalArgumentException("lexicons must not be null or empty");
+ }
+ final Set ids = HashSet.newHashSet(lexicons.size());
+ for (final WnLmfLexicon lexicon : lexicons) {
+ if (lexicon == null) {
+ throw new IllegalArgumentException("lexicons must not contain null");
+ }
+ if (!ids.add(lexicon.id())) {
+ throw new IllegalArgumentException("Duplicate lexicon id " + lexicon.id());
+ }
+ }
+ lexicons = List.copyOf(lexicons);
+ }
+
+ /**
+ * Finds a lexicon by its WN-LMF id.
+ *
+ * @param id The exact lexicon id. Must not be {@code null}.
+ * @return The lexicon, or empty when no lexicon has the id.
+ * @throws IllegalArgumentException Thrown if {@code id} is {@code null}.
+ */
+ public Optional lexicon(String id) {
+ if (id == null) {
+ throw new IllegalArgumentException("id must not be null");
+ }
+ for (final WnLmfLexicon lexicon : lexicons) {
+ if (lexicon.id().equals(id)) {
+ return Optional.of(lexicon);
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WndbReader.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WndbReader.java
new file mode 100644
index 0000000000..6b46983126
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WndbReader.java
@@ -0,0 +1,815 @@
+/*
+ * 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.wordnet;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.wordnet.LexicalKnowledgeBase;
+import opennlp.tools.wordnet.Synset;
+import opennlp.tools.wordnet.WordNetPOS;
+import opennlp.tools.wordnet.WordNetRelation;
+
+/**
+ * Reads a Princeton WordNet database directory in the
+ * WNDB format
+ * ({@code index.noun}, {@code data.noun}, and the corresponding pairs for verbs, adjectives, and
+ * adverbs) into a {@link LexicalKnowledgeBase}.
+ *
+ * All eight index and data files must be present. License preamble lines (which begin with a
+ * space in the released files) are skipped. {@code index.sense} is not read, and the
+ * {@code *.exc} exception lists are the {@link MorphyLemmatizer} companion input, read
+ * separately.
+ *
+ * Synset ids use {@code wndb-}offset{@code -}pos, formed from the data file's
+ * 8-digit byte offset and part-of-speech letter, for example {@code wndb-00001740-n}; the id is
+ * opaque to consumers. Adjective satellite lines normalize to {@link WordNetPOS#ADJECTIVE}, the
+ * syntactic markers the adjective files append ({@code (p)}, {@code (a)}, {@code (ip)}) are
+ * stripped, and underscores in lemmas become spaces. Sense order per lemma follows the index
+ * file's offset order.
+ *
+ * Malformed content causes an {@link InvalidFormatException} naming the file and line; I/O
+ * failures propagate as {@link IOException}. The returned lexicon is immutable and safe for
+ * concurrent lookups.
+ *
+ * @since 3.0.0
+ */
+public final class WndbReader {
+
+ /** The WNDB pointer symbols this reader accepts, mapped to the contract relations. */
+ private static final Map POINTER_SYMBOLS = pointerSymbols();
+
+ /** Pointer symbols used by index files, where instance and domain subtypes are collapsed. */
+ private static final Set INDEX_POINTER_SYMBOLS = Set.of(
+ "!", "@", "~", "#m", "#s", "#p", "%m", "%s", "%p", "=", "+", "*", ">", "^",
+ "$", "&", "<", "\\", ";", "-");
+
+ /** The prefix used for every synset id returned by this reader. */
+ private static final String SYNSET_ID_PREFIX = "wndb-";
+
+ /** The failure detail for a synset offset field that is not exactly 8 digits. */
+ private static final String MALFORMED_OFFSET = "Synset offset must be 8 digits, got: ";
+
+ /** Not instantiable. */
+ private WndbReader() {
+ }
+
+ /**
+ * Builds a synset id in this reader's scheme: the {@code wndb-} prefix, the 8-digit data-file
+ * byte offset, a hyphen, and the part-of-speech letter, for example {@code wndb-00001740-n}.
+ *
+ * @param offset The 8-digit synset offset field.
+ * @param posChar The WNDB part-of-speech letter.
+ * @return The synset id.
+ */
+ private static String synsetId(String offset, char posChar) {
+ return SYNSET_ID_PREFIX + offset + '-' + posChar;
+ }
+
+ /**
+ * Reads a WNDB database directory.
+ *
+ * @param directory The directory containing the eight index and data files. Must not be
+ * {@code null} and must exist.
+ * @return The loaded lexicon.
+ * @throws IllegalArgumentException Thrown if {@code directory} is {@code null} or not a
+ * directory.
+ * @throws InvalidFormatException Thrown if a database file is missing or any file is
+ * malformed; the message names the file and line.
+ * @throws IOException Thrown if reading a file fails.
+ */
+ public static LexicalKnowledgeBase read(Path directory) throws IOException {
+ if (directory == null) {
+ throw new IllegalArgumentException("directory must not be null");
+ }
+ if (!Files.isDirectory(directory)) {
+ throw new IllegalArgumentException(
+ "directory does not exist or is not a directory: " + directory);
+ }
+ final Map rawSynsets = new LinkedHashMap<>();
+ for (final FilePos filePos : FilePos.values()) {
+ parseDataFile(directory, filePos, rawSynsets);
+ }
+ final Map synsetsById = resolve(rawSynsets);
+ final Map> senseOrder = new LinkedHashMap<>();
+ for (final FilePos filePos : FilePos.values()) {
+ parseIndexFile(directory, filePos, rawSynsets, senseOrder);
+ }
+ return new InMemoryWordNetLexicon(synsetsById, senseOrder);
+ }
+
+ /** The four part-of-speech file pairs of a WNDB directory. */
+ private enum FilePos {
+ NOUN("noun", 'n', WordNetPOS.NOUN),
+ VERB("verb", 'v', WordNetPOS.VERB),
+ ADJECTIVE("adj", 'a', WordNetPOS.ADJECTIVE),
+ ADVERB("adv", 'r', WordNetPOS.ADVERB);
+
+ private final String suffix;
+ private final char posChar;
+ private final WordNetPOS pos;
+
+ /**
+ * Binds a part of speech to its file suffix and WNDB letter.
+ *
+ * @param suffix The file suffix, for example {@code noun}.
+ * @param posChar The WNDB part-of-speech letter.
+ * @param pos The mapped part of speech.
+ */
+ FilePos(String suffix, char posChar, WordNetPOS pos) {
+ this.suffix = suffix;
+ this.posChar = posChar;
+ this.pos = pos;
+ }
+ }
+
+ /**
+ * Parses one {@code data.*} file, collecting its synsets by id.
+ *
+ * @param directory The database directory.
+ * @param filePos The part-of-speech file pair.
+ * @param rawSynsets The accumulating synset table.
+ * @throws IOException Thrown if the file is missing, malformed, or unreadable.
+ */
+ private static void parseDataFile(Path directory, FilePos filePos,
+ Map rawSynsets) throws IOException {
+ final String fileName = "data." + filePos.suffix;
+ final byte[] bytes = readAll(directory.resolve(fileName));
+ int lineStart = 0;
+ int lineNumber = 0;
+ while (lineStart < bytes.length) {
+ lineNumber++;
+ int lineEnd = lineStart;
+ while (lineEnd < bytes.length && bytes[lineEnd] != '\n') {
+ lineEnd++;
+ }
+ // ISO-8859-1 decodes bytes one-to-one, keeping offsets exact for any released file.
+ final String line =
+ new String(bytes, lineStart, lineEnd - lineStart, StandardCharsets.ISO_8859_1);
+ if (!line.isEmpty() && line.charAt(0) != ' ') {
+ parseDataLine(line, lineStart, fileName, lineNumber, filePos, rawSynsets);
+ }
+ lineStart = lineEnd + 1;
+ }
+ }
+
+ /**
+ * Parses one data-file synset line into a raw synset.
+ *
+ * @param line The decoded line, without its trailing newline.
+ * @param byteOffset The line's byte offset, matched against the line's own offset field.
+ * @param fileName The data file name, for error reporting.
+ * @param lineNumber The 1-based line number.
+ * @param filePos The part-of-speech file pair.
+ * @param rawSynsets The accumulating synset table.
+ * @throws InvalidFormatException Thrown if the line is malformed or its offset field disagrees
+ * with its byte position.
+ */
+ private static void parseDataLine(String line, int byteOffset, String fileName, int lineNumber,
+ FilePos filePos, Map rawSynsets)
+ throws InvalidFormatException {
+ final Tokenizer tokens = new Tokenizer(line, fileName, lineNumber);
+ final String offsetField = tokens.next("synset_offset");
+ if (parseOffset(offsetField, tokens) != byteOffset) {
+ throw malformed(fileName, lineNumber, "Synset offset field " + offsetField
+ + " disagrees with the actual byte position " + byteOffset);
+ }
+ final int lexicographerFile = tokens.nextFixedInt(
+ "lex_filenum", 10, 2);
+ if (lexicographerFile > 44) {
+ throw malformed(fileName, lineNumber,
+ "lex_filenum must be between 00 and 44, got: " + lexicographerFile);
+ }
+ final String ssType = tokens.next("ss_type (synset type)");
+ final boolean validType = switch (filePos) {
+ case ADJECTIVE -> "a".equals(ssType) || "s".equals(ssType);
+ default -> ssType.length() == 1 && ssType.charAt(0) == filePos.posChar;
+ };
+ if (!validType) {
+ throw malformed(fileName, lineNumber,
+ "Synset type " + ssType + " does not belong in " + fileName);
+ }
+ final int wordCount = tokens.nextFixedCount(
+ "w_cnt", 16, 2, "Word count");
+ if (wordCount < 1) {
+ throw malformed(fileName, lineNumber, "Word count must be at least 1, got: " + wordCount);
+ }
+ final List lemmas = new ArrayList<>(wordCount);
+ for (int i = 0; i < wordCount; i++) {
+ final String lemma = cleanLemma(tokens.next("word"), fileName, lineNumber, filePos);
+ tokens.nextFixedInt("lex_id", 16, 1);
+ if (!lemmas.contains(lemma)) {
+ lemmas.add(lemma);
+ }
+ }
+ final int pointerCount = tokens.nextFixedCount(
+ "p_cnt", 10, 3, "Pointer count");
+ final List pointers = new ArrayList<>(pointerCount);
+ for (int i = 0; i < pointerCount; i++) {
+ final String symbol = tokens.next("pointer_symbol");
+ final WordNetRelation relation = POINTER_SYMBOLS.get(symbol);
+ if (relation == null) {
+ throw malformed(fileName, lineNumber, "Undeclared pointer symbol: " + symbol);
+ }
+ final String targetOffset = tokens.next("pointer synset_offset");
+ parseOffset(targetOffset, tokens);
+ final char targetPos = posChar(tokens.next("pointer pos"), tokens);
+ final int sourceTarget = tokens.nextFixedInt("pointer source/target", 16, 4);
+ final int sourceWord = sourceTarget >>> 8;
+ final int targetWord = sourceTarget & 0xff;
+ if (sourceWord > wordCount) {
+ throw malformed(fileName, lineNumber, "Pointer source word " + sourceWord
+ + " exceeds word count " + wordCount);
+ }
+ if ((sourceWord == 0) != (targetWord == 0)) {
+ throw malformed(fileName, lineNumber,
+ "Pointer source and target words must both be zero or nonzero");
+ }
+ pointers.add(new RawPointer(relation, synsetId(targetOffset, targetPos),
+ targetWord, lineNumber));
+ }
+ if (filePos == FilePos.VERB) {
+ final int frameCount = tokens.nextFixedCount(
+ "f_cnt", 10, 2, "Verb frame count");
+ for (int i = 0; i < frameCount; i++) {
+ final String marker = tokens.next("verb frame marker");
+ if (!"+".equals(marker)) {
+ throw malformed(fileName, lineNumber,
+ "Expected + before a verb frame, got: " + marker);
+ }
+ final int frameNumber = tokens.nextFixedInt("f_num", 10, 2);
+ if (frameNumber < 1 || frameNumber > 35) {
+ throw malformed(fileName, lineNumber,
+ "f_num must be between 01 and 35, got: " + frameNumber);
+ }
+ final int wordNumber = tokens.nextFixedInt("w_num", 16, 2);
+ if (wordNumber > wordCount) {
+ throw malformed(fileName, lineNumber, "Verb frame word " + wordNumber
+ + " exceeds word count " + wordCount);
+ }
+ }
+ }
+ final String gloss = tokens.gloss();
+ final String id = synsetId(offsetField, filePos.posChar);
+ rawSynsets.put(id, new RawSynset(id, filePos.pos, lemmas, gloss, pointers,
+ fileName, lineNumber));
+ }
+
+ /**
+ * Resolves raw synsets into contract synsets, validating every pointer target.
+ *
+ * @param rawSynsets The parsed synsets keyed by id.
+ * @return The contract synsets keyed by id.
+ * @throws InvalidFormatException Thrown if a pointer targets a nonexistent synset.
+ */
+ private static Map resolve(Map rawSynsets)
+ throws InvalidFormatException {
+ final Map synsetsById =
+ LinkedHashMap.newLinkedHashMap(rawSynsets.size());
+ for (final RawSynset raw : rawSynsets.values()) {
+ final Map> typed = new LinkedHashMap<>();
+ for (final RawPointer pointer : raw.pointers) {
+ final RawSynset target = rawSynsets.get(pointer.targetId);
+ if (target == null) {
+ throw malformed(raw.fileName, pointer.lineNumber, "Synset " + raw.id + " has a "
+ + pointer.relation + " pointer to nonexistent synset " + pointer.targetId);
+ }
+ if (pointer.targetWord > target.lemmas.size()) {
+ throw malformed(raw.fileName, pointer.lineNumber, "Pointer target word "
+ + pointer.targetWord + " exceeds target word count " + target.lemmas.size());
+ }
+ // Share the synset table's id instance so only one copy of each id is retained.
+ typed.computeIfAbsent(pointer.relation, unused -> new LinkedHashSet<>())
+ .add(target.id);
+ }
+ final Map> relations =
+ LinkedHashMap.newLinkedHashMap(typed.size());
+ for (final Map.Entry> entry : typed.entrySet()) {
+ relations.put(entry.getKey(), List.copyOf(entry.getValue()));
+ }
+ synsetsById.put(raw.id, new Synset(raw.id, raw.pos, raw.lemmas, raw.gloss, relations));
+ }
+ return synsetsById;
+ }
+
+ /**
+ * Parses one {@code index.*} file, building the sense order per folded lemma key.
+ *
+ * @param directory The database directory.
+ * @param filePos The part-of-speech file pair.
+ * @param rawSynsets The resolved synset table, for offset validation.
+ * @param senses The accumulating sense-order map.
+ * @throws IOException Thrown if the file is missing, malformed, or unreadable.
+ */
+ private static void parseIndexFile(Path directory, FilePos filePos,
+ Map rawSynsets,
+ Map> senses)
+ throws IOException {
+ final String fileName = "index." + filePos.suffix;
+ final byte[] bytes = readAll(directory.resolve(fileName));
+ final String content = new String(bytes, StandardCharsets.ISO_8859_1);
+ int lineNumber = 0;
+ int lineStart = 0;
+ while (lineStart < content.length()) {
+ lineNumber++;
+ int lineEnd = content.indexOf('\n', lineStart);
+ if (lineEnd < 0) {
+ lineEnd = content.length();
+ }
+ final String line = content.substring(lineStart, lineEnd);
+ if (!line.isEmpty() && line.charAt(0) != ' ') {
+ parseIndexLine(line, fileName, lineNumber, filePos, rawSynsets, senses);
+ }
+ lineStart = lineEnd + 1;
+ }
+ }
+
+ /**
+ * Parses one index-file line into a lemma's sense order.
+ *
+ * @param line The line to parse.
+ * @param fileName The index file name, for error reporting.
+ * @param lineNumber The 1-based line number.
+ * @param filePos The part-of-speech file pair.
+ * @param rawSynsets The resolved synset table, for offset validation.
+ * @param senses The accumulating sense-order map.
+ * @throws InvalidFormatException Thrown if the line is malformed or references an unknown
+ * offset.
+ */
+ private static void parseIndexLine(String line, String fileName, int lineNumber,
+ FilePos filePos, Map rawSynsets,
+ Map> senses)
+ throws InvalidFormatException {
+ final Tokenizer tokens = new Tokenizer(line, fileName, lineNumber);
+ final String lemma = tokens.next("lemma");
+ final String pos = tokens.next("pos");
+ if (pos.length() != 1 || pos.charAt(0) != filePos.posChar) {
+ throw malformed(fileName, lineNumber, "Index pos " + pos + " does not belong in "
+ + fileName);
+ }
+ final int synsetCount = tokens.nextNonNegativeInt("synset count", "Synset count");
+ if (synsetCount < 1) {
+ throw malformed(fileName, lineNumber,
+ "Synset count must be at least 1, got: " + synsetCount);
+ }
+ final int pointerTypeCount = tokens.nextNonNegativeInt("pointer count", "Pointer count");
+ final LinkedHashSet pointerTypes = new LinkedHashSet<>();
+ for (int i = 0; i < pointerTypeCount; i++) {
+ // The summary symbols are informational; the data file's pointers are authoritative.
+ final String symbol = tokens.next("ptr_symbol (pointer symbol)");
+ if (!INDEX_POINTER_SYMBOLS.contains(symbol)) {
+ throw malformed(fileName, lineNumber, "Undeclared pointer symbol: " + symbol);
+ }
+ if (!pointerTypes.add(symbol)) {
+ throw malformed(fileName, lineNumber, "Duplicate pointer symbol: " + symbol);
+ }
+ }
+ final int senseCount = tokens.nextNonNegativeInt("sense count", "Sense count");
+ if (senseCount != synsetCount) {
+ throw malformed(fileName, lineNumber, "Sense count " + senseCount
+ + " does not match synset count " + synsetCount);
+ }
+ final int taggedSenseCount = tokens.nextNonNegativeInt(
+ "tagged-sense count", "Tagged-sense count");
+ if (taggedSenseCount > senseCount) {
+ throw malformed(fileName, lineNumber, "Tagged-sense count " + taggedSenseCount
+ + " exceeds sense count " + senseCount);
+ }
+ if (synsetCount > tokens.remainingFixedWidthFields(8)) {
+ throw malformed(fileName, lineNumber, "Synset count " + synsetCount
+ + " exceeds the available offset fields");
+ }
+ final List order = new ArrayList<>(synsetCount);
+ for (int i = 0; i < synsetCount; i++) {
+ final String offset = tokens.next("synset_offset");
+ parseOffset(offset, tokens);
+ final String synsetId = synsetId(offset, filePos.posChar);
+ if (!rawSynsets.containsKey(synsetId)) {
+ throw malformed(fileName, lineNumber, "Lemma " + lemma + " references offset " + offset
+ + " with no data." + filePos.suffix + " line");
+ }
+ if (order.contains(synsetId)) {
+ throw malformed(fileName, lineNumber, "Duplicate synset offset " + offset);
+ }
+ order.add(synsetId);
+ }
+ tokens.requireEnd("synset offsets");
+ final InMemoryWordNetLexicon.LemmaKey key =
+ InMemoryWordNetLexicon.LemmaKey.of(lemma, filePos.pos);
+ final List existing = senses.get(key);
+ if (existing == null) {
+ senses.put(key, order);
+ } else {
+ // Two index lemmas can fold to one key; keep first-listed order and append the rest.
+ for (final String synsetId : order) {
+ if (!existing.contains(synsetId)) {
+ existing.add(synsetId);
+ }
+ }
+ }
+ }
+
+ /**
+ * Strips the adjective syntactic markers ({@code (p)}, {@code (a)}, {@code (ip)}) and turns
+ * underscores into spaces.
+ *
+ * @param word The raw word field.
+ * @param fileName The data file name, for error reporting.
+ * @param lineNumber The 1-based line number.
+ * @param filePos The part-of-speech file being parsed.
+ * @return The cleaned lemma.
+ * @throws InvalidFormatException Thrown if the word is empty, contains an unknown marker, or
+ * uses an adjective marker outside {@code data.adj}.
+ */
+ private static String cleanLemma(String word, String fileName, int lineNumber, FilePos filePos)
+ throws InvalidFormatException {
+ String cleaned = word;
+ if (cleaned.endsWith(")")) {
+ final int open = cleaned.lastIndexOf('(');
+ final String marker = open < 0 ? "" : cleaned.substring(open);
+ if (!"(p)".equals(marker) && !"(a)".equals(marker) && !"(ip)".equals(marker)) {
+ throw malformed(fileName, lineNumber, "Unknown syntactic marker on word: " + word);
+ }
+ if (filePos != FilePos.ADJECTIVE) {
+ throw malformed(fileName, lineNumber,
+ "Syntactic marker " + marker + " is only valid in data.adj");
+ }
+ cleaned = cleaned.substring(0, open);
+ }
+ if (cleaned.isEmpty()) {
+ throw malformed(fileName, lineNumber, "Empty word field");
+ }
+ return cleaned.replace('_', ' ');
+ }
+
+ /**
+ * Parses an 8-digit synset offset.
+ *
+ * @param offset The offset field.
+ * @param tokens The tokenizer, for error reporting.
+ * @return The offset as an integer.
+ * @throws InvalidFormatException Thrown if the field is not 8 digits.
+ */
+ private static int parseOffset(String offset, Tokenizer tokens) throws InvalidFormatException {
+ if (offset.length() != 8) {
+ throw tokens.malformedToken(MALFORMED_OFFSET + offset);
+ }
+ int value = 0;
+ for (int i = 0; i < 8; i++) {
+ final char c = offset.charAt(i);
+ if (c < '0' || c > '9') {
+ throw tokens.malformedToken(MALFORMED_OFFSET + offset);
+ }
+ value = value * 10 + (c - '0');
+ }
+ return value;
+ }
+
+ /**
+ * Parses a pointer's one-letter part-of-speech code.
+ *
+ * @param pos The code field.
+ * @param tokens The tokenizer, for error reporting.
+ * @return One of {@code n}, {@code v}, {@code a}, {@code r}.
+ * @throws InvalidFormatException Thrown if the code is not one of those letters.
+ */
+ private static char posChar(String pos, Tokenizer tokens) throws InvalidFormatException {
+ if (pos.length() == 1) {
+ final char c = pos.charAt(0);
+ if (c == 'n' || c == 'v' || c == 'a' || c == 'r') {
+ return c;
+ }
+ }
+ throw tokens.malformedToken("Pointer pos must be one of n, v, a, r, got: " + pos);
+ }
+
+ /**
+ * Reads a required database file in full.
+ *
+ * @param file The file path.
+ * @return The file bytes.
+ * @throws InvalidFormatException Thrown if the file is missing.
+ * @throws IOException Thrown if reading fails.
+ */
+ private static byte[] readAll(Path file) throws IOException {
+ if (!Files.isRegularFile(file)) {
+ throw new InvalidFormatException("Missing WNDB database file: " + file);
+ }
+ return Files.readAllBytes(file);
+ }
+
+ /**
+ * Builds a malformed-file exception naming the file and line.
+ *
+ * @param fileName The file name.
+ * @param lineNumber The 1-based line number.
+ * @param message The failure detail.
+ * @return The exception to throw.
+ */
+ private static InvalidFormatException malformed(String fileName, int lineNumber,
+ String message) {
+ return new InvalidFormatException(malformedMessage(fileName, lineNumber, message));
+ }
+
+ /**
+ * Builds the malformed-file message naming the file and line.
+ *
+ * @param fileName The file name.
+ * @param lineNumber The 1-based line number.
+ * @param message The failure detail.
+ * @return The message text.
+ */
+ private static String malformedMessage(String fileName, int lineNumber, String message) {
+ return "Malformed WNDB file " + fileName + " at line " + lineNumber + ": " + message;
+ }
+
+ /** A cursor over one line's space-separated fields. */
+ private static final class Tokenizer {
+
+ private final String line;
+ private final String fileName;
+ private final int lineNumber;
+ private int position;
+
+ /**
+ * Creates a tokenizer over one line.
+ *
+ * @param line The line to tokenize.
+ * @param fileName The file name, for error reporting.
+ * @param lineNumber The 1-based line number.
+ */
+ Tokenizer(String line, String fileName, int lineNumber) {
+ this.line = line;
+ this.fileName = fileName;
+ this.lineNumber = lineNumber;
+ }
+
+ /**
+ * Reads the next space-separated field.
+ *
+ * @param field The field name, for error reporting.
+ * @return The field value.
+ * @throws InvalidFormatException Thrown if the line is truncated before the field.
+ */
+ String next(String field) throws InvalidFormatException {
+ while (position < line.length() && line.charAt(position) == ' ') {
+ position++;
+ }
+ if (position >= line.length()) {
+ throw malformed(fileName, lineNumber, "Truncated line, missing field: " + field);
+ }
+ final int start = position;
+ while (position < line.length() && line.charAt(position) != ' ') {
+ position++;
+ }
+ return line.substring(start, position);
+ }
+
+ /**
+ * Reads a non-negative decimal field.
+ *
+ * @param field The field name used when parsing fails.
+ * @param label The field label used for a negative value.
+ * @return The parsed value.
+ * @throws InvalidFormatException Thrown if the field is missing, invalid, or negative.
+ */
+ int nextNonNegativeInt(String field, String label) throws InvalidFormatException {
+ final String token = next(field);
+ if (token.charAt(0) == '-') {
+ throw malformed(fileName, lineNumber,
+ label + " must not be negative, got: " + token);
+ }
+ int value = 0;
+ for (int i = 0; i < token.length(); i++) {
+ final char c = token.charAt(i);
+ final int digit = c - '0';
+ if (digit < 0 || digit > 9 || value > (Integer.MAX_VALUE - digit) / 10) {
+ throw malformed(fileName, lineNumber,
+ field + " is not an unsigned decimal integer: " + token);
+ }
+ value = value * 10 + digit;
+ }
+ return value;
+ }
+
+ /**
+ * Reads a fixed-width ASCII integer.
+ *
+ * @param field The field name used when parsing fails.
+ * @param radix The numeric radix, either 10 or 16.
+ * @param width The required field width.
+ * @return The parsed value.
+ * @throws InvalidFormatException Thrown if the field has the wrong width or contains a digit
+ * outside the radix.
+ */
+ int nextFixedInt(String field, int radix, int width) throws InvalidFormatException {
+ final String token = next(field);
+ return parseFixedInt(field, radix, width, token);
+ }
+
+ /** Parses a fixed-width ASCII integer already read from the line. */
+ private int parseFixedInt(String field, int radix, int width, String token)
+ throws InvalidFormatException {
+ if (token.length() != width) {
+ throw malformed(fileName, lineNumber, fixedIntegerMessage(field, radix, width, token));
+ }
+ int value = 0;
+ for (int i = 0; i < token.length(); i++) {
+ final char c = token.charAt(i);
+ final int digit;
+ if (c >= '0' && c <= '9') {
+ digit = c - '0';
+ } else if (c >= 'a' && c <= 'f') {
+ digit = c - 'a' + 10;
+ } else if (c >= 'A' && c <= 'F') {
+ digit = c - 'A' + 10;
+ } else {
+ throw malformed(fileName, lineNumber,
+ fixedIntegerMessage(field, radix, width, token));
+ }
+ if (digit >= radix) {
+ throw malformed(fileName, lineNumber,
+ fixedIntegerMessage(field, radix, width, token));
+ }
+ value = value * radix + digit;
+ }
+ return value;
+ }
+
+ /**
+ * Reads a non-negative fixed-width count.
+ *
+ * @param field The field name used when parsing fails.
+ * @param radix The numeric radix.
+ * @param width The required field width.
+ * @param label The field label used for a negative value.
+ * @return The parsed count.
+ * @throws InvalidFormatException Thrown if the field is negative or malformed.
+ */
+ int nextFixedCount(String field, int radix, int width, String label)
+ throws InvalidFormatException {
+ final String token = next(field);
+ if (!token.isEmpty() && token.charAt(0) == '-') {
+ throw malformed(fileName, lineNumber, label + " must not be negative, got: " + token);
+ }
+ return parseFixedInt(field, radix, width, token);
+ }
+
+ /** Returns the standard fixed-width integer error detail. */
+ private String fixedIntegerMessage(String field, int radix, int width, String token) {
+ return field + " must be a " + width + "-digit base-" + radix
+ + " integer, got: " + token;
+ }
+
+ /** Returns the maximum number of space-prefixed fields of {@code width} still available. */
+ int remainingFixedWidthFields(int width) {
+ return (line.length() - position) / (width + 1);
+ }
+
+ /**
+ * Rejects fields after a complete index entry.
+ *
+ * @param parsedFields The fields already consumed.
+ * @throws InvalidFormatException Thrown if another field remains.
+ */
+ void requireEnd(String parsedFields) throws InvalidFormatException {
+ while (position < line.length() && line.charAt(position) == ' ') {
+ position++;
+ }
+ if (position < line.length()) {
+ throw malformed(fileName, lineNumber,
+ "Unexpected field after " + parsedFields + ": " + next("field"));
+ }
+ }
+
+ /**
+ * Reads the gloss: the remainder after the pipe separator, trimmed of surrounding spaces.
+ *
+ * @return The gloss text.
+ * @throws InvalidFormatException Thrown if the pipe separator is missing.
+ */
+ String gloss() throws InvalidFormatException {
+ final String separator = next("gloss separator");
+ if (!"|".equals(separator)) {
+ throw malformed(fileName, lineNumber, "Expected the | gloss separator, got: " + separator);
+ }
+ int start = position;
+ while (start < line.length() && line.charAt(start) == ' ') {
+ start++;
+ }
+ int end = line.length();
+ while (end > start && line.charAt(end - 1) == ' ') {
+ end--;
+ }
+ return line.substring(start, end);
+ }
+
+ /**
+ * Builds a malformed-file exception at this tokenizer's line.
+ *
+ * @param message The failure detail.
+ * @return The exception to throw.
+ */
+ InvalidFormatException malformedToken(String message) {
+ return malformed(fileName, lineNumber, message);
+ }
+ }
+
+ /** A parsed pointer line, kept until the target synset is known. */
+ private record RawPointer(WordNetRelation relation, String targetId, int targetWord,
+ int lineNumber) {
+ }
+
+ /** A parsed data-file synset, kept until its pointer targets can be resolved. */
+ private static final class RawSynset {
+ private final String id;
+ private final WordNetPOS pos;
+ private final List lemmas;
+ private final String gloss;
+ private final List pointers;
+ private final String fileName;
+ private final int lineNumber;
+
+ /**
+ * Creates a raw synset gathered while parsing a data file.
+ *
+ * @param id The synset id.
+ * @param pos The part of speech.
+ * @param lemmas The member lemmas.
+ * @param gloss The gloss text.
+ * @param pointers The raw pointers to resolve.
+ * @param fileName The source file name.
+ * @param lineNumber The source line number.
+ */
+ RawSynset(String id, WordNetPOS pos, List lemmas, String gloss,
+ List pointers, String fileName, int lineNumber) {
+ this.id = id;
+ this.pos = pos;
+ this.lemmas = lemmas;
+ this.gloss = gloss;
+ this.pointers = pointers;
+ this.fileName = fileName;
+ this.lineNumber = lineNumber;
+ }
+ }
+
+ /**
+ * Builds the WNDB pointer-symbol to {@link WordNetRelation} table.
+ *
+ * @return The immutable symbol table.
+ */
+ private static Map pointerSymbols() {
+ final Map symbols = new HashMap<>();
+ symbols.put("!", WordNetRelation.ANTONYM);
+ symbols.put("@", WordNetRelation.HYPERNYM);
+ symbols.put("@i", WordNetRelation.INSTANCE_HYPERNYM);
+ symbols.put("~", WordNetRelation.HYPONYM);
+ symbols.put("~i", WordNetRelation.INSTANCE_HYPONYM);
+ symbols.put("#m", WordNetRelation.MEMBER_HOLONYM);
+ symbols.put("#s", WordNetRelation.SUBSTANCE_HOLONYM);
+ symbols.put("#p", WordNetRelation.PART_HOLONYM);
+ symbols.put("%m", WordNetRelation.MEMBER_MERONYM);
+ symbols.put("%s", WordNetRelation.SUBSTANCE_MERONYM);
+ symbols.put("%p", WordNetRelation.PART_MERONYM);
+ symbols.put("=", WordNetRelation.ATTRIBUTE);
+ symbols.put("+", WordNetRelation.DERIVATIONALLY_RELATED);
+ symbols.put("*", WordNetRelation.ENTAILMENT);
+ symbols.put(">", WordNetRelation.CAUSE);
+ symbols.put("^", WordNetRelation.ALSO_SEE);
+ symbols.put("$", WordNetRelation.VERB_GROUP);
+ symbols.put("&", WordNetRelation.SIMILAR_TO);
+ symbols.put("<", WordNetRelation.PARTICIPLE);
+ symbols.put("\\", WordNetRelation.PERTAINYM);
+ symbols.put(";c", WordNetRelation.DOMAIN_TOPIC);
+ symbols.put("-c", WordNetRelation.MEMBER_OF_DOMAIN_TOPIC);
+ symbols.put(";r", WordNetRelation.DOMAIN_REGION);
+ symbols.put("-r", WordNetRelation.MEMBER_OF_DOMAIN_REGION);
+ symbols.put(";u", WordNetRelation.DOMAIN_USAGE);
+ symbols.put("-u", WordNetRelation.MEMBER_OF_DOMAIN_USAGE);
+ return Map.copyOf(symbols);
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/InMemoryWordNetLexiconTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/InMemoryWordNetLexiconTest.java
new file mode 100644
index 0000000000..060ea37629
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/InMemoryWordNetLexiconTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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.wordnet;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import opennlp.tools.wordnet.Synset;
+import opennlp.tools.wordnet.WordNetPOS;
+import opennlp.tools.wordnet.WordNetRelation;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Tests referential-integrity validation independently of the file readers. */
+public class InMemoryWordNetLexiconTest {
+
+ private static Synset synset(String id, Map> relations) {
+ return new Synset(id, WordNetPOS.NOUN, List.of("lemma"), "a gloss", relations);
+ }
+
+ @Test
+ void testAcceptsConsistentMaps() {
+ final Synset a = synset("a", Map.of(WordNetRelation.HYPERNYM, List.of("b")));
+ final Synset b = synset("b", Map.of());
+ final InMemoryWordNetLexicon lexicon = new InMemoryWordNetLexicon(
+ Map.of("a", a, "b", b),
+ Map.of(InMemoryWordNetLexicon.LemmaKey.of("lemma", WordNetPOS.NOUN), List.of("a", "b")));
+ assertEquals(2, lexicon.size());
+ assertEquals(List.of(a, b), lexicon.lookup("lemma", WordNetPOS.NOUN));
+ }
+
+ @Test
+ void testRejectsKeyThatDoesNotMatchSynsetId() {
+ final Map table = Map.of("wrong-key", synset("real-id", Map.of()));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new InMemoryWordNetLexicon(table, Map.of()));
+ assertTrue(e.getMessage().contains("wrong-key"));
+ }
+
+ @Test
+ void testRejectsDanglingRelationTarget() {
+ final Map table =
+ Map.of("a", synset("a", Map.of(WordNetRelation.HYPERNYM, List.of("nope"))));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new InMemoryWordNetLexicon(table, Map.of()));
+ assertTrue(e.getMessage().contains("nope"));
+ assertTrue(e.getMessage().contains("HYPERNYM"));
+ }
+
+ @Test
+ void testRejectsSenseOrderEntryWithUnknownSynset() {
+ final Map table = Map.of("a", synset("a", Map.of()));
+ final Map> senseOrder =
+ Map.of(InMemoryWordNetLexicon.LemmaKey.of("lemma", WordNetPOS.NOUN), List.of("missing"));
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new InMemoryWordNetLexicon(table, senseOrder));
+ assertTrue(e.getMessage().contains("missing"));
+ assertTrue(e.getMessage().contains("lemma"));
+ }
+
+ @ParameterizedTest
+ @CsvSource({"other,NOUN", "lemma,VERB"})
+ void testRejectsSenseOrderEntryThatDoesNotDescribeSynset(String lemma, WordNetPOS pos) {
+ final Map table = Map.of("a", synset("a", Map.of()));
+ final Map> senseOrder =
+ Map.of(InMemoryWordNetLexicon.LemmaKey.of(lemma, pos), List.of("a"));
+
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new InMemoryWordNetLexicon(table, senseOrder));
+
+ assertTrue(e.getMessage().contains("a"));
+ assertTrue(e.getMessage().contains(lemma));
+ }
+
+ @Test
+ void testRejectsDuplicateSenseOrderEntry() {
+ final Map table = Map.of("a", synset("a", Map.of()));
+ final Map> senseOrder =
+ Map.of(InMemoryWordNetLexicon.LemmaKey.of("lemma", WordNetPOS.NOUN), List.of("a", "a"));
+
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new InMemoryWordNetLexicon(table, senseOrder));
+
+ assertTrue(e.getMessage().contains("duplicate synset a"));
+ }
+
+ @Test
+ void testRejectsNullSenseOrderKey() {
+ final Map> senseOrder = new HashMap<>();
+ senseOrder.put(null, List.of());
+
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new InMemoryWordNetLexicon(Map.of(), senseOrder));
+
+ assertEquals("senseOrder key must not be null", e.getMessage());
+ }
+
+ @Test
+ void testRejectsNullSenseOrderList() {
+ final Map> senseOrder = new HashMap<>();
+ senseOrder.put(InMemoryWordNetLexicon.LemmaKey.of("lemma", WordNetPOS.NOUN), null);
+
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> new InMemoryWordNetLexicon(Map.of(), senseOrder));
+
+ assertEquals("senseOrder value must not be null", e.getMessage());
+ }
+
+ @Test
+ void testRejectsSenseOrderKeyWithoutPartOfSpeech() {
+ final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> InMemoryWordNetLexicon.LemmaKey.of("lemma", null));
+
+ assertEquals("pos must not be null", e.getMessage());
+ }
+
+ @Test
+ void testRejectsNullMaps() {
+ assertThrows(IllegalArgumentException.class, () -> new InMemoryWordNetLexicon(null, Map.of()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new InMemoryWordNetLexicon(Map.of(), null));
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LemmaFoldingTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LemmaFoldingTest.java
new file mode 100644
index 0000000000..76351edd4c
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LemmaFoldingTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.wordnet;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.wordnet.WordNetPOS;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** Tests lemma folding shared by the readers, exception lists, and lookup index. */
+public class LemmaFoldingTest {
+
+ @Test
+ void testFoldUsesLocaleIndependentLowercaseAndSpacesForUnderscores() {
+ assertEquals("mice", LemmaFolding.fold("MICE"));
+ assertEquals("domestic dog", LemmaFolding.fold("Domestic_Dog"));
+ assertEquals("attorney general", LemmaFolding.fold("attorney_general"));
+ assertEquals("dog", LemmaFolding.fold("dog"));
+ assertEquals("", LemmaFolding.fold(""));
+ }
+
+ @Test
+ void testSplitOnSpacesCollapsesRunsAndIgnoresEdges() {
+ assertEquals(List.of("a", "b", "c"), LemmaFolding.splitOnSpaces("a b c"));
+ assertEquals(List.of("a", "b"), LemmaFolding.splitOnSpaces("a b"));
+ assertEquals(List.of("a"), LemmaFolding.splitOnSpaces("a"));
+ assertEquals(List.of("a"), LemmaFolding.splitOnSpaces(" a "));
+ assertEquals(List.of(), LemmaFolding.splitOnSpaces(""));
+ assertEquals(List.of(), LemmaFolding.splitOnSpaces(" "));
+ }
+
+ @Test
+ void testLemmaKeyAndExceptionLookupAgreeOnTheFold() {
+ assertEquals(InMemoryWordNetLexicon.LemmaKey.of("Domestic_Dog", WordNetPOS.NOUN),
+ InMemoryWordNetLexicon.LemmaKey.of(LemmaFolding.fold("DOMESTIC_DOG"), WordNetPOS.NOUN));
+ }
+
+ @Test
+ void testFoldRejectsNull() {
+ assertThrows(IllegalArgumentException.class, () -> LemmaFolding.fold(null));
+ }
+
+ @Test
+ void testSplitOnSpacesRejectsNull() {
+ assertThrows(IllegalArgumentException.class, () -> LemmaFolding.splitOnSpaces(null));
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LexiconConcurrencyTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LexiconConcurrencyTest.java
new file mode 100644
index 0000000000..ca2eab5c1b
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LexiconConcurrencyTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.wordnet;
+
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.wordnet.LexicalKnowledgeBase;
+import opennlp.tools.wordnet.WordNetPOS;
+import opennlp.tools.wordnet.WordNetRelation;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Tests concurrent lookups on an immutable loaded lexicon. */
+public class LexiconConcurrencyTest {
+
+ private static final int THREADS = 8;
+ private static final int ITERATIONS = 500;
+
+ @Test
+ void testConcurrentLookupsSeeConsistentResults() throws InterruptedException {
+ final LexicalKnowledgeBase lexicon = WndbReaderTest.fixture();
+ final CountDownLatch start = new CountDownLatch(1);
+ final CountDownLatch done = new CountDownLatch(THREADS);
+ final Queue problems = new ConcurrentLinkedQueue<>();
+ for (int t = 0; t < THREADS; t++) {
+ final Thread thread = new Thread(() -> {
+ try {
+ start.await();
+ for (int i = 0; i < ITERATIONS; i++) {
+ verifyOnce(lexicon, problems);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ problems.add("Interrupted: " + e);
+ } catch (RuntimeException e) {
+ problems.add("Unexpected exception: " + e);
+ } finally {
+ done.countDown();
+ }
+ });
+ thread.setDaemon(true);
+ thread.start();
+ }
+ start.countDown();
+ assertTrue(done.await(60, TimeUnit.SECONDS), "Worker threads must finish in time");
+ assertEquals(List.of(), List.copyOf(problems));
+ }
+
+ private static void verifyOnce(LexicalKnowledgeBase lexicon, Queue problems) {
+ if (!WndbReaderTest.DOG_ID.equals(lexicon.lookup("dog", WordNetPOS.NOUN).get(0).id())) {
+ problems.add("Wrong dog lookup");
+ }
+ if (lexicon.lookup("run", WordNetPOS.NOUN).size() != 2) {
+ problems.add("Wrong run sense count");
+ }
+ if (!List.of(WndbReaderTest.CANID_ID)
+ .equals(lexicon.related(WndbReaderTest.DOG_ID, WordNetRelation.HYPERNYM))) {
+ problems.add("Wrong dog hypernym");
+ }
+ if (lexicon.contains("zebra", WordNetPOS.NOUN)) {
+ problems.add("Phantom zebra");
+ }
+ if (!lexicon.contains("walk", WordNetPOS.VERB)) {
+ problems.add("Missing walk verb");
+ }
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/MorphyExceptionsTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/MorphyExceptionsTest.java
new file mode 100644
index 0000000000..b672b1e3c2
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/MorphyExceptionsTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.wordnet;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.wordnet.WordNetPOS;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class MorphyExceptionsTest {
+
+ /**
+ * Loads the exception lists from the miniature WNDB fixture directory.
+ *
+ * @return The loaded fixture exception lists.
+ */
+ static MorphyExceptions fixture() {
+ try {
+ return MorphyExceptions.load(WndbReaderTest.fixtureDirectory());
+ } catch (IOException e) {
+ throw new IllegalStateException("Unexpected IOException reading the fixture lists", e);
+ }
+ }
+
+ /**
+ * Writes the standard one-entry exception list for each part of speech into
+ * {@code directory}. Tests that need a variation overwrite or delete individual files
+ * afterwards.
+ *
+ * @param directory The directory to receive {@code noun.exc}, {@code verb.exc},
+ * {@code adj.exc}, and {@code adv.exc}.
+ * @throws IOException Thrown if writing a file fails.
+ */
+ private static void writeStandardLists(Path directory) throws IOException {
+ Files.writeString(directory.resolve("noun.exc"), "mice mouse\n");
+ Files.writeString(directory.resolve("verb.exc"), "went go\n");
+ Files.writeString(directory.resolve("adj.exc"), "better good\n");
+ Files.writeString(directory.resolve("adv.exc"), "best well\n");
+ }
+
+ @ParameterizedTest
+ @CsvSource(nullValues = "unknown", value = {
+ "mice, NOUN, mouse",
+ "went, VERB, go",
+ "better, ADJECTIVE, good",
+ "best, ADVERB, well",
+ // Entries are part-of-speech scoped: went is only a verb exception.
+ "went, NOUN, unknown",
+ "dog, NOUN, unknown",
+ })
+ void testLookupPerPartOfSpeech(String form, WordNetPOS pos, String lemma) {
+ final List expected = lemma == null ? List.of() : List.of(lemma);
+ assertEquals(expected, fixture().lookup(form, pos));
+ }
+
+ @Test
+ void testLookupFoldsCase() {
+ assertEquals(List.of("mouse"), fixture().lookup("Mice", WordNetPOS.NOUN));
+ assertEquals(List.of("mouse"), fixture().lookup("MICE", WordNetPOS.NOUN));
+ }
+
+ @Test
+ void testLookupRejectsNulls() {
+ final MorphyExceptions exceptions = fixture();
+ assertThrows(IllegalArgumentException.class,
+ () -> exceptions.lookup(null, WordNetPOS.NOUN));
+ assertThrows(IllegalArgumentException.class, () -> exceptions.lookup("mice", null));
+ }
+
+ @Test
+ void testLoadRejectsNullAndMissingDirectory(@TempDir Path tempDir) {
+ assertThrows(IllegalArgumentException.class, () -> MorphyExceptions.load(null));
+ assertThrows(IllegalArgumentException.class,
+ () -> MorphyExceptions.load(tempDir.resolve("absent")));
+ }
+
+ @Test
+ void testLoadRejectsMissingFile(@TempDir Path tempDir) throws IOException {
+ writeStandardLists(tempDir);
+ Files.delete(tempDir.resolve("adv.exc"));
+ final InvalidFormatException e = assertThrows(InvalidFormatException.class,
+ () -> MorphyExceptions.load(tempDir));
+ assertTrue(e.getMessage().contains("adv.exc"));
+ }
+
+ @Test
+ void testLoadRejectsMalformedLine(@TempDir Path tempDir) throws IOException {
+ writeStandardLists(tempDir);
+ Files.writeString(tempDir.resolve("noun.exc"), "mice mouse\nlonely\n");
+ final InvalidFormatException e = assertThrows(InvalidFormatException.class,
+ () -> MorphyExceptions.load(tempDir));
+ assertTrue(e.getMessage().contains("noun.exc"));
+ assertTrue(e.getMessage().contains("line 2"));
+ }
+
+ @Test
+ void testMultipleBaseFormsKeepFileOrder(@TempDir Path tempDir) throws IOException {
+ writeStandardLists(tempDir);
+ Files.writeString(tempDir.resolve("noun.exc"), "axes axis ax\n");
+ assertEquals(List.of("axis", "ax"),
+ MorphyExceptions.load(tempDir).lookup("axes", WordNetPOS.NOUN));
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/MorphyLemmatizerTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/MorphyLemmatizerTest.java
new file mode 100644
index 0000000000..46ba1fe79e
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/MorphyLemmatizerTest.java
@@ -0,0 +1,205 @@
+/*
+ * 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.wordnet;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import opennlp.tools.wordnet.WordNetPOS;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class MorphyLemmatizerTest {
+
+ private static MorphyLemmatizer morphy() {
+ return new MorphyLemmatizer(WndbReaderTest.fixture(), MorphyExceptionsTest.fixture());
+ }
+
+ private static String one(String token, String tag) {
+ return morphy().lemmatize(new String[] {token}, new String[] {tag})[0];
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ // Irregular forms resolve through the exception lists.
+ "mice, NN, mouse",
+ "Mice, NNS, mouse",
+ "men, NNS, man",
+ "ran, VBD, run",
+ "running, VBG, run",
+ "went, VBD, go",
+ "gone, VBN, go",
+ "best, RBS, well",
+ // Regular detachments, validated against the lexicon.
+ "dogs, NNS, dog",
+ "boxes, NNS, box",
+ "berries, NNS, berry",
+ "runs, NNS, run",
+ "runs, VBZ, run",
+ "walked, VBD, walk",
+ "walking, VBG, walk",
+ "walks, VBZ, walk",
+ "moved, VBD, move",
+ "taller, JJR, tall",
+ "tallest, JJS, tall",
+ "larger, JJR, large",
+ // A word that is already a lemma comes back as itself.
+ "dog, NN, dog",
+ "quickly, RB, quickly",
+ // WordNet letter tags are accepted alongside Penn tags.
+ "dogs, n, dog",
+ "walked, v, walk",
+ "taller, a, tall",
+ "best, r, well",
+ })
+ void testLemmatizesToken(String token, String tag, String lemma) {
+ assertEquals(lemma, one(token, tag));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ // Rule candidates not in the lexicon are rejected, not returned.
+ "dogged, VBD",
+ "boxes, VBZ",
+ "glarbs, NNS",
+ // A known word under the wrong part of speech is unknown.
+ "walk, NN",
+ // Tags outside the mapping yield the unknown marker.
+ "dog, DT",
+ "dog, XYZ",
+ "dogs, ''",
+ // Multi-letter closed-class tags beginning with a WordNet letter code are not
+ // adjective lookups: AUX was must be unknown, and AUX taller must not detach to tall.
+ "was, AUX",
+ "taller, AUX",
+ })
+ void testUnknownYieldsMarker(String token, String tag) {
+ assertEquals("O", one(token, tag));
+ }
+
+ @Test
+ void testExceptionHitsAreReturnedWithoutLexiconValidation() {
+ // oxen maps to ox, which the miniature lexicon does not contain; the exception list is
+ // authoritative for irregulars, so the lemma is returned anyway.
+ assertEquals("ox", one("oxen", "NNS"));
+ // better maps to good, also absent from the miniature lexicon.
+ assertEquals("good", one("better", "JJR"));
+ }
+
+ @Test
+ void testArrayFormKeepsPositions() {
+ final String[] lemmas = morphy().lemmatize(
+ new String[] {"The", "mice", "ran", "quickly"},
+ new String[] {"DT", "NNS", "VBD", "RB"});
+ assertArrayEquals(new String[] {"O", "mouse", "run", "quickly"}, lemmas);
+ }
+
+ @Test
+ void testListFormReturnsAllCandidates() {
+ final List> lemmas = morphy().lemmatize(
+ List.of("glarbs", "berries", "axes"), List.of("NNS", "NNS", "NNS"));
+ assertEquals(List.of("O"), lemmas.get(0));
+ assertEquals(List.of("berry"), lemmas.get(1));
+ // The fixture noun.exc lists axes with two base forms; both come back, in file order.
+ assertEquals(List.of("axis", "ax"), lemmas.get(2));
+ }
+
+ @Test
+ void testArrayFormReturnsFirstOfSeveralCandidates() {
+ // The list form above returns both base forms of axes; the array form keeps only the
+ // first, most preferred one.
+ assertEquals("axis", one("axes", "NNS"));
+ }
+
+ @Test
+ void testMatchesWnLmfLexicon() {
+ final MorphyLemmatizer lmfMorphy =
+ new MorphyLemmatizer(WnLmfReaderTest.fixture(), MorphyExceptionsTest.fixture());
+ assertArrayEquals(new String[] {"mouse", "box", "walk", "large", "O"},
+ lmfMorphy.lemmatize(
+ new String[] {"mice", "boxes", "walking", "larger", "dogged"},
+ new String[] {"NNS", "NNS", "VBG", "JJR", "VBD"}));
+ }
+
+ @ParameterizedTest
+ @CsvSource(nullValues = "none", value = {
+ "NNP, NOUN",
+ "VBZ, VERB",
+ "JJ, ADJECTIVE",
+ "RBR, ADVERB",
+ "a, ADJECTIVE",
+ "s, ADJECTIVE",
+ "ADJ, ADJECTIVE",
+ "ADV, ADVERB",
+ "r, ADVERB",
+ "DT, none",
+ "'', none",
+ // The letter codes a and s match only as one-letter tags: multi-letter tags beginning
+ // with those letters are closed-class or symbol tags, not adjectives.
+ "AUX, none",
+ "ADP, none",
+ "SCONJ, none",
+ "SYM, none",
+ "ß, none",
+ "Å¿, none",
+ })
+ void testPosFromTagMapping(String tag, WordNetPOS pos) {
+ assertEquals(pos, MorphyLemmatizer.posFromTag(tag));
+ }
+
+ @Test
+ void testPosFromTagRejectsNull() {
+ assertThrows(IllegalArgumentException.class, () -> MorphyLemmatizer.posFromTag(null));
+ }
+
+ @Test
+ void testConstructorRejectsMissingInputs() {
+ final MorphyExceptions exceptions = MorphyExceptionsTest.fixture();
+ assertThrows(IllegalArgumentException.class,
+ () -> new MorphyLemmatizer(null, exceptions));
+ assertThrows(IllegalArgumentException.class,
+ () -> new MorphyLemmatizer(WndbReaderTest.fixture(), null));
+ }
+
+ @Test
+ void testRejectsNullOrMismatchedSequences() {
+ final MorphyLemmatizer morphy = morphy();
+ assertThrows(IllegalArgumentException.class,
+ () -> morphy.lemmatize((String[]) null, new String[0]));
+ assertThrows(IllegalArgumentException.class,
+ () -> morphy.lemmatize(new String[0], (String[]) null));
+ assertThrows(IllegalArgumentException.class,
+ () -> morphy.lemmatize(new String[] {"a", "b"}, new String[] {"NN"}));
+ assertThrows(IllegalArgumentException.class,
+ () -> morphy.lemmatize(List.of("a"), List.of("NN", "NN")));
+ assertThrows(IllegalArgumentException.class,
+ () -> morphy.lemmatize(new String[] {null}, new String[] {"NN"}));
+ assertThrows(IllegalArgumentException.class,
+ () -> morphy.lemmatize(new String[] {"dog"}, new String[] {null}));
+ final List withNull = Collections.singletonList(null);
+ assertThrows(IllegalArgumentException.class,
+ () -> morphy.lemmatize(withNull, List.of("NN")));
+ assertThrows(IllegalArgumentException.class,
+ () -> morphy.lemmatize(List.of("dog"), withNull));
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/ReaderEquivalenceTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/ReaderEquivalenceTest.java
new file mode 100644
index 0000000000..29b4b0e536
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/ReaderEquivalenceTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.wordnet;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.wordnet.LexicalKnowledgeBase;
+import opennlp.tools.wordnet.Synset;
+import opennlp.tools.wordnet.WordNetPOS;
+import opennlp.tools.wordnet.WordNetRelation;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/** Compares equivalent WN-LMF and WNDB fixtures by their unique glosses. */
+public class ReaderEquivalenceTest {
+
+ @Test
+ void testBothReadersProduceEquivalentViews() {
+ final InMemoryWordNetLexicon lmf = (InMemoryWordNetLexicon) WnLmfReaderTest.fixture();
+ final InMemoryWordNetLexicon wndb = (InMemoryWordNetLexicon) WndbReaderTest.fixture();
+ assertEquals(lmf.size(), wndb.size(), "Both fixtures encode the same synsets");
+
+ final Map wndbByGloss = byGloss(wndb);
+ assertEquals(byGloss(lmf).keySet(), wndbByGloss.keySet(), "Same glosses on both sides");
+
+ for (final Synset expected : lmf.synsets()) {
+ final Synset actual = wndbByGloss.get(expected.gloss());
+ assertNotNull(actual, "WNDB view has a synset for gloss: " + expected.gloss());
+ assertEquals(expected.pos(), actual.pos(), "Part of speech for: " + expected.gloss());
+ assertEquals(expected.lemmas(), actual.lemmas(), "Lemmas for: " + expected.gloss());
+ assertEquals(relationsByGloss(expected, lmf), relationsByGloss(actual, wndb),
+ "Relations for: " + expected.gloss());
+ }
+ }
+
+ @Test
+ void testLookupAgreesForEveryLemmaAndPos() {
+ final LexicalKnowledgeBase lmf = WnLmfReaderTest.fixture();
+ final InMemoryWordNetLexicon wndb = (InMemoryWordNetLexicon) WndbReaderTest.fixture();
+ final Set checked = new HashSet<>();
+ for (final Synset synset : wndb.synsets()) {
+ for (final String lemma : synset.lemmas()) {
+ if (!checked.add(lemma + "/" + synset.pos())) {
+ continue;
+ }
+ assertEquals(
+ glosses(lmf.lookup(lemma, synset.pos())),
+ glosses(wndb.lookup(lemma, synset.pos())),
+ "Sense sequence for " + lemma + " as " + synset.pos());
+ }
+ }
+ for (final WordNetPOS pos : WordNetPOS.values()) {
+ assertEquals(lmf.contains("dog", pos), wndb.contains("dog", pos));
+ }
+ }
+
+ @Test
+ void testSenseOrderAgreesForMultiSenseLemma() {
+ final LexicalKnowledgeBase lmf = WnLmfReaderTest.fixture();
+ final LexicalKnowledgeBase wndb = WndbReaderTest.fixture();
+ final List lmfOrder = glosses(lmf.lookup("run", WordNetPOS.NOUN));
+ final List wndbOrder = glosses(wndb.lookup("run", WordNetPOS.NOUN));
+ assertEquals(2, lmfOrder.size());
+ assertEquals(lmfOrder, wndbOrder);
+ }
+
+ private static Map byGloss(InMemoryWordNetLexicon lexicon) {
+ final Map byGloss = new HashMap<>();
+ for (final Synset synset : lexicon.synsets()) {
+ final Synset previous = byGloss.put(synset.gloss(), synset);
+ assertEquals(null, previous, "Fixture glosses must be unique, duplicated: "
+ + synset.gloss());
+ }
+ return byGloss;
+ }
+
+ private static Map> relationsByGloss(Synset synset,
+ LexicalKnowledgeBase lexicon) {
+ final Map> result = new HashMap<>();
+ for (final Map.Entry> relation :
+ synset.relations().entrySet()) {
+ final Set targetGlosses = new HashSet<>();
+ for (final String targetId : relation.getValue()) {
+ targetGlosses.add(lexicon.synset(targetId).orElseThrow().gloss());
+ }
+ result.put(relation.getKey(), targetGlosses);
+ }
+ return result;
+ }
+
+ private static List glosses(List synsets) {
+ return synsets.stream().map(Synset::gloss).toList();
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfOmwIntegrationTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfOmwIntegrationTest.java
new file mode 100644
index 0000000000..da2bcac683
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfOmwIntegrationTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.wordnet;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import opennlp.tools.wordnet.WordNetPOS;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/** Smoke tests against complete, pinned OMW 2.0 releases fetched by the developer script. */
+class WnLmfOmwIntegrationTest {
+
+ @ParameterizedTest(name = "OMW 2.0 {0}")
+ @MethodSource("wordnets")
+ void testCompleteOmwRelease(String language, String relativeFile, String lemma,
+ String expectedSynset) throws IOException {
+ final String fixtureRoot = System.getProperty("opennlp.wordnet.omwDir");
+ Assumptions.assumeTrue(fixtureRoot != null && !fixtureRoot.isBlank(),
+ "Run dev/test-omw-wordnets.sh to fetch and verify the pinned releases");
+
+ final WnLmfResource resource =
+ WnLmfReader.readResource(Path.of(fixtureRoot).resolve(relativeFile));
+ assertEquals(1, resource.lexicons().size());
+ final WnLmfLexicon lexicon = resource.lexicons().get(0);
+ assertEquals(language, lexicon.language());
+ assertEquals(List.of(new WnLmfDependency("omw-en", "2.0")),
+ lexicon.dependencies());
+ assertEquals(expectedSynset,
+ lexicon.knowledgeBase().lookup(lemma, WordNetPOS.NOUN).get(0).id());
+ }
+
+ /**
+ * Supplies the pinned OMW releases and representative noun lookups.
+ *
+ * @return The release arguments.
+ */
+ private static Stream wordnets() {
+ return Stream.of(
+ Arguments.of("it", "omw-it/omw-it.xml", "cane", "omw-it-02084071-n"),
+ Arguments.of("es", "omw-es/omw-es.xml", "perro", "omw-es-02084071-n"),
+ Arguments.of("sv", "omw-sv/omw-sv.xml", "hund", "omw-sv-02084071-n"));
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfReaderTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfReaderTest.java
new file mode 100644
index 0000000000..5b2b9130c8
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfReaderTest.java
@@ -0,0 +1,778 @@
+/*
+ * 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.wordnet;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+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.ValueSource;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.wordnet.LexicalKnowledgeBase;
+import opennlp.tools.wordnet.Synset;
+import opennlp.tools.wordnet.WordNetPOS;
+import opennlp.tools.wordnet.WordNetRelation;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class WnLmfReaderTest {
+
+ private static final String RESOURCE_NAME = "inline.xml";
+ private static final String CAT_ENTRY = """
+
+
+
+
+ """;
+ private static final String CAT_SYNSET = "";
+
+ /**
+ * Loads the miniature WN-LMF document from the test classpath into a lexicon.
+ *
+ * @return The loaded fixture lexicon.
+ */
+ static LexicalKnowledgeBase fixture() {
+ try (InputStream in = WnLmfReaderTest.class.getResourceAsStream("mini-wn-lmf.xml")) {
+ assertNotNull(in, "Fixture mini-wn-lmf.xml must be on the test classpath");
+ return WnLmfReader.read(in, "mini-wn-lmf.xml");
+ } catch (IOException e) {
+ throw new IllegalStateException("Unexpected IOException from a classpath stream", e);
+ }
+ }
+
+ /**
+ * Loads an inline XML document.
+ *
+ * @param document The XML text.
+ * @return The only knowledge base.
+ * @throws IOException If parsing fails.
+ */
+ private LexicalKnowledgeBase parse(String document) throws IOException {
+ return WnLmfReader.read(
+ new ByteArrayInputStream(document.getBytes(StandardCharsets.UTF_8)), RESOURCE_NAME);
+ }
+
+ /**
+ * Places content in one lexicon and resource root.
+ *
+ * @param body The lexicon content.
+ * @return The complete document.
+ */
+ private String wrap(String body) {
+ return "\n\n"
+ + "\n"
+ + body + "\n\n\n";
+ }
+
+ @Test
+ void testLookupReturnsSynsetWithAllComponents() {
+ final List senses = fixture().lookup("dog", WordNetPOS.NOUN);
+ assertEquals(1, senses.size());
+ final Synset dog = senses.get(0);
+ assertEquals("mini-n1", dog.id());
+ assertEquals(WordNetPOS.NOUN, dog.pos());
+ assertEquals(List.of("dog", "domestic dog"), dog.lemmas());
+ assertEquals("a domesticated canid", dog.gloss());
+ assertEquals(List.of("mini-n2"), dog.related(WordNetRelation.HYPERNYM));
+ }
+
+ @Test
+ void testLookupFoldsCaseAndUnderscore() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ assertEquals("mini-n1", lexicon.lookup("Domestic_Dog", WordNetPOS.NOUN).get(0).id());
+ assertEquals("mini-n1", lexicon.lookup("DOG", WordNetPOS.NOUN).get(0).id());
+ }
+
+ @Test
+ void testLookupKeepsSenseOrder() {
+ final List runSenses = fixture().lookup("run", WordNetPOS.NOUN);
+ assertEquals(List.of("mini-n5", "mini-n9"),
+ runSenses.stream().map(Synset::id).toList());
+ }
+
+ @Test
+ void testPreservesMultipleDefinitions() throws IOException {
+ final LexicalKnowledgeBase lexicon = parse(
+ wrap(""
+ + ""
+ + ""
+ + "a feline"
+ + "a domesticated cat"));
+
+ assertEquals("a feline; a domesticated cat",
+ lexicon.lookup("cat", WordNetPOS.NOUN).get(0).gloss());
+ }
+
+ @Test
+ void testLookupIsPosScoped() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ assertEquals(1, lexicon.lookup("run", WordNetPOS.VERB).size());
+ assertTrue(lexicon.lookup("dog", WordNetPOS.VERB).isEmpty());
+ assertFalse(lexicon.contains("walk", WordNetPOS.NOUN));
+ assertTrue(lexicon.contains("walk", WordNetPOS.VERB));
+ }
+
+ @Test
+ void testRelationNavigation() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ assertEquals(List.of("mini-n1"), lexicon.related("mini-n2", WordNetRelation.HYPONYM));
+ assertEquals(List.of("mini-v1", "mini-v2"),
+ lexicon.related("mini-v4", WordNetRelation.HYPONYM));
+ assertEquals(List.of("mini-v4"), lexicon.related("mini-v1", WordNetRelation.HYPERNYM));
+ }
+
+ @Test
+ void testRelationTargetSharesCanonicalIdInstance() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ final String target = lexicon.synset("mini-n1").orElseThrow()
+ .related(WordNetRelation.HYPERNYM).get(0);
+ // Relation targets reuse the id instance from the synset table.
+ assertSame(lexicon.synset("mini-n2").orElseThrow().id(), target);
+ }
+
+ @Test
+ void testSenseRelationsAreRepresentedAtSynsetLevel() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ assertEquals(List.of("mini-a2"), lexicon.related("mini-a1", WordNetRelation.ANTONYM));
+ assertEquals(List.of("mini-a1"), lexicon.related("mini-a2", WordNetRelation.ANTONYM));
+ assertEquals(List.of("mini-v1"),
+ lexicon.related("mini-n5", WordNetRelation.DERIVATIONALLY_RELATED));
+ assertEquals(List.of("mini-n5"),
+ lexicon.related("mini-v1", WordNetRelation.DERIVATIONALLY_RELATED));
+ }
+
+ @Test
+ void testSatelliteNormalizesToAdjective() {
+ final List senses = fixture().lookup("large", WordNetPOS.ADJECTIVE);
+ assertEquals(1, senses.size());
+ assertEquals(WordNetPOS.ADJECTIVE, senses.get(0).pos());
+ assertEquals(List.of("mini-a4"), fixture().related("mini-a3", WordNetRelation.SIMILAR_TO));
+ assertEquals(List.of("mini-a3"), fixture().related("mini-a4", WordNetRelation.SIMILAR_TO));
+ }
+
+ @Test
+ void testSimilarOnVerbSynsetMapsToVerbGroup() throws IOException {
+ // Documents derived from Princeton data express verb groups as similar on verb synsets;
+ // the fixture only defines similar on adjectives, so this pins the verb branch directly.
+ final LexicalKnowledgeBase lexicon = parse(wrap(
+ ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "produce musical tones"
+ + ""
+ + ""
+ + "sing monotonously"));
+ assertEquals(List.of("t-v2"), lexicon.related("t-v1", WordNetRelation.VERB_GROUP));
+ assertTrue(lexicon.related("t-v1", WordNetRelation.SIMILAR_TO).isEmpty());
+ }
+
+ @Test
+ void testUnknownLemmaOrSynsetIsEmpty() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ assertTrue(lexicon.lookup("zebra", WordNetPOS.NOUN).isEmpty());
+ assertTrue(lexicon.synset("mini-n99").isEmpty());
+ }
+
+ @Test
+ void testReadPath(@TempDir Path tempDir) throws IOException {
+ final Path file = tempDir.resolve("tiny.xml");
+ Files.writeString(file, wrap(
+ ""
+ + ""
+ + "a feline"));
+ final LexicalKnowledgeBase lexicon = WnLmfReader.read(file);
+ assertEquals("a feline", lexicon.lookup("cat", WordNetPOS.NOUN).get(0).gloss());
+ }
+
+ @Test
+ void testReadPathRejectsNullAndMissing(@TempDir Path tempDir) {
+ assertThrows(IllegalArgumentException.class, () -> WnLmfReader.read((Path) null));
+ assertThrows(IllegalArgumentException.class,
+ () -> WnLmfReader.read(tempDir.resolve("absent.xml")));
+ }
+
+ @Test
+ void testReadStreamRejectsNulls() {
+ assertThrows(IllegalArgumentException.class, () -> WnLmfReader.read(null, "x"));
+ assertThrows(IllegalArgumentException.class,
+ () -> WnLmfReader.read(new ByteArrayInputStream(new byte[0]), null));
+ }
+
+ @Test
+ void testReadDoesNotCloseInputStream() throws IOException {
+ final boolean[] closed = {false};
+ final InputStream in = new ByteArrayInputStream(wrap("").getBytes(StandardCharsets.UTF_8)) {
+ @Override
+ public void close() throws IOException {
+ closed[0] = true;
+ super.close();
+ }
+ };
+
+ WnLmfReader.read(in, RESOURCE_NAME);
+
+ assertFalse(closed[0]);
+ in.close();
+ assertTrue(closed[0]);
+ }
+
+ @Test
+ void testStreamReadFailurePropagatesAsIOException() {
+ final InputStream failing = new InputStream() {
+ @Override
+ public int read() throws IOException {
+ throw new IOException("Simulated stream failure");
+ }
+ };
+ final IOException e =
+ assertThrows(IOException.class, () -> WnLmfReader.read(failing, "failing.xml"));
+ // Preserve an I/O failure instead of reporting malformed XML.
+ assertFalse(e instanceof InvalidFormatException);
+ }
+
+ @Test
+ void testSkipsDoctypeDeclaration() throws IOException {
+ // The reserved domain makes an attempted external DTD fetch fail the test.
+ final String document = "\n"
+ + "\n"
+ + ""
+ + ""
+ + ""
+ + "a feline"
+ + "";
+ final LexicalKnowledgeBase lexicon = parse(document);
+ assertEquals("a feline", lexicon.lookup("cat", WordNetPOS.NOUN).get(0).gloss());
+ }
+
+ @Test
+ void testInternalSubsetEntityIsNeverExpanded(@TempDir Path tempDir) throws IOException {
+ // Expanding this entity would expose the temporary file's contents.
+ final Path secret = tempDir.resolve("secret.txt");
+ Files.writeString(secret, "xxe-marker-should-never-appear");
+ final String document = "\n"
+ + "]>\n"
+ + ""
+ + ""
+ + ""
+ + "a feline"
+ + "";
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> parse(document));
+ assertFalse(e.getMessage().contains("xxe-marker-should-never-appear"));
+ }
+
+ @Test
+ void testRejectsTruncatedDocument() {
+ final InvalidFormatException e = assertThrows(InvalidFormatException.class,
+ () -> parse("\n rejectedDocuments() {
+ return Stream.of(
+ Arguments.of(Named.of("sense without synset attribute",
+ ""
+ + ""),
+ List.of("synset")),
+ Arguments.of(Named.of("sense to undeclared synset",
+ ""
+ + ""),
+ List.of("t-9")),
+ Arguments.of(Named.of("relation to undeclared synset",
+ ""
+ + ""
+ + "a feline"
+ + ""),
+ List.of("t-9")),
+ Arguments.of(Named.of("unknown relation type",
+ ""
+ + ""
+ + "a feline"
+ + ""),
+ List.of("quasi_synonym")),
+ Arguments.of(Named.of("unknown part of speech",
+ ""
+ + ""
+ + "a feline"
+ + ""),
+ List.of("x")),
+ Arguments.of(Named.of("duplicate synset id",
+ ""
+ + ""
+ + "a feline"
+ + ""
+ + "a repeat"
+ + ""),
+ List.of("Duplicate synset id t-1")),
+ Arguments.of(Named.of("duplicate lexical entry id",
+ ""
+ + ""
+ + ""
+ + ""
+ + "a feline"
+ + ""),
+ List.of("Duplicate lexical entry id t-cat-n")),
+ Arguments.of(Named.of("duplicate sense id",
+ ""
+ + ""
+ + ""
+ + "a feline"
+ + ""
+ + "a second"
+ + ""),
+ List.of("Duplicate sense id t-cat-n-1")),
+ Arguments.of(Named.of("synset member pos mismatch",
+ ""
+ + ""
+ + "a feline"
+ + ""),
+ List.of("t-cat-n", "VERB", "NOUN")),
+ Arguments.of(Named.of("synset member assigned to another synset",
+ ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""),
+ List.of("t-dog-n-1", "t-1", "t-2")),
+ Arguments.of(Named.of("sense relation to undeclared sense",
+ ""
+ + ""
+ + ""
+ + ""
+ + "a feline"
+ + ""),
+ List.of("t-ghost-1")),
+ Arguments.of(Named.of("lemma outside lexical entry",
+ ""),
+ List.of("Lemma outside a LexicalEntry")),
+ Arguments.of(Named.of("duplicate lemma",
+ ""
+ + ""),
+ List.of("Duplicate Lemma in LexicalEntry t-cat-n")),
+ Arguments.of(Named.of("nested lexical entry",
+ ""
+ + ""),
+ List.of("Nested LexicalEntry inside t-outer-n")),
+ Arguments.of(Named.of("sense before lemma",
+ ""
+ + ""
+ + "a feline"
+ + ""),
+ List.of("Sense before its entry's Lemma")),
+ Arguments.of(Named.of("nested sense",
+ ""
+ + ""
+ + ""
+ + ""),
+ List.of("Nested Sense inside t-cat-n-1")),
+ Arguments.of(Named.of("nested synset",
+ ""
+ + ""),
+ List.of("Nested Synset inside t-1")),
+ Arguments.of(Named.of("definition outside synset",
+ "orphaned definition"),
+ List.of("Definition outside a Synset")),
+ Arguments.of(Named.of("sense relation outside sense",
+ ""
+ + ""
+ + ""
+ + "a feline"
+ + ""),
+ List.of("SenseRelation outside a Sense")),
+ Arguments.of(Named.of("synset relation outside synset",
+ ""),
+ List.of("SynsetRelation outside a Synset")));
+ }
+
+ @ParameterizedTest
+ @MethodSource("rejectedDocuments")
+ void testRejectsStructurallyInvalidDocument(String body,
+ List expectedMessageFragments) {
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> parse(wrap(body)));
+ for (final String fragment : expectedMessageFragments) {
+ assertTrue(e.getMessage().contains(fragment),
+ () -> "Rejection message must contain '" + fragment + "' but was: " + e.getMessage());
+ }
+ }
+
+ @Test
+ void testSkipsOtherRelationTypeOnSenseRelation() throws IOException {
+ final LexicalKnowledgeBase lexicon = parse(
+ wrap(""
+ + ""
+ + ""
+ + "a feline"));
+ assertTrue(lexicon.synset("t-1").orElseThrow().relations().isEmpty());
+ }
+
+ @Test
+ void testSkipsOtherRelationTypeOnSynsetRelation() throws IOException {
+ // The DTD permits relType="other" on SynsetRelation too, and several OMW-family wordnets
+ // emit it; it is skipped exactly like the SenseRelation case, not rejected.
+ final LexicalKnowledgeBase lexicon = parse(
+ wrap(""
+ + ""
+ + "a feline"
+ + ""));
+ assertTrue(lexicon.synset("t-1").orElseThrow().relations().isEmpty());
+ }
+
+ /**
+ * Rejects content that has an unexpected direct parent.
+ *
+ * @param body The invalid lexicon content.
+ */
+ @ParameterizedTest
+ @MethodSource("misplacedContent")
+ void testRejectsMisplacedContent(String body) {
+ assertMalformed(wrap(body));
+ }
+
+ /**
+ * Supplies incorrect nesting that could otherwise change the lookup index.
+ *
+ * @return Named XML examples.
+ */
+ private static Stream misplacedContent() {
+ return Stream.of(
+ Arguments.of(Named.of("synset in entry", """
+
+
+
+
+
+ """)),
+ Arguments.of(Named.of("entry in synset",
+ "" + CAT_ENTRY + "")),
+ Arguments.of(Named.of("sense in lemma", """
+
+
+
+
+
+ """ + CAT_SYNSET)),
+ Arguments.of(Named.of("nested sense relation", """
+
+
+
+
+
+
+
+
+ """ + CAT_SYNSET)),
+ Arguments.of(Named.of("definition in relation", CAT_ENTRY + """
+
+
+ misplaced definition
+
+
+ """)),
+ Arguments.of(Named.of("nested synset relation", CAT_ENTRY + """
+
+
+
+
+
+ """)),
+ Arguments.of(Named.of("requires in sense", """
+
+
+
+
+
+
+ """ + CAT_SYNSET)),
+ Arguments.of(Named.of("requires in synset", CAT_ENTRY + """
+
+
+
+ """)),
+ Arguments.of(Named.of("nested requires", """
+
+
+
+ """ + CAT_ENTRY + CAT_SYNSET)),
+ Arguments.of(Named.of("missing lemma",
+ "" + CAT_ENTRY + CAT_SYNSET)));
+ }
+
+ /** Requires the resource root instead of accepting a lexicon as the document root. */
+ @Test
+ void testRejectsLexiconAsRoot() {
+ assertMalformed("\n"
+ + CAT_ENTRY + CAT_SYNSET + "");
+ }
+
+ /** Rejects a resource container inside a lexicon. */
+ @Test
+ void testRejectsNestedResource() {
+ assertMalformed(wrap("" + CAT_ENTRY + CAT_SYNSET));
+ }
+
+ /**
+ * Prevents ignored entry content from adding senses or changing the active entry.
+ *
+ * @param ignored The ignored element name.
+ * @throws IOException If parsing fails.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"Form", "SyntacticBehaviour", "Metadata"})
+ void testIgnoredEntryContentCannotAddSenses(String ignored) throws IOException {
+ final LexicalKnowledgeBase lexicon = parse(wrap("""
+
+
+ """ + "<" + ignored + ">" + ignored + ">"
+ + "" + CAT_SYNSET));
+ assertEquals(List.of("t-1"), lexicon.lookup("cat", WordNetPOS.NOUN).stream()
+ .map(Synset::id).toList());
+ }
+
+ /**
+ * Excludes ignored definitions and relations from a synset.
+ *
+ * @param ignored The ignored element name.
+ * @throws IOException If parsing fails.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"Example", "ILIDefinition", "Metadata"})
+ void testIgnoredSynsetContentCannotAddDefinitionsOrRelations(String ignored) throws IOException {
+ final LexicalKnowledgeBase lexicon = parse(wrap(CAT_ENTRY
+ + "<" + ignored + ">"
+ + "ignored"
+ + "" + ignored + ">a cat"));
+ final Synset cat = lexicon.synset("t-1").orElseThrow();
+ assertEquals("a cat", cat.gloss());
+ assertTrue(cat.relations().isEmpty());
+ }
+
+ /**
+ * Skips nested metadata without claiming IDs or changing the following lexicon.
+ *
+ * @throws IOException If parsing fails.
+ */
+ @Test
+ void testIgnoredLexiconsDoNotChangeResource() throws IOException {
+ final int depth = 16;
+ final String lexicon = ""
+ + CAT_ENTRY + CAT_SYNSET + "";
+ final LexicalKnowledgeBase result = parse(""
+ + "".repeat(depth) + lexicon + "".repeat(depth)
+ + lexicon + "");
+ assertEquals(List.of("t-1"), result.lookup("cat", WordNetPOS.NOUN).stream()
+ .map(Synset::id).toList());
+ }
+
+ /**
+ * Requires ignored XML to remain syntactically correct.
+ *
+ * @param content The malformed content.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"", "",
+ "&undeclared;"})
+ void testIgnoredXmlMustBeWellFormed(String content) {
+ assertMalformed(wrap(content + CAT_ENTRY + CAT_SYNSET));
+ }
+
+ /**
+ * Checks lexical data and dependency metadata around valid ignored elements.
+ *
+ * @throws IOException If parsing fails.
+ */
+ @Test
+ void testSupportedContentWithMetadataPreservesLookup() throws IOException {
+ final WnLmfResource resource = WnLmfReader.readResource(new ByteArrayInputStream(wrap("""
+
+
+ cat
+
+
+
+ example1
+
+
+
+ a cata feline
+ ignored
+
+ example
+
+ """).getBytes(StandardCharsets.UTF_8)), RESOURCE_NAME);
+ final WnLmfLexicon lexicon = resource.lexicons().get(0);
+ assertEquals(List.of(new WnLmfDependency("external", "1")), lexicon.dependencies());
+ final Synset cat = lexicon.knowledgeBase().lookup("cat", WordNetPOS.NOUN).get(0);
+ assertEquals("a cat; a feline", cat.gloss());
+ assertEquals(List.of("cat"), cat.lemmas());
+ assertEquals(List.of("t-1"), cat.related(WordNetRelation.ALSO_SEE));
+ assertEquals(List.of("t-1"), cat.related(WordNetRelation.ANTONYM));
+ }
+
+ /**
+ * Reports a sense omitted from explicit member lemmas as a format error.
+ *
+ * @param members The sense or legacy entry identifier listing the cat.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"t-cat-n-1", "t-cat-n"})
+ void testOmittedSenseLemmaIsAFormatError(String members) {
+ final String document = wrap(CAT_ENTRY + """
+
+
+
+
+ """ + "");
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> parse(document));
+ assertTrue(error.getMessage().contains("t-dog-n-1"), error::getMessage);
+ assertTrue(error.getMessage().contains(" at line "), error::getMessage);
+ assertTrue(error.getMessage().contains(RESOURCE_NAME), error::getMessage);
+ }
+
+ /**
+ * Checks parts of speech even for senses omitted from explicit member lists.
+ *
+ * @param members The sense or legacy entry identifier listing the cat.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"t-cat-n-1", "t-cat-n"})
+ void testOmittedSensePartOfSpeechIsAFormatError(String members) {
+ final String document = wrap(CAT_ENTRY + """
+
+
+
+
+ """ + "");
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> parse(document));
+ assertTrue(error.getMessage().contains("t-purr-v-1"), error::getMessage);
+ assertTrue(error.getMessage().contains("part of speech"), error::getMessage);
+ assertTrue(error.getMessage().contains(" at line "), error::getMessage);
+ assertTrue(error.getMessage().contains(RESOURCE_NAME), error::getMessage);
+ }
+
+ /** Reports the source line of a sense referencing a missing synset. */
+ @Test
+ void testMissingSenseTargetReportsSenseLine() {
+ final String document = """
+
+
+
+
+
+
+
+
+ """;
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> parse(document));
+ assertTrue(error.getMessage().contains("t-cat-n-1"), error::getMessage);
+ assertTrue(error.getMessage().contains("missing"), error::getMessage);
+ assertTrue(error.getMessage().contains(" at line 5"), error::getMessage);
+ assertTrue(error.getMessage().contains(RESOURCE_NAME), error::getMessage);
+ }
+
+ /**
+ * Accepts member aliases after case and underscore normalization.
+ *
+ * @param lemma The spelling in the explicit member list.
+ * @param alias The spelling of another sense's entry.
+ * @throws IOException If parsing fails.
+ */
+ @ParameterizedTest
+ @CsvSource({"cat,CAT", "cat,cAt", "domestic dog,domestic_dog", "domestic dog,DOMESTIC_DOG"})
+ void testEquivalentMemberAliasIsAccepted(String lemma, String alias) throws IOException {
+ final String document = wrap(CAT_ENTRY.replace("cat\"", lemma + "\"")
+ + ""
+ + ""
+ + "");
+ final LexicalKnowledgeBase result = parse(document);
+ assertEquals(List.of(lemma), result.synset("t-1").orElseThrow().lemmas());
+ assertEquals(List.of("t-1"), result.lookup(alias, WordNetPOS.NOUN).stream()
+ .map(Synset::id).toList());
+ }
+
+ /**
+ * Keeps each member lemma and deduplicates repeated sense targets.
+ *
+ * @throws IOException If parsing fails.
+ */
+ @Test
+ void testRepeatedSenseTargetRemainsDeduplicated() throws IOException {
+ final String entry = CAT_ENTRY.replace("",
+ "");
+ final LexicalKnowledgeBase result = parse(wrap(entry + CAT_SYNSET));
+ assertEquals(List.of("cat"), result.synset("t-1").orElseThrow().lemmas());
+ assertEquals(List.of("t-1"), result.lookup("cat", WordNetPOS.NOUN).stream()
+ .map(Synset::id).toList());
+ }
+
+ /**
+ * Preserves legacy member entries without adding senses.
+ *
+ * @throws IOException If parsing fails.
+ */
+ @Test
+ void testLegacyMemberWithoutSenseRemainsAccepted() throws IOException {
+ final String entry = CAT_ENTRY.replace("", "");
+ final LexicalKnowledgeBase result = parse(wrap(entry
+ + ""));
+ assertEquals(List.of("cat"), result.synset("t-1").orElseThrow().lemmas());
+ assertTrue(result.lookup("cat", WordNetPOS.NOUN).isEmpty());
+ }
+
+ /**
+ * Checks the exception type, resource name and source line.
+ *
+ * @param document The invalid document.
+ */
+ private void assertMalformed(String document) {
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> parse(document));
+ assertTrue(error.getMessage().contains(RESOURCE_NAME), error::getMessage);
+ assertTrue(error.getMessage().contains(" at line "), error::getMessage);
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfRelationCoverageTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfRelationCoverageTest.java
new file mode 100644
index 0000000000..4d0039648b
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfRelationCoverageTest.java
@@ -0,0 +1,303 @@
+/*
+ * 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.wordnet;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+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.MethodSource;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.wordnet.LexicalKnowledgeBase;
+import opennlp.tools.wordnet.WordNetRelation;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Conformance pins for every relation type in the GWA WN-LMF 1.4 DTD. */
+class WnLmfRelationCoverageTest {
+
+ private static final Map SYNSET_RELATIONS = synsetRelations();
+ private static final Map SENSE_RELATIONS = senseRelations();
+
+ @ParameterizedTest(name = "SynsetRelation {0} maps to {1}")
+ @MethodSource("legalSynsetRelations")
+ void testEveryLegalSynsetRelation(String relType, WordNetRelation expected) throws IOException {
+ final LexicalKnowledgeBase lexicon = parse(synsetDocument(relType, "n"));
+ assertEquals(List.of("t-target-n"), lexicon.related("t-source-n", expected));
+ }
+
+ @ParameterizedTest(name = "SenseRelation {0} maps to {1}")
+ @MethodSource("legalSenseRelations")
+ void testEveryLegalSenseRelation(String relType, WordNetRelation expected) throws IOException {
+ final LexicalKnowledgeBase lexicon = parse(senseDocument(relType, "v"));
+ assertEquals(List.of("t-target-v"), lexicon.related("t-source-v", expected));
+ }
+
+ @Test
+ void testCoverageTablesMatchWnLmf14Cardinality() {
+ // WN-LMF 1.4 declares 85 SynsetRelation values and 48 SenseRelation values.
+ // The untyped "other" relation has no WordNetRelation value and is skipped.
+ assertEquals(84, SYNSET_RELATIONS.size());
+ assertEquals(47, SENSE_RELATIONS.size());
+ }
+
+ @Test
+ void testSimilarKeepsSynsetAndSenseSemanticsSeparate() throws IOException {
+ final LexicalKnowledgeBase synsetVerb = parse(synsetDocument("similar", "v"));
+ assertEquals(List.of("t-target-v"),
+ synsetVerb.related("t-source-v", WordNetRelation.VERB_GROUP));
+
+ final LexicalKnowledgeBase senseVerb = parse(senseDocument("similar", "v"));
+ assertEquals(List.of("t-target-v"),
+ senseVerb.related("t-source-v", WordNetRelation.SIMILAR_TO));
+ assertTrue(senseVerb.related("t-source-v", WordNetRelation.VERB_GROUP).isEmpty());
+ }
+
+ @Test
+ void testOtherRemainsAnExplicitlyUntypedEscapeHatch() throws IOException {
+ assertTrue(parse(synsetDocument("other", "n")).synset("t-source-n")
+ .orElseThrow().relations().isEmpty());
+ assertTrue(parse(senseDocument("other", "n")).synset("t-source-n")
+ .orElseThrow().relations().isEmpty());
+ }
+
+ @Test
+ void testRelationNamesRemainCaseSensitive() {
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> parse(synsetDocument("Hypernym", "n")));
+ assertTrue(error.getMessage().contains("Unknown relation type Hypernym"));
+ }
+
+ @Test
+ void testRejectsSenseOnlyRelationOnSynset() {
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> parse(synsetDocument("body_part", "n")));
+ assertTrue(error.getMessage().contains(
+ "Relation type body_part is not legal on SynsetRelation"));
+ }
+
+ @Test
+ void testRejectsSynsetOnlyRelationOnSense() {
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> parse(senseDocument("hypernym", "n")));
+ assertTrue(error.getMessage().contains(
+ "Relation type hypernym is not legal on SenseRelation"));
+ }
+
+ @Test
+ void testDuplicateRelationTargetsAreDeduplicatedInSourceOrder() throws IOException {
+ final String body = entries("n")
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "";
+ final LexicalKnowledgeBase lexicon = parse(wrap(body));
+ assertEquals(List.of("t-target-n", "t-target2-n"),
+ lexicon.related("t-source-n", WordNetRelation.AGENT));
+ }
+
+ static Stream legalSynsetRelations() {
+ return SYNSET_RELATIONS.entrySet().stream()
+ .map(entry -> Arguments.of(entry.getKey(), entry.getValue()));
+ }
+
+ static Stream legalSenseRelations() {
+ return SENSE_RELATIONS.entrySet().stream()
+ .map(entry -> Arguments.of(entry.getKey(), entry.getValue()));
+ }
+
+ private static LexicalKnowledgeBase parse(String document) throws IOException {
+ return WnLmfReader.read(
+ new ByteArrayInputStream(document.getBytes(StandardCharsets.UTF_8)), "relations.xml");
+ }
+
+ private static String synsetDocument(String relType, String pos) {
+ return wrap(entries(pos)
+ + ""
+ + ""
+ + "");
+ }
+
+ private static String senseDocument(String relType, String pos) {
+ return wrap(""
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "");
+ }
+
+ private static String entries(String pos) {
+ return ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "";
+ }
+
+ private static String wrap(String body) {
+ return ""
+ + ""
+ + body + "";
+ }
+
+ private static Map synsetRelations() {
+ final Map relations = commonRelations();
+ relations.put("attribute", WordNetRelation.ATTRIBUTE);
+ relations.put("be_in_state", WordNetRelation.BE_IN_STATE);
+ relations.put("causes", WordNetRelation.CAUSE);
+ relations.put("classified_by", WordNetRelation.CLASSIFIED_BY);
+ relations.put("classifies", WordNetRelation.CLASSIFIES);
+ relations.put("co_agent_instrument", WordNetRelation.CO_AGENT_INSTRUMENT);
+ relations.put("co_agent_patient", WordNetRelation.CO_AGENT_PATIENT);
+ relations.put("co_agent_result", WordNetRelation.CO_AGENT_RESULT);
+ relations.put("co_instrument_agent", WordNetRelation.CO_INSTRUMENT_AGENT);
+ relations.put("co_instrument_patient", WordNetRelation.CO_INSTRUMENT_PATIENT);
+ relations.put("co_instrument_result", WordNetRelation.CO_INSTRUMENT_RESULT);
+ relations.put("co_patient_agent", WordNetRelation.CO_PATIENT_AGENT);
+ relations.put("co_patient_instrument", WordNetRelation.CO_PATIENT_INSTRUMENT);
+ relations.put("co_result_agent", WordNetRelation.CO_RESULT_AGENT);
+ relations.put("co_result_instrument", WordNetRelation.CO_RESULT_INSTRUMENT);
+ relations.put("co_role", WordNetRelation.CO_ROLE);
+ relations.put("direction", WordNetRelation.DIRECTION);
+ relations.put("entails", WordNetRelation.ENTAILMENT);
+ relations.put("eq_synonym", WordNetRelation.EQ_SYNONYM);
+ relations.put("holo_location", WordNetRelation.LOCATION_HOLONYM);
+ relations.put("holo_member", WordNetRelation.MEMBER_HOLONYM);
+ relations.put("holo_part", WordNetRelation.PART_HOLONYM);
+ relations.put("holo_portion", WordNetRelation.PORTION_HOLONYM);
+ relations.put("holo_substance", WordNetRelation.SUBSTANCE_HOLONYM);
+ relations.put("holonym", WordNetRelation.HOLONYM);
+ relations.put("hypernym", WordNetRelation.HYPERNYM);
+ relations.put("hyponym", WordNetRelation.HYPONYM);
+ relations.put("in_manner", WordNetRelation.IN_MANNER);
+ relations.put("instance_hypernym", WordNetRelation.INSTANCE_HYPERNYM);
+ relations.put("instance_hyponym", WordNetRelation.INSTANCE_HYPONYM);
+ relations.put("involved", WordNetRelation.INVOLVED);
+ relations.put("involved_agent", WordNetRelation.INVOLVED_AGENT);
+ relations.put("involved_direction", WordNetRelation.INVOLVED_DIRECTION);
+ relations.put("involved_instrument", WordNetRelation.INVOLVED_INSTRUMENT);
+ relations.put("involved_location", WordNetRelation.INVOLVED_LOCATION);
+ relations.put("involved_patient", WordNetRelation.INVOLVED_PATIENT);
+ relations.put("involved_result", WordNetRelation.INVOLVED_RESULT);
+ relations.put("involved_source_direction", WordNetRelation.INVOLVED_SOURCE_DIRECTION);
+ relations.put("involved_target_direction", WordNetRelation.INVOLVED_TARGET_DIRECTION);
+ relations.put("ir_synonym", WordNetRelation.IR_SYNONYM);
+ relations.put("is_caused_by", WordNetRelation.CAUSED_BY);
+ relations.put("is_entailed_by", WordNetRelation.ENTAILED_BY);
+ relations.put("is_subevent_of", WordNetRelation.IS_SUBEVENT_OF);
+ relations.put("manner_of", WordNetRelation.MANNER_OF);
+ relations.put("mero_location", WordNetRelation.LOCATION_MERONYM);
+ relations.put("mero_member", WordNetRelation.MEMBER_MERONYM);
+ relations.put("mero_part", WordNetRelation.PART_MERONYM);
+ relations.put("mero_portion", WordNetRelation.PORTION_MERONYM);
+ relations.put("mero_substance", WordNetRelation.SUBSTANCE_MERONYM);
+ relations.put("meronym", WordNetRelation.MERONYM);
+ relations.put("patient", WordNetRelation.PATIENT);
+ relations.put("restricted_by", WordNetRelation.RESTRICTED_BY);
+ relations.put("restricts", WordNetRelation.RESTRICTS);
+ relations.put("role", WordNetRelation.ROLE);
+ relations.put("similar", WordNetRelation.SIMILAR_TO);
+ relations.put("source_direction", WordNetRelation.SOURCE_DIRECTION);
+ relations.put("state_of", WordNetRelation.STATE_OF);
+ relations.put("subevent", WordNetRelation.SUBEVENT);
+ relations.put("target_direction", WordNetRelation.TARGET_DIRECTION);
+ return Map.copyOf(relations);
+ }
+
+ private static Map senseRelations() {
+ final Map relations = commonRelations();
+ relations.put("body_part", WordNetRelation.BODY_PART);
+ relations.put("by_means_of", WordNetRelation.BY_MEANS_OF);
+ relations.put("derivation", WordNetRelation.DERIVATIONALLY_RELATED);
+ relations.put("destination", WordNetRelation.DESTINATION);
+ relations.put("event", WordNetRelation.EVENT);
+ relations.put("has_metaphor", WordNetRelation.HAS_METAPHOR);
+ relations.put("has_metonym", WordNetRelation.HAS_METONYM);
+ relations.put("material", WordNetRelation.MATERIAL);
+ relations.put("metaphor", WordNetRelation.METAPHOR);
+ relations.put("metonym", WordNetRelation.METONYM);
+ relations.put("participle", WordNetRelation.PARTICIPLE);
+ relations.put("pertainym", WordNetRelation.PERTAINYM);
+ relations.put("property", WordNetRelation.PROPERTY);
+ relations.put("secondary_aspect_ip", WordNetRelation.SECONDARY_ASPECT_IP);
+ relations.put("secondary_aspect_pi", WordNetRelation.SECONDARY_ASPECT_PI);
+ relations.put("similar", WordNetRelation.SIMILAR_TO);
+ relations.put("simple_aspect_ip", WordNetRelation.SIMPLE_ASPECT_IP);
+ relations.put("simple_aspect_pi", WordNetRelation.SIMPLE_ASPECT_PI);
+ relations.put("state", WordNetRelation.STATE);
+ relations.put("undergoer", WordNetRelation.UNDERGOER);
+ relations.put("uses", WordNetRelation.USES);
+ relations.put("vehicle", WordNetRelation.VEHICLE);
+ return Map.copyOf(relations);
+ }
+
+ private static Map commonRelations() {
+ final Map relations = new LinkedHashMap<>();
+ relations.put("agent", WordNetRelation.AGENT);
+ relations.put("also", WordNetRelation.ALSO_SEE);
+ relations.put("anto_converse", WordNetRelation.ANTO_CONVERSE);
+ relations.put("anto_gradable", WordNetRelation.ANTO_GRADABLE);
+ relations.put("anto_simple", WordNetRelation.ANTO_SIMPLE);
+ relations.put("antonym", WordNetRelation.ANTONYM);
+ relations.put("augmentative", WordNetRelation.AUGMENTATIVE);
+ relations.put("diminutive", WordNetRelation.DIMINUTIVE);
+ relations.put("domain_region", WordNetRelation.DOMAIN_REGION);
+ relations.put("domain_topic", WordNetRelation.DOMAIN_TOPIC);
+ relations.put("exemplifies", WordNetRelation.DOMAIN_USAGE);
+ relations.put("feminine", WordNetRelation.FEMININE);
+ relations.put("has_augmentative", WordNetRelation.HAS_AUGMENTATIVE);
+ relations.put("has_diminutive", WordNetRelation.HAS_DIMINUTIVE);
+ relations.put("has_domain_region", WordNetRelation.MEMBER_OF_DOMAIN_REGION);
+ relations.put("has_domain_topic", WordNetRelation.MEMBER_OF_DOMAIN_TOPIC);
+ relations.put("has_feminine", WordNetRelation.HAS_FEMININE);
+ relations.put("has_masculine", WordNetRelation.HAS_MASCULINE);
+ relations.put("has_young", WordNetRelation.HAS_YOUNG);
+ relations.put("instrument", WordNetRelation.INSTRUMENT);
+ relations.put("is_exemplified_by", WordNetRelation.MEMBER_OF_DOMAIN_USAGE);
+ relations.put("location", WordNetRelation.LOCATION);
+ relations.put("masculine", WordNetRelation.MASCULINE);
+ relations.put("result", WordNetRelation.RESULT);
+ relations.put("young", WordNetRelation.YOUNG);
+ return relations;
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfResourceTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfResourceTest.java
new file mode 100644
index 0000000000..a716a88a8b
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfResourceTest.java
@@ -0,0 +1,329 @@
+/*
+ * 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.wordnet;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.xml.namespace.QName;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.wordnet.WordNetPOS;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class WnLmfResourceTest {
+
+ private static final String DC_NAMESPACE =
+ "https://globalwordnet.github.io/schemas/dc/";
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void testReadsRepresentativeMultilingualResourceWithoutMergingLexicons() throws IOException {
+ final WnLmfResource resource = multilingualFixture();
+ assertEquals(List.of("omw-it", "omw-es", "omw-sv"),
+ resource.lexicons().stream().map(WnLmfLexicon::id).toList());
+
+ final WnLmfLexicon italian = resource.lexicon("omw-it").orElseThrow();
+ assertEquals("it", italian.language());
+ assertEquals("2.0", italian.version());
+ assertEquals("https://creativecommons.org/licenses/by/3.0/",
+ italian.metadata().get(new QName("license")));
+ assertEquals("Open Multilingual Wordnet",
+ italian.metadata().get(new QName(DC_NAMESPACE, "publisher")));
+ assertEquals(List.of(new WnLmfDependency("omw-en", "2.0")), italian.dependencies());
+ assertEquals("omw-it-02084071-n",
+ italian.knowledgeBase().lookup("cane", WordNetPOS.NOUN).get(0).id());
+ assertEquals(List.of("cane"), italian.knowledgeBase()
+ .synset("omw-it-02084071-n").orElseThrow().lemmas());
+
+ assertEquals("omw-es-02084071-n", resource.lexicon("omw-es").orElseThrow()
+ .knowledgeBase().lookup("perro", WordNetPOS.NOUN).get(0).id());
+ assertEquals("omw-sv-02084071-n", resource.lexicon("omw-sv").orElseThrow()
+ .knowledgeBase().lookup("hund", WordNetPOS.NOUN).get(0).id());
+
+ assertTrue(italian.knowledgeBase().lookup("perro", WordNetPOS.NOUN).isEmpty());
+ assertTrue(resource.lexicon("missing").isEmpty());
+ }
+
+ @Test
+ void testSingleLexiconConvenienceRejectsMultiLexiconResource() {
+ try (InputStream in = fixtureStream()) {
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> WnLmfReader.read(in, "omw-multilingual.xml"));
+ assertTrue(error.getMessage().contains("contains 3 lexicons"));
+ assertTrue(error.getMessage().contains("readResource"));
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @Test
+ void testReadResourcePathPreservesLexiconBoundaries() throws IOException {
+ final Path resourceFile = tempDir.resolve("multilingual.xml");
+ try (InputStream in = fixtureStream()) {
+ Files.copy(in, resourceFile);
+ }
+
+ final WnLmfResource resource = WnLmfReader.readResource(resourceFile);
+ assertEquals(List.of("omw-it", "omw-es", "omw-sv"),
+ resource.lexicons().stream().map(WnLmfLexicon::id).toList());
+ }
+
+ @Test
+ void testReadResourceRejectsInvalidPathAndStreamArguments() {
+ assertThrows(IllegalArgumentException.class,
+ () -> WnLmfReader.readResource((Path) null));
+ assertThrows(IllegalArgumentException.class,
+ () -> WnLmfReader.readResource(tempDir.resolve("missing.xml")));
+ assertThrows(IllegalArgumentException.class,
+ () -> WnLmfReader.readResource((InputStream) null, "null.xml"));
+ assertThrows(IllegalArgumentException.class,
+ () -> WnLmfReader.readResource(bytes(""), null));
+ }
+
+ @Test
+ void testReadResourceRejectsDuplicateLexiconIds() {
+ final String document = "" + tinyLexicon("same", "en", "cat")
+ + tinyLexicon("same", "de", "Katze") + "";
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> WnLmfReader.readResource(bytes(document), "duplicates.xml"));
+ assertTrue(error.getMessage().contains("Duplicate lexicon id same"));
+ }
+
+ @Test
+ void testReadResourceRejectsDuplicateXmlIdsAcrossLexicons() {
+ final String first = tinyLexicon("one", "en", "cat");
+ final String second = tinyLexicon("two", "de", "Katze")
+ .replace("two-entry", "one-entry");
+ final String document = "" + first + second + "";
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> WnLmfReader.readResource(bytes(document), "duplicate-xml-id.xml"));
+ assertTrue(error.getMessage().contains("Duplicate lexical entry id one-entry"));
+ }
+
+ @Test
+ void testReadResourceRejectsLexicalContentOutsideLexicon() {
+ final String document = ""
+ + ""
+ + "";
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> WnLmfReader.readResource(bytes(document), "outside.xml"));
+ assertTrue(error.getMessage().contains("LexicalEntry outside a Lexicon"));
+ }
+
+ @Test
+ void testReadResourceRejectsUnsupportedLexiconExtensionClearly() {
+ final String document = "";
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> WnLmfReader.readResource(bytes(document), "extension.xml"));
+ assertTrue(error.getMessage().contains("LexiconExtension is not supported"));
+ }
+
+ @Test
+ void testReadResourceRejectsMissingRequiredLexiconMetadata() {
+ final String document = "";
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> WnLmfReader.readResource(bytes(document), "metadata.xml"));
+ assertTrue(error.getMessage().contains("Lexicon is missing required attribute label"));
+ }
+
+ @Test
+ void testPreservesMultipleDependenciesInSourceOrder() throws IOException {
+ final String document = ""
+ + ""
+ + ""
+ + "";
+ final WnLmfLexicon lexicon =
+ WnLmfReader.readResource(bytes(document), "dependencies.xml").lexicons().get(0);
+ assertEquals(List.of(
+ new WnLmfDependency("base", "2"),
+ new WnLmfDependency("domain", "3")), lexicon.dependencies());
+ }
+
+ @Test
+ void testLexiconWithoutRequiresHasNoDependencies() throws IOException {
+ final String document = "" + tinyLexicon("standalone", "en", "cat")
+ + "";
+ final WnLmfLexicon lexicon =
+ WnLmfReader.readResource(bytes(document), "standalone.xml").lexicons().get(0);
+ assertTrue(lexicon.dependencies().isEmpty());
+ }
+
+ @Test
+ void testDependenciesDoNotLeakBetweenLexicons() throws IOException {
+ final String dependent = ""
+ + "";
+ final String document = "" + dependent
+ + tinyLexicon("standalone", "de", "Katze") + "";
+ final WnLmfResource resource =
+ WnLmfReader.readResource(bytes(document), "dependency-scope.xml");
+ assertEquals(List.of(new WnLmfDependency("base", "2")),
+ resource.lexicons().get(0).dependencies());
+ assertTrue(resource.lexicons().get(1).dependencies().isEmpty());
+ }
+
+ @Test
+ void testReadResourceRejectsRequiresOutsideLexicon() {
+ final String document = ""
+ + tinyLexicon("one", "en", "cat") + "";
+ final InvalidFormatException error = assertThrows(InvalidFormatException.class,
+ () -> WnLmfReader.readResource(bytes(document), "outside-requires.xml"));
+ assertTrue(error.getMessage().contains("Requires outside a Lexicon"));
+ }
+
+ @Test
+ void testReadResourceRejectsIncompleteRequires() {
+ final String missingRef = ""
+ + "";
+ final InvalidFormatException refError = assertThrows(InvalidFormatException.class,
+ () -> WnLmfReader.readResource(bytes(missingRef), "missing-ref.xml"));
+ assertTrue(refError.getMessage().contains("Requires is missing required attribute ref"));
+
+ final String missingVersion = ""
+ + "";
+ final InvalidFormatException versionError = assertThrows(InvalidFormatException.class,
+ () -> WnLmfReader.readResource(bytes(missingVersion), "missing-version.xml"));
+ assertTrue(versionError.getMessage().contains(
+ "Requires is missing required attribute version"));
+ }
+
+ @Test
+ void testPreservesUnlexicalizedSynsetUsedByRealOmwResources() throws IOException {
+ final String document = ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + "";
+ final WnLmfLexicon lexicon =
+ WnLmfReader.readResource(bytes(document), "unlexicalized.xml").lexicons().get(0);
+ assertTrue(lexicon.knowledgeBase().synset("unlexicalized").orElseThrow().lemmas().isEmpty());
+ }
+
+ @Test
+ void testResourceAndLexiconContractsAreDefensive() {
+ final WnLmfLexicon source = multilingualFixture().lexicon("omw-it").orElseThrow();
+ final Map metadata = new HashMap<>();
+ metadata.put(new QName("license"), "test-license");
+ final List dependencies = new ArrayList<>();
+ dependencies.add(new WnLmfDependency("base", "1"));
+ final WnLmfLexicon lexicon = new WnLmfLexicon(
+ "id", "label", "it", "1", metadata, dependencies, source.knowledgeBase());
+ metadata.put(new QName("mutated"), "yes");
+ dependencies.clear();
+ assertEquals(Map.of(new QName("license"), "test-license"), lexicon.metadata());
+ assertEquals(List.of(new WnLmfDependency("base", "1")), lexicon.dependencies());
+ assertThrows(UnsupportedOperationException.class,
+ () -> lexicon.metadata().put(new QName("x"), "y"));
+ assertThrows(UnsupportedOperationException.class, () -> lexicon.dependencies().clear());
+
+ final List lexicons = new ArrayList<>(List.of(lexicon));
+ final WnLmfResource resource = new WnLmfResource(lexicons);
+ lexicons.clear();
+ assertEquals(1, resource.lexicons().size());
+ assertThrows(UnsupportedOperationException.class, () -> resource.lexicons().clear());
+ assertThrows(IllegalArgumentException.class, () -> resource.lexicon(null));
+ }
+
+ @Test
+ void testResourceAndLexiconContractsRejectInvalidComponents() {
+ final WnLmfLexicon source = multilingualFixture().lexicon("omw-it").orElseThrow();
+ assertThrows(IllegalArgumentException.class, () -> new WnLmfResource(List.of()));
+ assertThrows(IllegalArgumentException.class, () -> new WnLmfResource(
+ java.util.Collections.singletonList(null)));
+ assertThrows(IllegalArgumentException.class, () -> new WnLmfResource(List.of(source, source)));
+ assertThrows(IllegalArgumentException.class,
+ () -> new WnLmfLexicon(null, "label", "it", "1", Map.of(), List.of(),
+ source.knowledgeBase()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new WnLmfLexicon("id", "", "it", "1", Map.of(), List.of(),
+ source.knowledgeBase()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new WnLmfLexicon("id", "label", "", "1", Map.of(), List.of(),
+ source.knowledgeBase()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new WnLmfLexicon("id", "label", "it", "", Map.of(), List.of(),
+ source.knowledgeBase()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new WnLmfLexicon("id", "label", "it", "1", null, List.of(),
+ source.knowledgeBase()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new WnLmfLexicon("id", "label", "it", "1", Map.of(), null,
+ source.knowledgeBase()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new WnLmfLexicon("id", "label", "it", "1", Map.of(),
+ java.util.Collections.singletonList(null), source.knowledgeBase()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new WnLmfLexicon("id", "label", "it", "1", Map.of(), List.of(), null));
+ assertThrows(IllegalArgumentException.class, () -> new WnLmfDependency(null, "1"));
+ assertThrows(IllegalArgumentException.class, () -> new WnLmfDependency("", "1"));
+ assertThrows(IllegalArgumentException.class, () -> new WnLmfDependency("base", null));
+ assertThrows(IllegalArgumentException.class, () -> new WnLmfDependency("base", ""));
+ }
+
+ private static WnLmfResource multilingualFixture() {
+ try (InputStream in = fixtureStream()) {
+ return WnLmfReader.readResource(in, "omw-multilingual.xml");
+ } catch (IOException e) {
+ throw new IllegalStateException("Unexpected fixture read failure", e);
+ }
+ }
+
+ private static InputStream fixtureStream() {
+ final InputStream in = WnLmfResourceTest.class.getResourceAsStream("omw-multilingual.xml");
+ assertNotNull(in, "Fixture omw-multilingual.xml must be on the test classpath");
+ return in;
+ }
+
+ private static ByteArrayInputStream bytes(String document) {
+ return new ByteArrayInputStream(document.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String tinyLexicon(String id, String language, String lemma) {
+ return ""
+ + ""
+ + ""
+ + ""
+ + "";
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WndbIntegrationTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WndbIntegrationTest.java
new file mode 100644
index 0000000000..7cfa560aae
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WndbIntegrationTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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.wordnet;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.wordnet.LexicalKnowledgeBase;
+import opennlp.tools.wordnet.WordNetPOS;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/** Smoke test against a complete Princeton WNDB release supplied by the developer. */
+class WndbIntegrationTest {
+
+ @Test
+ void testCompleteRelease() throws IOException {
+ final String fixtureRoot = System.getProperty("opennlp.wordnet.wndbDir");
+ Assumptions.assumeTrue(fixtureRoot != null && !fixtureRoot.isBlank(),
+ "Set opennlp.wordnet.wndbDir to a complete WNDB directory");
+
+ final LexicalKnowledgeBase wordNet = WndbReader.read(Path.of(fixtureRoot));
+
+ assertFalse(wordNet.lookup("dog", WordNetPOS.NOUN).isEmpty());
+ assertFalse(wordNet.lookup("run", WordNetPOS.VERB).isEmpty());
+ assertFalse(wordNet.lookup("quick", WordNetPOS.ADJECTIVE).isEmpty());
+ assertFalse(wordNet.lookup("quickly", WordNetPOS.ADVERB).isEmpty());
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WndbReaderTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WndbReaderTest.java
new file mode 100644
index 0000000000..2a258890e3
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WndbReaderTest.java
@@ -0,0 +1,336 @@
+/*
+ * 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.wordnet;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.UnaryOperator;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import opennlp.tools.util.InvalidFormatException;
+import opennlp.tools.wordnet.LexicalKnowledgeBase;
+import opennlp.tools.wordnet.Synset;
+import opennlp.tools.wordnet.WordNetPOS;
+import opennlp.tools.wordnet.WordNetRelation;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class WndbReaderTest {
+
+ /** The id of the fixture's dog synset, shared with the other fixture tests. */
+ static final String DOG_ID = "wndb-00001075-n";
+
+ /** The id of the fixture's canid synset, the hypernym of {@link #DOG_ID}. */
+ static final String CANID_ID = "wndb-00001160-n";
+
+ /**
+ * Locates the miniature WNDB database directory on the test classpath.
+ *
+ * @return The fixture directory.
+ */
+ static Path fixtureDirectory() {
+ final URL url = WndbReaderTest.class.getResource("mini-wndb");
+ assertNotNull(url, "Fixture directory mini-wndb must be on the test classpath");
+ try {
+ return Path.of(url.toURI());
+ } catch (URISyntaxException e) {
+ throw new IllegalStateException("Unexpected fixture URI: " + url, e);
+ }
+ }
+
+ /**
+ * Loads the miniature WNDB database into a lexicon.
+ *
+ * @return The loaded fixture lexicon.
+ */
+ static LexicalKnowledgeBase fixture() {
+ try {
+ return WndbReader.read(fixtureDirectory());
+ } catch (IOException e) {
+ throw new IllegalStateException("Unexpected IOException reading the WNDB fixture", e);
+ }
+ }
+
+ @Test
+ void testLookupReturnsSynsetWithAllComponents() {
+ final List senses = fixture().lookup("dog", WordNetPOS.NOUN);
+ assertEquals(1, senses.size());
+ final Synset dog = senses.get(0);
+ assertEquals(DOG_ID, dog.id());
+ assertEquals(WordNetPOS.NOUN, dog.pos());
+ assertEquals(List.of("dog", "domestic dog"), dog.lemmas());
+ assertEquals("a domesticated canid", dog.gloss());
+ assertEquals(List.of(CANID_ID), dog.related(WordNetRelation.HYPERNYM));
+ }
+
+ @Test
+ void testLookupFoldsCaseAndUnderscore() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ assertEquals(DOG_ID, lexicon.lookup("Domestic_Dog", WordNetPOS.NOUN).get(0).id());
+ assertEquals(DOG_ID, lexicon.lookup("DOG", WordNetPOS.NOUN).get(0).id());
+ }
+
+ @Test
+ void testLookupKeepsIndexSenseOrder() {
+ assertEquals(List.of("wndb-00001427-n", "wndb-00001669-n"),
+ fixture().lookup("run", WordNetPOS.NOUN).stream().map(Synset::id).toList());
+ }
+
+ @Test
+ void testRelationNavigation() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ assertEquals(List.of(DOG_ID), lexicon.related(CANID_ID, WordNetRelation.HYPONYM));
+ assertEquals(List.of("wndb-00001075-v", "wndb-00001171-v"),
+ lexicon.related("wndb-00001324-v", WordNetRelation.HYPONYM));
+ assertEquals(List.of("wndb-00001075-v"),
+ lexicon.related("wndb-00001427-n", WordNetRelation.DERIVATIONALLY_RELATED));
+ }
+
+ @Test
+ void testRelationTargetSharesCanonicalIdInstance() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ final String target = lexicon.synset(DOG_ID).orElseThrow()
+ .related(WordNetRelation.HYPERNYM).get(0);
+ // Pointer targets reuse the id instance from the synset table.
+ assertSame(lexicon.synset(CANID_ID).orElseThrow().id(), target);
+ }
+
+ @Test
+ void testLexicalPointersSurfaceAtSynsetLevel() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ assertEquals(List.of("wndb-00001141-a"),
+ lexicon.related("wndb-00001075-a", WordNetRelation.ANTONYM));
+ assertEquals(List.of("wndb-00001075-a"),
+ lexicon.related("wndb-00001141-a", WordNetRelation.ANTONYM));
+ }
+
+ @Test
+ void testSatelliteNormalizesToAdjectiveAndMarkerIsStripped() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ final Synset large = lexicon.lookup("large", WordNetPOS.ADJECTIVE).get(0);
+ assertEquals(WordNetPOS.ADJECTIVE, large.pos());
+ assertEquals(List.of("wndb-00001211-a"), large.related(WordNetRelation.SIMILAR_TO));
+ // short is stored as short(p); the syntactic marker is not part of the lemma.
+ assertEquals(List.of("short"),
+ lexicon.lookup("short", WordNetPOS.ADJECTIVE).get(0).lemmas());
+ }
+
+ @Test
+ void testVerbGroupPointerMapsToVerbGroup(@TempDir Path tempDir) throws IOException {
+ // The fixture has no $ pointer, so the VERB_GROUP mapping is pinned against a minimal
+ // constructed database whose byte offsets are computed, not hard-coded: every offset field
+ // is exactly eight digits, so the second line's position is independent of the digit values.
+ writeEmptyDb(tempDir, "noun", "adj", "adv");
+ final String template =
+ "00000000 29 v 01 sing 0 001 $ XXXXXXXX v 0000 00 | produce musical tones";
+ final String off2 = String.format(Locale.ROOT, "%08d", template.length() + 1);
+ final String line1 = template.replace("XXXXXXXX", off2);
+ final String line2 = off2 + " 29 v 01 chant 0 001 $ 00000000 v 0000 00 | sing monotonously";
+ Files.writeString(tempDir.resolve("data.verb"), line1 + "\n" + line2 + "\n",
+ StandardCharsets.ISO_8859_1);
+ Files.writeString(tempDir.resolve("index.verb"),
+ "chant v 1 1 $ 1 0 " + off2 + "\nsing v 1 1 $ 1 0 00000000\n",
+ StandardCharsets.ISO_8859_1);
+ final LexicalKnowledgeBase lexicon = WndbReader.read(tempDir);
+ assertEquals(List.of("wndb-" + off2 + "-v"),
+ lexicon.related("wndb-00000000-v", WordNetRelation.VERB_GROUP));
+ assertEquals(List.of("wndb-00000000-v"),
+ lexicon.related("wndb-" + off2 + "-v", WordNetRelation.VERB_GROUP));
+ }
+
+ @Test
+ void testUnknownLemmaOrSynsetIsEmpty() {
+ final LexicalKnowledgeBase lexicon = fixture();
+ assertTrue(lexicon.lookup("zebra", WordNetPOS.NOUN).isEmpty());
+ assertTrue(lexicon.synset("wndb-99999999-n").isEmpty());
+ }
+
+ @Test
+ void testRejectsNullAndMissingDirectory(@TempDir Path tempDir) {
+ assertThrows(IllegalArgumentException.class, () -> WndbReader.read(null));
+ assertThrows(IllegalArgumentException.class,
+ () -> WndbReader.read(tempDir.resolve("absent")));
+ }
+
+ @Test
+ void testRejectsMissingDatabaseFile(@TempDir Path tempDir) throws IOException {
+ copyFixture(tempDir);
+ Files.delete(tempDir.resolve("data.verb"));
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> WndbReader.read(tempDir));
+ assertTrue(e.getMessage().contains("data.verb"));
+ }
+
+ @Test
+ void testRejectsIndexOffsetWithoutDataLine(@TempDir Path tempDir) throws IOException {
+ copyFixture(tempDir);
+ mutate(tempDir, "index.noun", line -> line.startsWith("berry ")
+ ? line.replace("00001564", "00001565") : line);
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> WndbReader.read(tempDir));
+ assertTrue(e.getMessage().contains("berry"));
+ assertTrue(e.getMessage().contains("00001565"));
+ }
+
+ @Test
+ void testRejectsDataOffsetFieldMismatch(@TempDir Path tempDir) throws IOException {
+ copyFixture(tempDir);
+ mutate(tempDir, "data.noun",
+ line -> line.replace("00001503 03 n 01 box", "00001504 03 n 01 box"));
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> WndbReader.read(tempDir));
+ assertTrue(e.getMessage().contains("disagrees"));
+ }
+
+ @Test
+ void testRejectsTruncatedDataLine(@TempDir Path tempDir) throws IOException {
+ copyFixture(tempDir);
+ mutate(tempDir, "data.noun", line -> line.startsWith("00001564")
+ ? line.substring(0, line.indexOf(" 000 |")) : line);
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> WndbReader.read(tempDir));
+ assertTrue(e.getMessage().contains("data.noun"));
+ assertTrue(e.getMessage().contains("Truncated"));
+ }
+
+ @Test
+ void testRejectsUndeclaredPointerSymbol(@TempDir Path tempDir) throws IOException {
+ copyFixture(tempDir);
+ mutate(tempDir, "data.noun", line -> line.replace("001 @ 00001160 n 0000",
+ "001 ? 00001160 n 0000"));
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> WndbReader.read(tempDir));
+ assertTrue(e.getMessage().contains("Undeclared pointer symbol: ?"));
+ }
+
+ @Test
+ void testRejectsPointerToNonexistentSynset(@TempDir Path tempDir) throws IOException {
+ copyFixture(tempDir);
+ mutate(tempDir, "data.noun", line -> line.replace("001 @ 00001160 n 0000",
+ "001 @ 00009999 n 0000"));
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> WndbReader.read(tempDir));
+ assertTrue(e.getMessage().contains("wndb-00009999-n"));
+ }
+
+ @Test
+ void testDanglingPointerErrorNamesPointerLine(@TempDir Path tempDir) throws IOException {
+ // A constructed database with no preamble, so the dangling pointer sits on a known line
+ // and the error message can be pinned to name it.
+ writeEmptyDb(tempDir, "noun", "adj", "adv");
+ Files.writeString(tempDir.resolve("data.verb"),
+ "00000000 29 v 01 sing 0 001 $ 00009999 v 0000 00 | produce musical tones\n",
+ StandardCharsets.ISO_8859_1);
+ Files.writeString(tempDir.resolve("index.verb"), "sing v 1 1 $ 1 0 00000000\n",
+ StandardCharsets.ISO_8859_1);
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> WndbReader.read(tempDir));
+ assertTrue(e.getMessage().contains("wndb-00009999-v"));
+ assertTrue(e.getMessage().contains("line 1"));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ // One field-level rejection per row, each driven by a same-length edit of a single fixture
+ // line so that every following line's byte offset stays valid.
+ "data.noun, 00001564 03 n, 0000156x 03 n, Synset offset must be 8 digits",
+ "data.noun, 00001564 03 n, 00001564 xx n, lex_filenum must be a 2-digit base-10 integer",
+ "data.noun, 00001564 03 n, 00001564 99 n, lex_filenum must be between 00 and 44",
+ "data.noun, 00001075 03 n, 00001075 03 v, Synset type v does not belong in",
+ "data.noun, n 01 box, n 0z box, w_cnt must be a 2-digit base-16 integer",
+ "data.noun, n 01 man, n 00 man, Word count must be at least 1",
+ "data.noun, n 01 box 0 000 |, n 01 box 0 -01 |, Pointer count must not be negative",
+ "data.noun, 00001160 n 0000, 00001160 q 0000, Pointer pos must be one of",
+ "data.noun, 00001160 n 0000, 00001160 n zzzz, pointer source/target must be a 4-digit base-16 integer",
+ "data.noun, 00001160 n 0000, 00001160 n 0300, Pointer source word 3 exceeds word count 2",
+ "data.noun, 00001160 n 0000, 00001160 n 0102, Pointer target word 2 exceeds target word count 1",
+ "data.noun, 00001160 n 0000, 00001160 n 0100, must both be zero or nonzero",
+ "data.verb, n 0101 01 + 02, n 0101 -1 + 02, Verb frame count must not be negative",
+ "data.verb, 01 + 02 00 |, 01 ? 02 00 |, Expected + before a verb frame",
+ "data.verb, 01 + 02 00 |, 01 + xx 00 |, f_num must be a 2-digit base-10 integer",
+ "data.verb, 01 + 02 00 |, 01 + 36 00 |, f_num must be between 01 and 35",
+ "data.verb, 01 + 02 00 |, 01 + 02 xx |, w_num must be a 2-digit base-16 integer",
+ "data.verb, 01 + 02 00 |, 01 + 02 02 |, Verb frame word 2 exceeds word count 1",
+ "data.noun, | a domesticated canid, ! a domesticated canid, Expected the | gloss separator",
+ "data.adj, short(p), short(x), Unknown syntactic marker on word: short(x)",
+ "data.noun, dog 0, dog(p) 0, Syntactic marker (p) is only valid in data.adj",
+ "index.noun, berry n 1, berry v 1, Index pos v does not belong in",
+ "index.noun, berry n 1 0 1 0, berry n +1 0 1 0, synset count is not an unsigned decimal integer: +1",
+ "index.noun, berry n 1 0 1 0, berry n 1 -1 1 0, Pointer count must not be negative",
+ "index.noun, dog n 1 1 @ 1 0, dog n 1 1 ? 1 0, Undeclared pointer symbol: ?",
+ "index.noun, dog n 1 1 @ 1 0, dog n 1 2 @ @ 1 0, Duplicate pointer symbol: @",
+ "index.noun, berry n 1 0 1 0, berry n 1 0 x 0, sense count is not an unsigned decimal integer",
+ "index.noun, berry n 1 0 1 0, berry n 1 0 1 -1, Tagged-sense count must not be negative",
+ "index.noun, berry n 1 0 1 0, berry n 1 0 2 0, Sense count 2 does not match synset count 1",
+ "index.noun, berry n 1 0 1 0, berry n 1 0 1 2, Tagged-sense count 2 exceeds sense count 1",
+ "index.noun, berry n 1 0 1 0, berry n 2147483647 0 2147483647 0, "
+ + "Synset count 2147483647 exceeds the available offset fields",
+ "index.noun, 00001427 00001669, 00001427 00001427, Duplicate synset offset 00001427",
+ "index.noun, 00001564 , 00001564 extra , Unexpected field after synset offsets: extra",
+ })
+ void testRejectsMalformedField(String fileName, String find, String replacement,
+ String expected, @TempDir Path tempDir) throws IOException {
+ copyFixture(tempDir);
+ mutate(tempDir, fileName, line -> line.replace(find, replacement));
+ final InvalidFormatException e =
+ assertThrows(InvalidFormatException.class, () -> WndbReader.read(tempDir));
+ assertTrue(e.getMessage().contains(expected), e.getMessage());
+ }
+
+ private static void writeEmptyDb(Path directory, String... suffixes) throws IOException {
+ for (final String suffix : suffixes) {
+ Files.writeString(directory.resolve("data." + suffix), "");
+ Files.writeString(directory.resolve("index." + suffix), "");
+ }
+ }
+
+ private static void copyFixture(Path target) throws IOException {
+ try (var files = Files.list(fixtureDirectory())) {
+ for (final Path file : files.toList()) {
+ Files.copy(file, target.resolve(file.getFileName().toString()));
+ }
+ }
+ }
+
+ // Applies a line transformation to one fixture file. A same-length edit keeps every following
+ // line's byte offset valid; an edit that changes a line's length is only safe when the reader
+ // is expected to fail on that line itself, before it reads the ones after it.
+ private static void mutate(Path directory, String fileName, UnaryOperator edit)
+ throws IOException {
+ final Path file = directory.resolve(fileName);
+ final List lines = Files.readAllLines(file, StandardCharsets.ISO_8859_1);
+ final StringBuilder out = new StringBuilder();
+ for (final String line : lines) {
+ out.append(edit.apply(line)).append('\n');
+ }
+ Files.writeString(file, out.toString(), StandardCharsets.ISO_8859_1);
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WordNetUsageExampleTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WordNetUsageExampleTest.java
new file mode 100644
index 0000000000..70d568506b
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WordNetUsageExampleTest.java
@@ -0,0 +1,132 @@
+/*
+ * 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.wordnet;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import opennlp.tools.wordnet.LexicalKnowledgeBase;
+import opennlp.tools.wordnet.Synset;
+import opennlp.tools.wordnet.WordNetPOS;
+import opennlp.tools.wordnet.WordNetRelation;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/** Runs the examples from the WordNet manual chapter against the test fixtures. */
+public class WordNetUsageExampleTest {
+
+ /**
+ * Load, lookup, and Morphy lemmatize as the chapter shows.
+ */
+ @Test
+ void testLoadLookupAndLemmatize() throws IOException {
+ final LexicalKnowledgeBase lexicon = WnLmfReaderTest.fixture();
+ final List senses = lexicon.lookup("dog", WordNetPOS.NOUN);
+ assertEquals(1, senses.size());
+ assertEquals("mini-n1", senses.get(0).id());
+ assertEquals(List.of("dog", "domestic dog"), senses.get(0).lemmas());
+ assertEquals("a domesticated canid", senses.get(0).gloss());
+
+ final MorphyLemmatizer lemmatizer = new MorphyLemmatizer(lexicon,
+ MorphyExceptions.load(WndbReaderTest.fixtureDirectory()));
+ assertEquals("mouse",
+ lemmatizer.lemmatize(new String[] {"mice"}, new String[] {"NNS"})[0]);
+ assertEquals("dog",
+ lemmatizer.lemmatize(new String[] {"dogs"}, new String[] {"NNS"})[0]);
+ }
+
+ /**
+ * Loads through the Path entry points shown in the manual:
+ * {@code WnLmfReader.read(Path)} on a file named {@code en-wordnet.xml} (a temp-dir copy of
+ * the fixture) and {@code WndbReader.read(Path)} on a WNDB {@code dict} directory.
+ */
+ @Test
+ void testLoadFromPath(@TempDir Path tempDir) throws IOException {
+ final Path file = tempDir.resolve("en-wordnet.xml");
+ try (InputStream in = WnLmfReaderTest.class.getResourceAsStream("mini-wn-lmf.xml")) {
+ assertNotNull(in, "Fixture mini-wn-lmf.xml must be on the test classpath");
+ Files.copy(in, file);
+ }
+ final LexicalKnowledgeBase lexicon = WnLmfReader.read(file);
+ assertEquals("mini-n1", lexicon.lookup("dog", WordNetPOS.NOUN).get(0).id());
+
+ final LexicalKnowledgeBase wndbLexicon = WndbReader.read(WndbReaderTest.fixtureDirectory());
+ assertEquals(List.of("dog", "domestic dog"),
+ wndbLexicon.lookup("dog", WordNetPOS.NOUN).get(0).lemmas());
+ }
+
+ /** Load and select one language from a multi-lexicon resource as the chapter shows. */
+ @Test
+ void testLoadMultilingualResource() throws IOException {
+ try (InputStream in = WordNetUsageExampleTest.class
+ .getResourceAsStream("omw-multilingual.xml")) {
+ assertNotNull(in, "Fixture omw-multilingual.xml must be on the test classpath");
+ final WnLmfResource resource = WnLmfReader.readResource(in, "omw-multilingual.xml");
+ final WnLmfLexicon spanish = resource.lexicon("omw-es").orElseThrow();
+
+ assertEquals("es", spanish.language());
+ final WnLmfDependency englishBase = spanish.dependencies().get(0);
+ assertEquals("omw-en", englishBase.ref());
+ assertEquals("2.0", englishBase.version());
+ assertEquals("omw-es-02084071-n",
+ spanish.knowledgeBase().lookup("perro", WordNetPOS.NOUN).get(0).id());
+ }
+ }
+
+ /**
+ * Follow the hypernym relation from the first sense of dog as the chapter's relation
+ * navigation listing shows.
+ */
+ @Test
+ void testNavigateRelations() {
+ final LexicalKnowledgeBase lexicon = WnLmfReaderTest.fixture();
+ final Synset dog = lexicon.lookup("dog", WordNetPOS.NOUN).get(0);
+ final List parents = dog.related(WordNetRelation.HYPERNYM);
+ assertEquals(List.of("mini-n2"), parents);
+
+ final Synset parent = lexicon.synset(parents.get(0)).orElseThrow();
+ assertEquals(List.of("canid"), parent.lemmas());
+ assertEquals("a carnivorous mammal with nonretractile claws", parent.gloss());
+
+ assertEquals(List.of("mini-n1"), lexicon.related("mini-n2", WordNetRelation.HYPONYM));
+ }
+
+ /** Navigate WN-LMF semantic-role relations as the extended relation example shows. */
+ @Test
+ void testNavigateSemanticRoleRelations() throws IOException {
+ try (InputStream in = WordNetUsageExampleTest.class
+ .getResourceAsStream("relation-usage-wn-lmf.xml")) {
+ assertNotNull(in, "Fixture relation-usage-wn-lmf.xml must be on the test classpath");
+ final LexicalKnowledgeBase lexicon =
+ WnLmfReader.read(in, "relation-usage-wn-lmf.xml");
+ final Synset purchase = lexicon.lookup("purchase", WordNetPOS.VERB).get(0);
+
+ final String agentId = purchase.related(WordNetRelation.INVOLVED_AGENT).get(0);
+ final String instrumentId = purchase.related(WordNetRelation.INVOLVED_INSTRUMENT).get(0);
+ assertEquals(List.of("buyer"), lexicon.synset(agentId).orElseThrow().lemmas());
+ assertEquals(List.of("payment card"),
+ lexicon.synset(instrumentId).orElseThrow().lemmas());
+ }
+ }
+}
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wn-lmf.xml b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wn-lmf.xml
new file mode 100644
index 0000000000..103e77d354
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wn-lmf.xml
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+ dog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ a domesticated canid
+
+ the dog barked
+
+
+ a carnivorous mammal with nonretractile claws
+
+
+
+ a small rodent with a long tail
+
+
+
+ a gnawing mammal with chisel teeth
+
+
+
+ an act of running at speed
+
+
+ a rigid rectangular container
+
+
+ a small juicy fruit
+
+
+ an adult male person
+
+
+ a score made in baseball
+
+
+ move fast on foot
+
+
+
+ move at a regular pace
+
+
+
+ change location or position
+
+
+ change position in space
+
+
+
+
+ of great height
+
+
+ of small height
+
+
+ of great size
+
+
+
+ above average in size
+
+
+
+ with speed
+
+
+ in a good or proper manner
+
+
+
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/.gitattributes b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/.gitattributes
new file mode 100644
index 0000000000..2d92ab66c5
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/.gitattributes
@@ -0,0 +1,2 @@
+# WNDB data files contain byte offsets and must retain LF endings and trailing spaces.
+* -text whitespace=-trailing-space
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/adj.exc b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/adj.exc
new file mode 100644
index 0000000000..404a2e4ddb
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/adj.exc
@@ -0,0 +1 @@
+better good
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/adv.exc b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/adv.exc
new file mode 100644
index 0000000000..c43a2cd529
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/adv.exc
@@ -0,0 +1 @@
+best well
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.adj b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.adj
new file mode 100644
index 0000000000..1340d16a45
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.adj
@@ -0,0 +1,22 @@
+ 1 Licensed to the Apache Software Foundation (ASF) under one or more
+ 2 contributor license agreements. See the NOTICE file distributed with
+ 3 this work for additional information regarding copyright ownership.
+ 4 The ASF licenses this file to You under the Apache License, Version 2.0
+ 5 (the "License"); you may not use this file except in compliance with
+ 6 the License. You may obtain a copy of the License at
+ 7
+ 8 http://www.apache.org/licenses/LICENSE-2.0
+ 9
+ 10 Unless required by applicable law or agreed to in writing, software
+ 11 distributed under the License is distributed on an "AS IS" BASIS,
+ 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ 13 See the License for the specific language governing permissions and
+ 14 limitations under the License.
+ 15
+ 16 Project-authored miniature WNDB fixture mirroring mini-wn-lmf.xml.
+ 17 License preamble lines begin with two spaces, as in released WNDB files,
+ 18 so readers skip them; data line offsets include this preamble.
+00001075 00 a 01 tall 0 001 ! 00001141 a 0101 | of great height
+00001141 00 a 01 short(p) 0 001 ! 00001075 a 0101 | of small height
+00001211 00 a 01 big 0 001 & 00001274 a 0000 | of great size
+00001274 00 s 01 large 0 001 & 00001211 a 0000 | above average in size
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.adv b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.adv
new file mode 100644
index 0000000000..732c21dfd5
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.adv
@@ -0,0 +1,20 @@
+ 1 Licensed to the Apache Software Foundation (ASF) under one or more
+ 2 contributor license agreements. See the NOTICE file distributed with
+ 3 this work for additional information regarding copyright ownership.
+ 4 The ASF licenses this file to You under the Apache License, Version 2.0
+ 5 (the "License"); you may not use this file except in compliance with
+ 6 the License. You may obtain a copy of the License at
+ 7
+ 8 http://www.apache.org/licenses/LICENSE-2.0
+ 9
+ 10 Unless required by applicable law or agreed to in writing, software
+ 11 distributed under the License is distributed on an "AS IS" BASIS,
+ 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ 13 See the License for the specific language governing permissions and
+ 14 limitations under the License.
+ 15
+ 16 Project-authored miniature WNDB fixture mirroring mini-wn-lmf.xml.
+ 17 License preamble lines begin with two spaces, as in released WNDB files,
+ 18 so readers skip them; data line offsets include this preamble.
+00001075 02 r 01 quickly 0 000 | with speed
+00001121 02 r 01 well 0 000 | in a good or proper manner
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.noun b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.noun
new file mode 100644
index 0000000000..0598111bf1
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.noun
@@ -0,0 +1,27 @@
+ 1 Licensed to the Apache Software Foundation (ASF) under one or more
+ 2 contributor license agreements. See the NOTICE file distributed with
+ 3 this work for additional information regarding copyright ownership.
+ 4 The ASF licenses this file to You under the Apache License, Version 2.0
+ 5 (the "License"); you may not use this file except in compliance with
+ 6 the License. You may obtain a copy of the License at
+ 7
+ 8 http://www.apache.org/licenses/LICENSE-2.0
+ 9
+ 10 Unless required by applicable law or agreed to in writing, software
+ 11 distributed under the License is distributed on an "AS IS" BASIS,
+ 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ 13 See the License for the specific language governing permissions and
+ 14 limitations under the License.
+ 15
+ 16 Project-authored miniature WNDB fixture mirroring mini-wn-lmf.xml.
+ 17 License preamble lines begin with two spaces, as in released WNDB files,
+ 18 so readers skip them; data line offsets include this preamble.
+00001075 03 n 02 dog 0 domestic_dog 0 001 @ 00001160 n 0000 | a domesticated canid
+00001160 03 n 01 canid 0 001 ~ 00001075 n 0000 | a carnivorous mammal with nonretractile claws
+00001257 03 n 01 mouse 0 001 @ 00001340 n 0000 | a small rodent with a long tail
+00001340 03 n 01 rodent 0 001 ~ 00001257 n 0000 | a gnawing mammal with chisel teeth
+00001427 03 n 01 run 0 001 + 00001075 v 0101 | an act of running at speed
+00001503 03 n 01 box 0 000 | a rigid rectangular container
+00001564 03 n 01 berry 0 000 | a small juicy fruit
+00001617 03 n 01 man 0 000 | an adult male person
+00001669 03 n 01 run 0 000 | a score made in baseball
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.verb b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.verb
new file mode 100644
index 0000000000..048546ed71
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/data.verb
@@ -0,0 +1,22 @@
+ 1 Licensed to the Apache Software Foundation (ASF) under one or more
+ 2 contributor license agreements. See the NOTICE file distributed with
+ 3 this work for additional information regarding copyright ownership.
+ 4 The ASF licenses this file to You under the Apache License, Version 2.0
+ 5 (the "License"); you may not use this file except in compliance with
+ 6 the License. You may obtain a copy of the License at
+ 7
+ 8 http://www.apache.org/licenses/LICENSE-2.0
+ 9
+ 10 Unless required by applicable law or agreed to in writing, software
+ 11 distributed under the License is distributed on an "AS IS" BASIS,
+ 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ 13 See the License for the specific language governing permissions and
+ 14 limitations under the License.
+ 15
+ 16 Project-authored miniature WNDB fixture mirroring mini-wn-lmf.xml.
+ 17 License preamble lines begin with two spaces, as in released WNDB files,
+ 18 so readers skip them; data line offsets include this preamble.
+00001075 29 v 01 run 0 002 @ 00001324 v 0000 + 00001427 n 0101 01 + 02 00 | move fast on foot
+00001171 29 v 01 walk 0 001 @ 00001324 v 0000 01 + 02 00 | move at a regular pace
+00001255 29 v 01 go 0 000 01 + 02 00 | change location or position
+00001324 29 v 01 move 0 002 ~ 00001075 v 0000 ~ 00001171 v 0000 01 + 02 00 | change position in space
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.adj b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.adj
new file mode 100644
index 0000000000..827a988a7d
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.adj
@@ -0,0 +1,22 @@
+ 1 Licensed to the Apache Software Foundation (ASF) under one or more
+ 2 contributor license agreements. See the NOTICE file distributed with
+ 3 this work for additional information regarding copyright ownership.
+ 4 The ASF licenses this file to You under the Apache License, Version 2.0
+ 5 (the "License"); you may not use this file except in compliance with
+ 6 the License. You may obtain a copy of the License at
+ 7
+ 8 http://www.apache.org/licenses/LICENSE-2.0
+ 9
+ 10 Unless required by applicable law or agreed to in writing, software
+ 11 distributed under the License is distributed on an "AS IS" BASIS,
+ 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ 13 See the License for the specific language governing permissions and
+ 14 limitations under the License.
+ 15
+ 16 Project-authored miniature WNDB fixture mirroring mini-wn-lmf.xml.
+ 17 License preamble lines begin with two spaces, as in released WNDB files,
+ 18 so readers skip them; data line offsets include this preamble.
+big a 1 1 & 1 0 00001211
+large a 1 1 & 1 0 00001274
+short a 1 1 ! 1 0 00001141
+tall a 1 1 ! 1 0 00001075
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.adv b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.adv
new file mode 100644
index 0000000000..da20fe1193
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.adv
@@ -0,0 +1,20 @@
+ 1 Licensed to the Apache Software Foundation (ASF) under one or more
+ 2 contributor license agreements. See the NOTICE file distributed with
+ 3 this work for additional information regarding copyright ownership.
+ 4 The ASF licenses this file to You under the Apache License, Version 2.0
+ 5 (the "License"); you may not use this file except in compliance with
+ 6 the License. You may obtain a copy of the License at
+ 7
+ 8 http://www.apache.org/licenses/LICENSE-2.0
+ 9
+ 10 Unless required by applicable law or agreed to in writing, software
+ 11 distributed under the License is distributed on an "AS IS" BASIS,
+ 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ 13 See the License for the specific language governing permissions and
+ 14 limitations under the License.
+ 15
+ 16 Project-authored miniature WNDB fixture mirroring mini-wn-lmf.xml.
+ 17 License preamble lines begin with two spaces, as in released WNDB files,
+ 18 so readers skip them; data line offsets include this preamble.
+quickly r 1 0 1 0 00001075
+well r 1 0 1 0 00001121
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.noun b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.noun
new file mode 100644
index 0000000000..41a8a4317b
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.noun
@@ -0,0 +1,27 @@
+ 1 Licensed to the Apache Software Foundation (ASF) under one or more
+ 2 contributor license agreements. See the NOTICE file distributed with
+ 3 this work for additional information regarding copyright ownership.
+ 4 The ASF licenses this file to You under the Apache License, Version 2.0
+ 5 (the "License"); you may not use this file except in compliance with
+ 6 the License. You may obtain a copy of the License at
+ 7
+ 8 http://www.apache.org/licenses/LICENSE-2.0
+ 9
+ 10 Unless required by applicable law or agreed to in writing, software
+ 11 distributed under the License is distributed on an "AS IS" BASIS,
+ 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ 13 See the License for the specific language governing permissions and
+ 14 limitations under the License.
+ 15
+ 16 Project-authored miniature WNDB fixture mirroring mini-wn-lmf.xml.
+ 17 License preamble lines begin with two spaces, as in released WNDB files,
+ 18 so readers skip them; data line offsets include this preamble.
+berry n 1 0 1 0 00001564
+box n 1 0 1 0 00001503
+canid n 1 1 ~ 1 0 00001160
+dog n 1 1 @ 1 0 00001075
+domestic_dog n 1 1 @ 1 0 00001075
+man n 1 0 1 0 00001617
+mouse n 1 1 @ 1 0 00001257
+rodent n 1 1 ~ 1 0 00001340
+run n 2 1 + 2 1 00001427 00001669
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.verb b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.verb
new file mode 100644
index 0000000000..2b380478de
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/index.verb
@@ -0,0 +1,22 @@
+ 1 Licensed to the Apache Software Foundation (ASF) under one or more
+ 2 contributor license agreements. See the NOTICE file distributed with
+ 3 this work for additional information regarding copyright ownership.
+ 4 The ASF licenses this file to You under the Apache License, Version 2.0
+ 5 (the "License"); you may not use this file except in compliance with
+ 6 the License. You may obtain a copy of the License at
+ 7
+ 8 http://www.apache.org/licenses/LICENSE-2.0
+ 9
+ 10 Unless required by applicable law or agreed to in writing, software
+ 11 distributed under the License is distributed on an "AS IS" BASIS,
+ 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ 13 See the License for the specific language governing permissions and
+ 14 limitations under the License.
+ 15
+ 16 Project-authored miniature WNDB fixture mirroring mini-wn-lmf.xml.
+ 17 License preamble lines begin with two spaces, as in released WNDB files,
+ 18 so readers skip them; data line offsets include this preamble.
+go v 1 0 1 0 00001255
+move v 1 1 ~ 1 0 00001324
+run v 1 2 @ + 1 1 00001075
+walk v 1 1 @ 1 0 00001171
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/noun.exc b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/noun.exc
new file mode 100644
index 0000000000..71b2d55fb6
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/noun.exc
@@ -0,0 +1,4 @@
+axes axis ax
+men man
+mice mouse
+oxen ox
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/verb.exc b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/verb.exc
new file mode 100644
index 0000000000..486d0c7851
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/verb.exc
@@ -0,0 +1,4 @@
+gone go
+ran run
+running run
+went go
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/omw-multilingual.xml b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/omw-multilingual.xml
new file mode 100644
index 0000000000..5f4a75ee33
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/omw-multilingual.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/relation-usage-wn-lmf.xml b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/relation-usage-wn-lmf.xml
new file mode 100644
index 0000000000..9ca0b635b1
--- /dev/null
+++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/relation-usage-wn-lmf.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ obtain something by paying for it
+
+
+
+
+ a person who buys something
+
+
+ a card used to make a payment
+
+
+
diff --git a/opennlp-extensions/pom.xml b/opennlp-extensions/pom.xml
index 9afcd3fe3c..e43a2cadc5 100644
--- a/opennlp-extensions/pom.xml
+++ b/opennlp-extensions/pom.xml
@@ -41,6 +41,7 @@
opennlp-morfologik
opennlp-spellcheck
opennlp-uima
+ opennlp-wordnet
-
\ No newline at end of file
+
diff --git a/pom.xml b/pom.xml
index 093d7cd503..226c208353 100644
--- a/pom.xml
+++ b/pom.xml
@@ -216,6 +216,12 @@
${project.version}