diff --git a/dev/test-omw-wordnets.sh b/dev/test-omw-wordnets.sh new file mode 100755 index 0000000000..d90b336c20 --- /dev/null +++ b/dev/test-omw-wordnets.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# 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. + +set -euo pipefail + +readonly RELEASE_URL="https://github.com/omwn/omw-data/releases/download/v2.0" +readonly IT_SHA512="d0ed09eaa6617509a7c8a1162e92d8085570328f5f773a108a168d7c517b02c8a1a79fe81297b7e94722b480b22f44f34419c46675f0c3e3af253504c2c5b380" +readonly ES_SHA512="86851763f10cf9ba1c5ea42e8c09bcbff7954aea22c62fbd229bcf546f236de4d67251582ecfd7956e8dae22e975fdf1d5bfa3fd7e2fbf4d901751c89bc0ca66" +readonly SV_SHA512="897a79c6a6ec43c10024c6ee55aac886bd27182e28127ef1248cc8748c1189e573b05b0b2cd9ada91756f0527b25cbac9a49b88a1f13a897f5442da7f0656c13" + +fixture_dir=$(mktemp -d "${TMPDIR:-/tmp}/opennlp-omw.XXXXXXXX") +trap 'rm -rf -- "$fixture_dir"' EXIT + +fetch() { + local language=$1 + local expected=$2 + local archive="$fixture_dir/omw-$language-2.0.tar.xz" + curl --fail --location --silent --show-error \ + --connect-timeout 15 --max-time 120 \ + --output "$archive" "$RELEASE_URL/omw-$language-2.0.tar.xz" + local actual + actual=$(sha512sum "$archive" | cut -d' ' -f1) + if [[ "$actual" != "$expected" ]]; then + echo "SHA-512 mismatch for omw-$language-2.0.tar.xz" >&2 + exit 1 + fi + tar -xJf "$archive" -C "$fixture_dir" +} + +fetch it "$IT_SHA512" +fetch es "$ES_SHA512" +fetch sv "$SV_SHA512" + +repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$repo_dir" +./mvnw -pl opennlp-extensions/opennlp-wordnet -am \ + -Dopennlp.forkCount=1 -Drat.skip=true \ + -Dtest=WnLmfOmwIntegrationTest -Dsurefire.failIfNoSpecifiedTests=false \ + -Dopennlp.wordnet.omwDir="$fixture_dir" test diff --git a/opennlp-api/src/main/java/opennlp/tools/wordnet/LexicalKnowledgeBase.java b/opennlp-api/src/main/java/opennlp/tools/wordnet/LexicalKnowledgeBase.java new file mode 100644 index 0000000000..1597b898be --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/wordnet/LexicalKnowledgeBase.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.wordnet; + +import java.util.List; +import java.util.Optional; + +/** + * Lemma and synset lookup over a loaded lexical-semantic resource in the WordNet family. Synset + * identifiers are opaque and source-qualified (see {@link Synset#id()}). Lookups return matches + * in the source's sense order. Lookup results are non-{@code null} lists. + * + *

How a queried lemma is matched against the source's written forms is implementation + * specific and documented there. Returned {@link Synset#lemmas() lemmas} preserve the source's + * written forms, with spaces in multiword lemmas.

+ * + *

Thread safety is implementation specific.

+ * + * @since 3.0.0 + */ +public interface LexicalKnowledgeBase { + + /** + * Finds the synsets containing a lemma with a part of speech, in the source's sense order + * (the most salient sense first when the source ranks senses). + * + * @param lemma The lemma to look up. Must not be {@code null}. + * @param pos The part of speech to look it up as. Must not be {@code null}. + * @return The matching synsets, never {@code null}; empty when the lexicon does not contain + * the lemma with that part of speech. + * @throws IllegalArgumentException Thrown if {@code lemma} or {@code pos} is {@code null}. + */ + List lookup(String lemma, WordNetPOS pos); + + /** + * Finds a synset by its opaque identifier. + * + * @param synsetId A synset identifier returned by this lexicon. Must not be {@code null}. + * @return The synset, or empty when this lexicon has no synset with that identifier. + * @throws IllegalArgumentException Thrown if {@code synsetId} is {@code null}. + */ + Optional synset(String synsetId); + + /** + * Navigates one typed relation from a synset. + * + * @param synsetId The source synset identifier. Must not be {@code null}. + * @param relation The relation type to follow. Must not be {@code null}. + * @return The target synset ids in source order, never {@code null}; empty when the synset is + * unknown or has no relation of that type. + * @throws IllegalArgumentException Thrown if {@code synsetId} or {@code relation} is + * {@code null}. + */ + default List related(String synsetId, WordNetRelation relation) { + if (synsetId == null) { + throw new IllegalArgumentException("synsetId must not be null"); + } + if (relation == null) { + throw new IllegalArgumentException("relation must not be null"); + } + return synset(synsetId).map(s -> s.related(relation)).orElse(List.of()); + } + + /** + * Tests whether the lexicon contains a lemma with a part of speech. The default implementation + * delegates to {@link #lookup(String, WordNetPOS)}. + * + * @param lemma The lemma to test. Must not be {@code null}. + * @param pos The part of speech to test it as. Must not be {@code null}. + * @return {@code true} if the lexicon contains the lemma with that part of speech. + * @throws IllegalArgumentException Thrown if {@code lemma} or {@code pos} is {@code null}. + */ + default boolean contains(String lemma, WordNetPOS pos) { + if (lemma == null) { + throw new IllegalArgumentException("lemma must not be null"); + } + if (pos == null) { + throw new IllegalArgumentException("pos must not be null"); + } + return !lookup(lemma, pos).isEmpty(); + } +} diff --git a/opennlp-api/src/main/java/opennlp/tools/wordnet/Synset.java b/opennlp-api/src/main/java/opennlp/tools/wordnet/Synset.java new file mode 100644 index 0000000000..7138c0ae25 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/wordnet/Synset.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.wordnet; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import opennlp.tools.commons.ThreadSafe; + +/** + * One synonym set: a single lexicalized concept with its member lemmas, gloss, and typed + * relations to other synsets. + * + *

The {@link #id() id} is an opaque, source-qualified string created by the reader that + * produced the synset; consumers must not parse it, only pass it back to + * {@link LexicalKnowledgeBase#synset(String)} and compare it for equality. Relations map each + * {@link WordNetRelation} present on this synset to the target synset ids in source order.

+ * + *

Instances are immutable and thread-safe: the list and map components are defensively + * copied to immutable views at construction.

+ * + * @param id The opaque, source-qualified synset identifier. Must not be {@code null} or + * empty. + * @param pos The part of speech. Must not be {@code null}. + * @param lemmas The member lemmas in source order, human-readable (multiword lemmas use + * spaces, not the underscores some formats store). Must not be {@code null} and + * must not contain {@code null} or empty elements. May be empty for an + * unlexicalized concept retained as a relation target. + * @param gloss The definition text, possibly empty when the source has none. Must not be + * {@code null}. + * @param relations The typed relations, each mapping to the target synset ids in source order. + * Must not be {@code null}; keys must not be {@code null}; each value must be + * a non-empty list of non-{@code null}, non-empty target ids. + * @since 3.0.0 + */ +@ThreadSafe +public record Synset( + String id, + WordNetPOS pos, + List lemmas, + String gloss, + Map> relations) { + + private static final String RELATION_PREFIX = "Relation "; + + /** + * Creates a synset. + * + * @throws IllegalArgumentException Thrown if any component violates its documented constraint. + */ + public Synset { + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("id must not be null or empty"); + } + if (pos == null) { + throw new IllegalArgumentException("pos must not be null"); + } + if (lemmas == null) { + throw new IllegalArgumentException("lemmas must not be null for synset " + id); + } + for (final String lemma : lemmas) { + if (lemma == null || lemma.isEmpty()) { + throw new IllegalArgumentException( + "lemmas must not contain a null or empty element for synset " + id); + } + } + if (gloss == null) { + throw new IllegalArgumentException("gloss must not be null for synset " + id); + } + if (relations == null) { + throw new IllegalArgumentException("relations must not be null for synset " + id); + } + final Map> copiedRelations = + new EnumMap<>(WordNetRelation.class); + for (final Map.Entry> relation : relations.entrySet()) { + if (relation.getKey() == null) { + throw new IllegalArgumentException("relations must not contain a null key for synset " + id); + } + final List targets = relation.getValue(); + if (targets == null || targets.isEmpty()) { + throw new IllegalArgumentException(RELATION_PREFIX + relation.getKey() + + " must map to a non-empty target list for synset " + id); + } + for (final String target : targets) { + if (target == null || target.isEmpty()) { + throw new IllegalArgumentException(RELATION_PREFIX + relation.getKey() + + " must not contain a null or empty target id for synset " + id); + } + } + copiedRelations.put(relation.getKey(), List.copyOf(targets)); + } + lemmas = List.copyOf(lemmas); + relations = Collections.unmodifiableMap(copiedRelations); + } + + /** + * Finds the target synset ids of one relation type. + * + * @param relation The relation type. Must not be {@code null}. + * @return The target synset ids in source order, never {@code null}; empty when this synset + * has no relation of that type. + * @throws IllegalArgumentException Thrown if {@code relation} is {@code null}. + */ + public List related(WordNetRelation relation) { + if (relation == null) { + throw new IllegalArgumentException("relation must not be null"); + } + final List targets = relations.get(relation); + return targets == null ? List.of() : targets; + } +} diff --git a/opennlp-api/src/main/java/opennlp/tools/wordnet/WordNetPOS.java b/opennlp-api/src/main/java/opennlp/tools/wordnet/WordNetPOS.java new file mode 100644 index 0000000000..fc9ef83c61 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/wordnet/WordNetPOS.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.wordnet; + +/** + * The four parts of speech a wordnet-style lexicon distinguishes. + * + *

The enum does not expose the single-letter codes used by file formats. Each reader maps its + * format's codes to these values. Adjective satellites normalize to + * {@link #ADJECTIVE}, with the cluster structure preserved through + * {@link WordNetRelation#SIMILAR_TO}.

+ * + * @since 3.0.0 + */ +public enum WordNetPOS { + + /** Nouns. */ + NOUN, + + /** Verbs. */ + VERB, + + /** Adjectives, including adjective satellites. */ + ADJECTIVE, + + /** Adverbs. */ + ADVERB +} diff --git a/opennlp-api/src/main/java/opennlp/tools/wordnet/WordNetRelation.java b/opennlp-api/src/main/java/opennlp/tools/wordnet/WordNetRelation.java new file mode 100644 index 0000000000..842560a420 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/wordnet/WordNetRelation.java @@ -0,0 +1,355 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.wordnet; + +/** + * The typed relations between {@link Synset synsets} in a WordNet-style lexicon. Readers map + * their source format's relation names onto these values. + * + *

Relations that a source format draws between individual word senses (antonymy and + * derivation, for example) are represented at the synset level: the synset containing the source + * sense has the relation to the synset containing the target sense.

+ * + *

The vocabulary includes every typed synset and sense relation in the + * Global WordNet Association + * WN-LMF 1.4 format. Format-specific readers document any contextual mapping.

+ * + * @since 3.0.0 + */ +public enum WordNetRelation { + + /** Opposition in meaning, for example between the adjectives for tall and short. */ + ANTONYM, + + /** The more general concept: a dog is a kind of canid. */ + HYPERNYM, + + /** The class a named instance belongs to: a specific river is an instance of river. */ + INSTANCE_HYPERNYM, + + /** The more specific concept: canid has the hyponym dog. */ + HYPONYM, + + /** A named instance of this class. */ + INSTANCE_HYPONYM, + + /** The group this synset is a member of. */ + MEMBER_HOLONYM, + + /** The whole this synset is a substance of. */ + SUBSTANCE_HOLONYM, + + /** The whole this synset is a part of. */ + PART_HOLONYM, + + /** A member of this group. */ + MEMBER_MERONYM, + + /** A substance this synset is made of. */ + SUBSTANCE_MERONYM, + + /** A part of this synset. */ + PART_MERONYM, + + /** The attribute a value expresses, or a value of this attribute. */ + ATTRIBUTE, + + /** A derivationally related form, typically across parts of speech. */ + DERIVATIONALLY_RELATED, + + /** An action entailed by this verb: snoring entails sleeping. */ + ENTAILMENT, + + /** The verb that entails this one; the inverse of {@link #ENTAILMENT}. */ + ENTAILED_BY, + + /** An effect this verb causes. */ + CAUSE, + + /** The cause of this verb; the inverse of {@link #CAUSE}. */ + CAUSED_BY, + + /** A related synset referenced by a see-also relation. */ + ALSO_SEE, + + /** A verb sense grouped with this one. */ + VERB_GROUP, + + /** A satellite or head adjective in the same similarity cluster. */ + SIMILAR_TO, + + /** The verb an adjective is the participle of. */ + PARTICIPLE, + + /** + * The noun an adjective pertains to, or the adjective an adverb derives from. The source + * formats use one pointer for both directions of derivation, so this value does too. + */ + PERTAINYM, + + /** The topical domain this synset belongs to. */ + DOMAIN_TOPIC, + + /** A synset belonging to this topical domain. */ + MEMBER_OF_DOMAIN_TOPIC, + + /** The regional domain this synset belongs to. */ + DOMAIN_REGION, + + /** A synset belonging to this regional domain. */ + MEMBER_OF_DOMAIN_REGION, + + /** The usage domain this synset belongs to, for example slang or archaism. */ + DOMAIN_USAGE, + + /** A synset belonging to this usage domain. */ + MEMBER_OF_DOMAIN_USAGE, + + /** An entity that intentionally performs or initiates an event. */ + AGENT, + + /** A state that this concept enters or occupies. */ + BE_IN_STATE, + + /** A semantic class that classifies this concept. */ + CLASSIFIED_BY, + + /** A concept classified by this semantic class. */ + CLASSIFIES, + + /** An instrument that participates with this agent. */ + CO_AGENT_INSTRUMENT, + + /** A patient that participates with this agent. */ + CO_AGENT_PATIENT, + + /** A result that participates with this agent. */ + CO_AGENT_RESULT, + + /** An agent that participates with this instrument. */ + CO_INSTRUMENT_AGENT, + + /** A patient that participates with this instrument. */ + CO_INSTRUMENT_PATIENT, + + /** A result that participates with this instrument. */ + CO_INSTRUMENT_RESULT, + + /** An agent that participates with this patient. */ + CO_PATIENT_AGENT, + + /** An instrument that participates with this patient. */ + CO_PATIENT_INSTRUMENT, + + /** An agent that participates with this result. */ + CO_RESULT_AGENT, + + /** An instrument that participates with this result. */ + CO_RESULT_INSTRUMENT, + + /** A semantic role that participates with this role. */ + CO_ROLE, + + /** A direction associated with an event or entity. */ + DIRECTION, + + /** An equivalent synonym relation from WN-LMF. */ + EQ_SYNONYM, + + /** The location whole that contains this concept. */ + LOCATION_HOLONYM, + + /** The portion whole that contains this concept. */ + PORTION_HOLONYM, + + /** A whole related by an unspecified holonym relation. */ + HOLONYM, + + /** The manner in which this concept occurs. */ + IN_MANNER, + + /** An instrument used in an event. */ + INSTRUMENT, + + /** A location associated with an entity or event. */ + LOCATION, + + /** An event or situation in which this concept is involved. */ + INVOLVED, + + /** The agent involved in an event. */ + INVOLVED_AGENT, + + /** The direction involved in an event. */ + INVOLVED_DIRECTION, + + /** The instrument involved in an event. */ + INVOLVED_INSTRUMENT, + + /** The location involved in an event. */ + INVOLVED_LOCATION, + + /** The patient involved in an event. */ + INVOLVED_PATIENT, + + /** The result involved in an event. */ + INVOLVED_RESULT, + + /** The source direction involved in an event. */ + INVOLVED_SOURCE_DIRECTION, + + /** The target direction involved in an event. */ + INVOLVED_TARGET_DIRECTION, + + /** A synonym relation that is intentionally irregular or non-equivalent. */ + IR_SYNONYM, + + /** An event of which this concept is a subevent. */ + IS_SUBEVENT_OF, + + /** An event for which this concept specifies the manner. */ + MANNER_OF, + + /** A location part contained by this concept. */ + LOCATION_MERONYM, + + /** A portion contained by this concept. */ + PORTION_MERONYM, + + /** A part related by an unspecified meronym relation. */ + MERONYM, + + /** An entity affected by an event. */ + PATIENT, + + /** A concept that restricts this concept. */ + RESTRICTED_BY, + + /** A concept restricted by this concept. */ + RESTRICTS, + + /** A result produced by an event. */ + RESULT, + + /** A semantic role associated with an event. */ + ROLE, + + /** The source direction of an event. */ + SOURCE_DIRECTION, + + /** An event or entity for which this concept is a state. */ + STATE_OF, + + /** An event contained within this event. */ + SUBEVENT, + + /** The target direction of an event. */ + TARGET_DIRECTION, + + /** A feminine form related to this concept. */ + FEMININE, + + /** A concept whose feminine form is this concept. */ + HAS_FEMININE, + + /** A masculine form related to this concept. */ + MASCULINE, + + /** A concept whose masculine form is this concept. */ + HAS_MASCULINE, + + /** A young form related to this concept. */ + YOUNG, + + /** A concept whose young form is this concept. */ + HAS_YOUNG, + + /** A diminutive form related to this concept. */ + DIMINUTIVE, + + /** A concept whose diminutive form is this concept. */ + HAS_DIMINUTIVE, + + /** An augmentative form related to this concept. */ + AUGMENTATIVE, + + /** A concept whose augmentative form is this concept. */ + HAS_AUGMENTATIVE, + + /** A gradable antonym relation. */ + ANTO_GRADABLE, + + /** A simple antonym relation. */ + ANTO_SIMPLE, + + /** A converse antonym relation. */ + ANTO_CONVERSE, + + /** A simple imperfective-to-perfective aspect relation. */ + SIMPLE_ASPECT_IP, + + /** A secondary imperfective-to-perfective aspect relation. */ + SECONDARY_ASPECT_IP, + + /** A simple perfective-to-imperfective aspect relation. */ + SIMPLE_ASPECT_PI, + + /** A secondary perfective-to-imperfective aspect relation. */ + SECONDARY_ASPECT_PI, + + /** A metaphorically related sense. */ + METAPHOR, + + /** A sense for which this sense is a metaphor. */ + HAS_METAPHOR, + + /** A metonymically related sense. */ + METONYM, + + /** A sense for which this sense is a metonym. */ + HAS_METONYM, + + /** The material associated with an entity or event. */ + MATERIAL, + + /** An event associated with a sense. */ + EVENT, + + /** The means by which an event occurs. */ + BY_MEANS_OF, + + /** An entity that undergoes an event. */ + UNDERGOER, + + /** A property expressed by a sense. */ + PROPERTY, + + /** A state expressed by a sense. */ + STATE, + + /** A resource or entity used by an event. */ + USES, + + /** The destination of an event. */ + DESTINATION, + + /** A body part associated with a sense. */ + BODY_PART, + + /** A vehicle associated with a sense. */ + VEHICLE +} diff --git a/opennlp-api/src/test/java/opennlp/tools/wordnet/LexicalKnowledgeBaseTest.java b/opennlp-api/src/test/java/opennlp/tools/wordnet/LexicalKnowledgeBaseTest.java new file mode 100644 index 0000000000..7f7ecd6700 --- /dev/null +++ b/opennlp-api/src/test/java/opennlp/tools/wordnet/LexicalKnowledgeBaseTest.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.wordnet; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Tests {@link LexicalKnowledgeBase} default methods without a file reader. */ +public class LexicalKnowledgeBaseTest { + + private static final Synset DOG = new Synset("test-1-n", WordNetPOS.NOUN, List.of("dog"), + "a domesticated canid", Map.of(WordNetRelation.HYPERNYM, List.of("test-2-n"))); + + private static final Synset CANID = new Synset("test-2-n", WordNetPOS.NOUN, List.of("canid"), + "a carnivorous mammal", Map.of(WordNetRelation.HYPONYM, List.of("test-1-n"))); + + private static final LexicalKnowledgeBase LEXICON = new LexicalKnowledgeBase() { + + @Override + public List lookup(String lemma, WordNetPOS pos) { + if (lemma == null) { + throw new IllegalArgumentException("lemma must not be null"); + } + if (pos == null) { + throw new IllegalArgumentException("pos must not be null"); + } + if (pos == WordNetPOS.NOUN && "dog".equals(lemma)) { + return List.of(DOG); + } + return List.of(); + } + + @Override + public Optional synset(String synsetId) { + if (synsetId == null) { + throw new IllegalArgumentException("synsetId must not be null"); + } + if (DOG.id().equals(synsetId)) { + return Optional.of(DOG); + } + if (CANID.id().equals(synsetId)) { + return Optional.of(CANID); + } + return Optional.empty(); + } + }; + + private static final LexicalKnowledgeBase PERMISSIVE_LEXICON = new LexicalKnowledgeBase() { + + @Override + public List lookup(String lemma, WordNetPOS pos) { + return List.of(); + } + + @Override + public Optional synset(String synsetId) { + return Optional.empty(); + } + }; + + @Test + void testRelatedNavigatesThroughSynset() { + assertEquals(List.of("test-2-n"), LEXICON.related("test-1-n", WordNetRelation.HYPERNYM)); + assertEquals(List.of("test-1-n"), LEXICON.related("test-2-n", WordNetRelation.HYPONYM)); + } + + @Test + void testRelatedIsEmptyForAbsentRelationOrUnknownSynset() { + assertTrue(LEXICON.related("test-1-n", WordNetRelation.ANTONYM).isEmpty()); + assertTrue(LEXICON.related("test-99-n", WordNetRelation.HYPERNYM).isEmpty()); + } + + @Test + void testRelatedRejectsNulls() { + assertThrows(IllegalArgumentException.class, + () -> LEXICON.related(null, WordNetRelation.HYPERNYM)); + assertThrows(IllegalArgumentException.class, () -> LEXICON.related("test-1-n", null)); + } + + @Test + void testContainsFollowsLookup() { + assertTrue(LEXICON.contains("dog", WordNetPOS.NOUN)); + assertFalse(LEXICON.contains("dog", WordNetPOS.VERB)); + assertFalse(LEXICON.contains("cat", WordNetPOS.NOUN)); + } + + @Test + void testContainsRejectsNulls() { + assertThrows(IllegalArgumentException.class, () -> LEXICON.contains(null, WordNetPOS.NOUN)); + assertThrows(IllegalArgumentException.class, () -> LEXICON.contains("dog", null)); + } + + @Test + void testDefaultMethodsValidateTheirArguments() { + assertThrows(IllegalArgumentException.class, + () -> PERMISSIVE_LEXICON.related(null, WordNetRelation.HYPERNYM)); + assertThrows(IllegalArgumentException.class, + () -> PERMISSIVE_LEXICON.related("test-1-n", null)); + assertThrows(IllegalArgumentException.class, + () -> PERMISSIVE_LEXICON.contains(null, WordNetPOS.NOUN)); + assertThrows(IllegalArgumentException.class, + () -> PERMISSIVE_LEXICON.contains("dog", null)); + } +} diff --git a/opennlp-api/src/test/java/opennlp/tools/wordnet/SynsetTest.java b/opennlp-api/src/test/java/opennlp/tools/wordnet/SynsetTest.java new file mode 100644 index 0000000000..9cba1f40ea --- /dev/null +++ b/opennlp-api/src/test/java/opennlp/tools/wordnet/SynsetTest.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package opennlp.tools.wordnet; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +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 SynsetTest { + + private static Synset dog() { + return new Synset("test-1-n", WordNetPOS.NOUN, List.of("dog", "domestic dog"), + "a domesticated canid", + Map.of(WordNetRelation.HYPERNYM, List.of("test-2-n"))); + } + + @Test + void testComponents() { + final Synset synset = dog(); + assertEquals("test-1-n", synset.id()); + assertEquals(WordNetPOS.NOUN, synset.pos()); + assertEquals(List.of("dog", "domestic dog"), synset.lemmas()); + assertEquals("a domesticated canid", synset.gloss()); + assertEquals(Map.of(WordNetRelation.HYPERNYM, List.of("test-2-n")), synset.relations()); + } + + @Test + void testRelatedReturnsTargetsInOrder() { + final Synset synset = new Synset("test-1-n", WordNetPOS.NOUN, List.of("dog"), "", + Map.of(WordNetRelation.HYPONYM, List.of("test-3-n", "test-2-n"))); + assertEquals(List.of("test-3-n", "test-2-n"), synset.related(WordNetRelation.HYPONYM)); + } + + @Test + void testRelatedIsEmptyForAbsentRelation() { + assertTrue(dog().related(WordNetRelation.ANTONYM).isEmpty()); + } + + @Test + void testRelatedRejectsNull() { + assertThrows(IllegalArgumentException.class, () -> dog().related(null)); + } + + @Test + void testEmptyGlossAndNoRelationsAreValid() { + final Synset synset = new Synset("test-9-r", WordNetPOS.ADVERB, List.of("well"), "", Map.of()); + assertEquals("", synset.gloss()); + assertTrue(synset.relations().isEmpty()); + } + + @Test + void testDefensiveCopies() { + final List lemmas = new ArrayList<>(List.of("dog")); + final List targets = new ArrayList<>(List.of("test-2-n")); + final Map> relations = new HashMap<>(); + relations.put(WordNetRelation.HYPERNYM, targets); + final Synset synset = new Synset("test-1-n", WordNetPOS.NOUN, lemmas, "gloss", relations); + lemmas.add("mutated"); + targets.add("mutated"); + relations.put(WordNetRelation.ANTONYM, List.of("test-3-n")); + assertEquals(List.of("dog"), synset.lemmas()); + assertEquals(List.of("test-2-n"), synset.related(WordNetRelation.HYPERNYM)); + assertEquals(1, synset.relations().size()); + } + + @Test + void testReturnedCollectionsAreImmutable() { + final Synset synset = dog(); + assertThrows(UnsupportedOperationException.class, () -> synset.lemmas().add("x")); + assertThrows(UnsupportedOperationException.class, + () -> synset.relations().put(WordNetRelation.ANTONYM, List.of("x"))); + assertThrows(UnsupportedOperationException.class, + () -> synset.related(WordNetRelation.HYPERNYM).add("x")); + } + + @Test + void testRejectsNullOrEmptyId() { + assertThrows(IllegalArgumentException.class, + () -> new Synset(null, WordNetPOS.NOUN, List.of("dog"), "", Map.of())); + assertThrows(IllegalArgumentException.class, + () -> new Synset("", WordNetPOS.NOUN, List.of("dog"), "", Map.of())); + } + + @Test + void testRejectsNullPos() { + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", null, List.of("dog"), "", Map.of())); + } + + @Test + void testAllowsUnlexicalizedSynsetButRejectsInvalidLemmaLists() { + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, null, "", Map.of())); + assertTrue(new Synset("test-1-n", WordNetPOS.NOUN, List.of(), "", Map.of()) + .lemmas().isEmpty()); + final List withNull = new ArrayList<>(); + withNull.add(null); + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, withNull, "", Map.of())); + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, List.of(""), "", Map.of())); + } + + @Test + void testRejectsNullGloss() { + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, List.of("dog"), null, Map.of())); + } + + @Test + void testRejectsInvalidRelations() { + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, List.of("dog"), "", null)); + final Map> nullKey = new HashMap<>(); + nullKey.put(null, List.of("test-2-n")); + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, List.of("dog"), "", nullKey)); + final Map> nullTargets = new HashMap<>(); + nullTargets.put(WordNetRelation.HYPERNYM, null); + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, List.of("dog"), "", nullTargets)); + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, List.of("dog"), "", + Map.of(WordNetRelation.HYPERNYM, List.of()))); + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, List.of("dog"), "", + Map.of(WordNetRelation.HYPERNYM, List.of("")))); + } +} diff --git a/opennlp-distr/pom.xml b/opennlp-distr/pom.xml index e9092d8821..39269575e8 100644 --- a/opennlp-distr/pom.xml +++ b/opennlp-distr/pom.xml @@ -91,6 +91,10 @@ org.apache.opennlp opennlp-spellcheck + + org.apache.opennlp + opennlp-wordnet + diff --git a/opennlp-distr/src/main/assembly/bin.xml b/opennlp-distr/src/main/assembly/bin.xml index 2db4eafc65..36c556d473 100644 --- a/opennlp-distr/src/main/assembly/bin.xml +++ b/opennlp-distr/src/main/assembly/bin.xml @@ -239,6 +239,13 @@ docs/apidocs/opennlp-spellcheck + + ../opennlp-extensions/opennlp-wordnet/target/reports/apidocs + 644 + 755 + docs/apidocs/opennlp-wordnet + + ../opennlp-extensions/opennlp-uima/target/reports/apidocs 644 diff --git a/opennlp-docs/src/docbkx/opennlp.xml b/opennlp-docs/src/docbkx/opennlp.xml index 0761fc95ff..3e864294dc 100644 --- a/opennlp-docs/src/docbkx/opennlp.xml +++ b/opennlp-docs/src/docbkx/opennlp.xml @@ -109,6 +109,7 @@ under the License. + diff --git a/opennlp-docs/src/docbkx/wordnet.xml b/opennlp-docs/src/docbkx/wordnet.xml new file mode 100644 index 0000000000..5012625e4d --- /dev/null +++ b/opennlp-docs/src/docbkx/wordnet.xml @@ -0,0 +1,225 @@ + + + + + + + WordNet + +
+ Introduction + + The opennlp-wordnet module loads a WordNet-style lexicon into + a LexicalKnowledgeBase and looks up synsets by lemma and part + of speech. Two readers are provided: WnLmfReader for the + Global WordNet Association WN-LMF XML interchange format, and + WndbReader for the classic Princeton WordNet database file + layout. Both return an immutable, thread-safe knowledge base. + + + WnLmfReader requires a LexicalResource root + with lexicons as direct children. Parsed entries, senses, synsets, + relations and dependency declarations must have their WN-LMF parent; + each lexical entry must contain a lemma. Incorrect placement or a + missing lemma raises InvalidFormatException with the + resource name and source line. Elements outside the reader's supported + subset are skipped with all descendants, which cannot add lexical + content or dependencies. Skipped XML must still be well formed. 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 InvalidFormatException + with the source name and line. An explicit members list + orders the lemmas; it cannot exclude a sense's lemma from the synset. + +
+ +
+ Loading a lexicon + + Use WN-LMF for Open English WordNet and other GWA wordnets. Use WNDB + for a local Princeton-style + dict directory. The examples below use the miniature + fixtures from the module's tests; replace the paths with a full lexicon + in application code. WordNetUsageExampleTest checks these + results. + + + +
+ +
+ Loading a multilingual lexical resource + + A WN-LMF LexicalResource may contain several + Lexicon elements. Use readResource for this + document. It returns the lexicons in document order and keeps each language's + lookup index separate. The single-lexicon read methods reject a + multi-lexicon document with an error that points to + readResource; equal lemmas in different languages remain separate. + WordNetUsageExampleTest checks this example: + + + + + A WnLmfLexicon exposes its id, label, language, version, + WN-LMF Requires declarations, and independently queryable + knowledgeBase. Dependencies are immutable and retain source + order. They are descriptive metadata: the reader does not fetch or resolve + the referenced lexicons, so the application remains responsible for loading + them. Remaining XML + attributes are available through the namespace-aware + Map<QName, String> metadata. This preserves attributes such + as license and dc:publisher without allowing + namespace collisions. LexiconExtension is not yet represented + by the knowledge-base contract and is rejected clearly rather than partly + loaded. + +
+ +
+ Lookup + + Lookups are scoped by part of speech and fold case and underscores the + same way the readers index lemmas. When a WN-LMF synset contains several + Definition elements, gloss() joins them in document + order with ; . Against the miniature WN-LMF fixture, + the noun dog has one sense: + senses = lexicon.lookup("dog", WordNetPOS.NOUN); +// senses.size() = 1 +// senses.get(0).id() = "mini-n1" +// senses.get(0).lemmas() = ["dog", "domestic dog"] +// senses.get(0).gloss() = "a domesticated canid"]]> + + +
+ +
+ Navigating relations + + Each synset exposes its typed relations (WordNetRelation) to + other synsets. Follow a relation with Synset.related(...), + resolve the returned ids with synset(id), or navigate in one + step with related(...) on the knowledge base. Starting from + the noun dog above, the hypernym relation leads to its + parent concept: + parents = dog.related(WordNetRelation.HYPERNYM); +// parents = ["mini-n2"] + +Synset parent = lexicon.synset(parents.get(0)).orElseThrow(); +// parent.lemmas() = ["canid"] +// parent.gloss() = "a carnivorous mammal with nonretractile claws" + +// one-step navigation, back down the hyponym relation: +// lexicon.related("mini-n2", WordNetRelation.HYPONYM) = ["mini-n1"]]]> + + + + The reader covers every typed SynsetRelation and + SenseRelation declared by WN-LMF 1.4. Sense relations are + represented at the synset level. The other relation is skipped + because it supplies no type the API can preserve; + any other unknown relation name fails with the source line. A relation + that is valid only on SenseRelation or only on + SynsetRelation is also rejected when placed on the wrong + element. A + similar synset relation on a verb maps to + VERB_GROUP, while a sense relation named + similar remains SIMILAR_TO. + + + The expanded relation vocabulary is navigated through the same API. For + example, semantic-role relations can resolve the participants of an event: + + + This listing is mirrored by + WordNetUsageExampleTest.testNavigateSemanticRoleRelations. + + + Some multilingual resources retain an unlexicalized synset as the target + of a relation. Such a synset is available through synset(id) + with an empty lemma list, but is not included in lemma lookup. + +
+ +
+ Checking complete releases + + The offline unit suite includes a reduced Italian, Spanish, and Swedish + multi-lexicon fixture. For a full-size compatibility check, run the + following command from the repository root: + + + The script downloads the pinned OMW 2.0 releases for those languages, + verifies each archive with SHA-512, extracts into a temporary directory, + runs WnLmfOmwIntegrationTest, and removes the downloads. The + archives are not added to the source tree. + + + Run the WNDB smoke test against a complete Princeton-style + dict directory with: + + + +
+ +
+ Morphy lemmatization + + MorphyLemmatizer implements the Morphy algorithm against a + loaded lexicon and the irregular-form exception lists + (noun.exc, verb.exc, adj.exc, + adv.exc). Exception hits are returned first; regular + detachments are kept only when the candidate is in the lexicon. Unknown + forms yield the marker O. + + + +
+
diff --git a/opennlp-extensions/opennlp-wordnet/pom.xml b/opennlp-extensions/opennlp-wordnet/pom.xml new file mode 100644 index 0000000000..0721fcfc98 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/pom.xml @@ -0,0 +1,59 @@ + + + + + + 4.0.0 + + org.apache.opennlp + opennlp-extensions + 3.0.0-SNAPSHOT + + + opennlp-wordnet + jar + Apache OpenNLP :: Ext :: WordNet + + + + org.apache.opennlp + opennlp-api + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.junit.jupiter + junit-jupiter-params + test + + + + diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/InMemoryWordNetLexicon.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/InMemoryWordNetLexicon.java new file mode 100644 index 0000000000..db1bf3e858 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/InMemoryWordNetLexicon.java @@ -0,0 +1,197 @@ +/* + * 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.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import opennlp.tools.commons.ThreadSafe; +import opennlp.tools.wordnet.LexicalKnowledgeBase; +import opennlp.tools.wordnet.Synset; +import opennlp.tools.wordnet.WordNetPOS; +import opennlp.tools.wordnet.WordNetRelation; + +/** + * The immutable in-memory {@link LexicalKnowledgeBase} both readers produce: a synset table plus a + * folded (lemma, part of speech) index. Package-private because it is a reader product, not a + * public entry point; consumers hold it as {@link LexicalKnowledgeBase}. + * + *

Stored keys and queries use {@link LemmaFolding}. Construction verifies + * referential integrity: each relation target must resolve to a synset in the table. After + * construction all state is immutable, making instances safe for concurrent lookups.

+ */ +@ThreadSafe +final class InMemoryWordNetLexicon implements LexicalKnowledgeBase { + + private static final String LEMMA_NULL = "lemma must not be null"; + private static final String POS_NULL = "pos must not be null"; + private static final String SENSE_INDEX_PREFIX = "Sense index entry "; + + private final Map synsetsById; + private final Map> senseIndex; + + /** + * Indexes the given synsets. + * + * @param synsetsById The synset table keyed by synset id; every key must equal its synset's + * {@link Synset#id() id}. Must not be {@code null}. + * @param senseOrder The sense order per folded (lemma, part of speech) key: for each key the + * ids of the synsets containing the lemma, most salient sense first, each + * id resolvable in {@code synsetsById} and free of duplicates per key. + * Must not be {@code null}. + * @throws IllegalArgumentException Thrown if a relation target or sense entry does not + * resolve, or a key disagrees with its synset id. + */ + InMemoryWordNetLexicon(Map synsetsById, Map> senseOrder) { + if (synsetsById == null) { + throw new IllegalArgumentException("synsetsById must not be null"); + } + if (senseOrder == null) { + throw new IllegalArgumentException("senseOrder must not be null"); + } + final Map byId = HashMap.newHashMap(synsetsById.size()); + for (final Map.Entry entry : synsetsById.entrySet()) { + if (entry.getValue() == null || !entry.getValue().id().equals(entry.getKey())) { + throw new IllegalArgumentException( + "Synset table key " + entry.getKey() + " does not match its synset"); + } + byId.put(entry.getKey(), entry.getValue()); + } + for (final Synset synset : byId.values()) { + for (final Map.Entry> relation : + synset.relations().entrySet()) { + for (final String target : relation.getValue()) { + if (!byId.containsKey(target)) { + throw new IllegalArgumentException("Synset " + synset.id() + " has a " + + relation.getKey() + " relation to unknown synset " + target); + } + } + } + } + final Map> index = HashMap.newHashMap(senseOrder.size()); + for (final Map.Entry> entry : senseOrder.entrySet()) { + if (entry.getKey() == null) { + throw new IllegalArgumentException("senseOrder key must not be null"); + } + final LemmaKey key = entry.getKey(); + final List orderedIds = entry.getValue(); + if (orderedIds == null) { + throw new IllegalArgumentException("senseOrder value must not be null"); + } + final List senses = new ArrayList<>(orderedIds.size()); + final Set seen = HashSet.newHashSet(orderedIds.size()); + for (final String synsetId : orderedIds) { + if (!seen.add(synsetId)) { + throw new IllegalArgumentException(SENSE_INDEX_PREFIX + entry.getKey().lemma() + + " (" + entry.getKey().pos() + ") contains duplicate synset " + synsetId); + } + final Synset synset = byId.get(synsetId); + if (synset == null) { + throw new IllegalArgumentException(SENSE_INDEX_PREFIX + key.lemma() + + " (" + key.pos() + ") references unknown synset " + synsetId); + } + boolean containsLemma = false; + for (final String lemma : synset.lemmas()) { + if (key.lemma().equals(LemmaFolding.fold(lemma))) { + containsLemma = true; + break; + } + } + if (synset.pos() != key.pos() || !containsLemma) { + throw new IllegalArgumentException(SENSE_INDEX_PREFIX + key.lemma() + + " (" + key.pos() + ") references synset " + synsetId + + ", which does not contain that lemma and part of speech"); + } + senses.add(synset); + } + index.put(key, List.copyOf(senses)); + } + this.synsetsById = byId; + this.senseIndex = index; + } + + /** {@inheritDoc} */ + @Override + public List lookup(String lemma, WordNetPOS pos) { + if (lemma == null) { + throw new IllegalArgumentException(LEMMA_NULL); + } + if (pos == null) { + throw new IllegalArgumentException(POS_NULL); + } + final List senses = senseIndex.get(LemmaKey.of(lemma, pos)); + return senses == null ? List.of() : senses; + } + + /** {@inheritDoc} */ + @Override + public Optional synset(String synsetId) { + if (synsetId == null) { + throw new IllegalArgumentException("synsetId must not be null"); + } + return Optional.ofNullable(synsetsById.get(synsetId)); + } + + /** {@return the number of synsets in this lexicon} */ + int size() { + return synsetsById.size(); + } + + /** {@return all synsets, for equivalence checks and diagnostics within this package} */ + Collection synsets() { + return Collections.unmodifiableCollection(synsetsById.values()); + } + + /** + * A folded sense-index key. Build with {@link #of(String, WordNetPOS)} to apply the same fold + * to stored keys and queries. + * + * @param lemma The folded lemma. + * @param pos The part of speech. + */ + record LemmaKey(String lemma, WordNetPOS pos) { + + LemmaKey { + if (lemma == null) { + throw new IllegalArgumentException(LEMMA_NULL); + } + if (pos == null) { + throw new IllegalArgumentException(POS_NULL); + } + } + + /** + * Folds a written form into a key using locale-independent lowercase and spaces for + * underscores. + * + * @param writtenForm The lemma as written in the source or query. Must not be {@code null}. + * @param pos The part of speech. Must not be {@code null}. + * @return The folded key. + * @throws IllegalArgumentException Thrown if {@code writtenForm} is {@code null}. + */ + static LemmaKey of(String writtenForm, WordNetPOS pos) { + return new LemmaKey(LemmaFolding.fold(writtenForm), pos); + } + } +} diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/LemmaFolding.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/LemmaFolding.java new file mode 100644 index 0000000000..3e8940b2dd --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/LemmaFolding.java @@ -0,0 +1,73 @@ +/* + * 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.ArrayList; +import java.util.List; + +import opennlp.tools.util.StringUtil; + +/** Shared lemma folding and space-separated field parsing for the WordNet readers. */ +final class LemmaFolding { + + /** Not instantiable. */ + private LemmaFolding() { + } + + /** + * Converts a written form to the lookup form: lowercase with the locale-independent + * one-to-one mapping of {@link StringUtil#toLowerCase(CharSequence)}, with the underscore + * some formats store in multiword lemmas treated as a space. + * + * @param writtenForm The form as written in a source file or query. Must not be {@code null}. + * @return The folded form. + * @throws IllegalArgumentException Thrown if {@code writtenForm} is {@code null}. + */ + static String fold(String writtenForm) { + if (writtenForm == null) { + throw new IllegalArgumentException("writtenForm must not be null"); + } + return StringUtil.toLowerCase(writtenForm.replace('_', ' ')); + } + + /** + * Splits a space-separated field list, collapsing runs of spaces. + * + * @param value The field list. Must not be {@code null}. + * @return The non-empty fields in order, never {@code null}. + * @throws IllegalArgumentException Thrown if {@code value} is {@code null}. + */ + static List splitOnSpaces(String value) { + if (value == null) { + throw new IllegalArgumentException("value must not be null"); + } + final List parts = new ArrayList<>(4); + int start = 0; + while (start < value.length()) { + final int space = value.indexOf(' ', start); + if (space < 0) { + parts.add(value.substring(start)); + break; + } + if (space > start) { + parts.add(value.substring(start, space)); + } + start = space + 1; + } + return parts; + } +} diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/MorphyExceptions.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/MorphyExceptions.java new file mode 100644 index 0000000000..6340d42bdc --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/MorphyExceptions.java @@ -0,0 +1,147 @@ +/* + * 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.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import opennlp.tools.commons.ThreadSafe; +import opennlp.tools.util.InvalidFormatException; +import opennlp.tools.wordnet.WordNetPOS; + +/** + * The Morphy exception lists: the per-part-of-speech tables of irregular inflected forms + * ({@code mice} to {@code mouse}, {@code went} to {@code go}) that the Morphy algorithm + * consults before its detachment rules. + * + *

{@link #load(Path)} reads the four {@code *.exc} files ({@code noun.exc}, + * {@code verb.exc}, {@code adj.exc}, {@code adv.exc}), which must all be present, in the WNDB + * format: one entry per line, the inflected form followed by one or more base forms, space + * separated, with underscores standing for spaces in multiword entries. No exception data is + * bundled; the caller supplies a directory.

+ * + *

Lookups apply the same lemma folding as the WordNet readers. Instances are immutable after + * loading and safe for concurrent lookups.

+ * + * @since 3.0.0 + */ +@ThreadSafe +public final class MorphyExceptions { + + private final Map>> byPos; + + /** + * Wraps the per-part-of-speech exception tables. + * + * @param byPos The loaded tables, one per part of speech. + */ + private MorphyExceptions(Map>> byPos) { + this.byPos = byPos; + } + + /** + * Loads the four exception lists from a directory. + * + * @param directory The directory containing {@code noun.exc}, {@code verb.exc}, + * {@code adj.exc}, and {@code adv.exc}. Must not be {@code null} and must + * exist. + * @return The loaded exception lists. + * @throws IllegalArgumentException Thrown if {@code directory} is {@code null} or not a + * directory. + * @throws InvalidFormatException Thrown if one of the four files is missing or a line is + * malformed; the message names the file and line. + * @throws IOException Thrown if reading a file fails. + */ + public static MorphyExceptions load(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>> byPos = new EnumMap<>(WordNetPOS.class); + byPos.put(WordNetPOS.NOUN, loadFile(directory, "noun.exc")); + byPos.put(WordNetPOS.VERB, loadFile(directory, "verb.exc")); + byPos.put(WordNetPOS.ADJECTIVE, loadFile(directory, "adj.exc")); + byPos.put(WordNetPOS.ADVERB, loadFile(directory, "adv.exc")); + return new MorphyExceptions(byPos); + } + + /** + * Finds the base forms of an irregular inflected form. + * + * @param word The inflected form; folded before lookup. Must not be {@code null}. + * @param pos The part of speech. Must not be {@code null}. + * @return The base forms in file order, never {@code null}; empty when the word has no entry. + * @throws IllegalArgumentException Thrown if {@code word} or {@code pos} is {@code null}. + */ + public List lookup(String word, WordNetPOS pos) { + if (word == null) { + throw new IllegalArgumentException("word must not be null"); + } + if (pos == null) { + throw new IllegalArgumentException("pos must not be null"); + } + final List lemmas = byPos.get(pos).get(LemmaFolding.fold(word)); + return lemmas == null ? List.of() : lemmas; + } + + /** + * Loads one {@code *.exc} file into a folded inflected-form to base-forms map. + * + * @param directory The directory holding the file. + * @param fileName The exception file name, for example {@code noun.exc}. + * @return The folded exception entries. + * @throws InvalidFormatException Thrown if the file is missing or a line is malformed. + * @throws IOException Thrown if reading the file fails. + */ + private static Map> loadFile(Path directory, String fileName) + throws IOException { + final Path file = directory.resolve(fileName); + if (!Files.isRegularFile(file)) { + throw new InvalidFormatException("Missing exception list file: " + file); + } + final List lines = Files.readAllLines(file, StandardCharsets.ISO_8859_1); + final Map> entries = HashMap.newHashMap(lines.size()); + for (int i = 0; i < lines.size(); i++) { + final String line = lines.get(i); + if (line.isEmpty()) { + continue; + } + final List fields = LemmaFolding.splitOnSpaces(line); + if (fields.size() < 2) { + throw new InvalidFormatException("Malformed exception list " + fileName + " at line " + + (i + 1) + ": expected an inflected form and at least one base form, got: " + line); + } + final List lemmas = new ArrayList<>(fields.size() - 1); + for (final String lemma : fields.subList(1, fields.size())) { + lemmas.add(LemmaFolding.fold(lemma)); + } + // A form listed twice keeps its first entry, matching first-match lookup semantics. + entries.putIfAbsent(LemmaFolding.fold(fields.get(0)), List.copyOf(lemmas)); + } + return Map.copyOf(entries); + } +} diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/MorphyLemmatizer.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/MorphyLemmatizer.java new file mode 100644 index 0000000000..4017a5bee3 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/MorphyLemmatizer.java @@ -0,0 +1,250 @@ +/* + * 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.ArrayList; +import java.util.List; + +import opennlp.tools.lemmatizer.Lemmatizer; +import opennlp.tools.util.StringUtil; +import opennlp.tools.wordnet.LexicalKnowledgeBase; +import opennlp.tools.wordnet.WordNetPOS; + +/** + * A {@link Lemmatizer} implementing the Morphy algorithm: exception-list lookup first, then the + * per-part-of-speech detachment rules, with every rule-derived candidate validated + * against a {@link LexicalKnowledgeBase} before it is returned. A token is folded with + * locale-independent lowercase and spaces for underscores before lookup. Returned lemmas use + * that folded form. + * + *

Part-of-speech tags map to a {@link WordNetPOS} by their conventional Penn Treebank + * prefixes ({@code N}, {@code V}, {@code J}, {@code R}), the names {@code ADJ} and {@code ADV}, + * and the one-letter WordNet codes {@code n}, {@code v}, {@code a}, {@code r}, and {@code s} + * (satellite, treated as adjective), case-insensitively. A tag that maps to no part of speech + * yields the unknown-word result.

+ * + *

Following {@code opennlp.tools.lemmatizer.DictionaryLemmatizer}, a token with no lemma + * yields {@link #UNKNOWN_LEMMA} from {@link #lemmatize(String[], String[])} and a singleton list + * of it from {@link #lemmatize(List, List)}. Both a lexicon and exception lists are required. + * Concurrent use depends on the supplied {@link LexicalKnowledgeBase} implementation.

+ * + * @since 3.0.0 + */ +public final class MorphyLemmatizer implements Lemmatizer { + + /** + * The output emitted for a token whose lemma is unknown, following the conventional + * {@link Lemmatizer} unknown marker also used by + * {@code opennlp.tools.lemmatizer.DictionaryLemmatizer}. + */ + public static final String UNKNOWN_LEMMA = "O"; + + private static final String TOKENS_NULL = "toks must not be null"; + private static final String TAGS_NULL = "tags must not be null"; + private static final String TOKEN_ELEMENT_NULL = "toks must not contain a null element"; + private static final String TAG_ELEMENT_NULL = "tags must not contain a null element"; + + private static final String[][] NOUN_RULES = { + {"s", ""}, {"ses", "s"}, {"xes", "x"}, {"zes", "z"}, + {"ches", "ch"}, {"shes", "sh"}, {"men", "man"}, {"ies", "y"}, + }; + + private static final String[][] VERB_RULES = { + {"s", ""}, {"ies", "y"}, {"es", "e"}, {"es", ""}, + {"ed", "e"}, {"ed", ""}, {"ing", "e"}, {"ing", ""}, + }; + + private static final String[][] ADJECTIVE_RULES = { + {"er", ""}, {"est", ""}, {"er", "e"}, {"est", "e"}, + }; + + private static final String[][] NO_RULES = {}; + + private final LexicalKnowledgeBase lexicon; + private final MorphyExceptions exceptions; + + /** + * Creates a Morphy lemmatizer over a loaded lexicon and exception lists. + * + * @param lexicon The lexicon rule candidates are validated against. Must not be + * {@code null}. + * @param exceptions The irregular-form exception lists. Must not be {@code null}. + * @throws IllegalArgumentException Thrown if {@code lexicon} or {@code exceptions} is + * {@code null}. + */ + public MorphyLemmatizer(LexicalKnowledgeBase lexicon, MorphyExceptions exceptions) { + if (lexicon == null) { + throw new IllegalArgumentException("lexicon must not be null"); + } + if (exceptions == null) { + throw new IllegalArgumentException("exceptions must not be null"); + } + this.lexicon = lexicon; + this.exceptions = exceptions; + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException Thrown if {@code toks} or {@code tags} is {@code null}, + * contains a {@code null} element, or the two differ in length. + */ + @Override + public String[] lemmatize(String[] toks, String[] tags) { + if (toks == null) { + throw new IllegalArgumentException(TOKENS_NULL); + } + if (tags == null) { + throw new IllegalArgumentException(TAGS_NULL); + } + if (toks.length != tags.length) { + throw new IllegalArgumentException("toks and tags must have the same length, got " + + toks.length + " and " + tags.length); + } + final String[] lemmas = new String[toks.length]; + for (int i = 0; i < toks.length; i++) { + if (toks[i] == null) { + throw new IllegalArgumentException(TOKEN_ELEMENT_NULL); + } + if (tags[i] == null) { + throw new IllegalArgumentException(TAG_ELEMENT_NULL); + } + final List candidates = lemmasOf(toks[i], tags[i]); + lemmas[i] = candidates.isEmpty() ? UNKNOWN_LEMMA : candidates.get(0); + } + return lemmas; + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException Thrown if {@code toks} or {@code tags} is {@code null}, + * contains a {@code null} element, or the two differ in size. + */ + @Override + public List> lemmatize(List toks, List tags) { + if (toks == null) { + throw new IllegalArgumentException(TOKENS_NULL); + } + if (tags == null) { + throw new IllegalArgumentException(TAGS_NULL); + } + if (toks.size() != tags.size()) { + throw new IllegalArgumentException("toks and tags must have the same size, got " + + toks.size() + " and " + tags.size()); + } + final List> lemmas = new ArrayList<>(toks.size()); + for (int i = 0; i < toks.size(); i++) { + if (toks.get(i) == null) { + throw new IllegalArgumentException(TOKEN_ELEMENT_NULL); + } + if (tags.get(i) == null) { + throw new IllegalArgumentException(TAG_ELEMENT_NULL); + } + final List candidates = lemmasOf(toks.get(i), tags.get(i)); + lemmas.add(candidates.isEmpty() ? List.of(UNKNOWN_LEMMA) : candidates); + } + return lemmas; + } + + /** + * Finds all lemmas of one token, most preferred first. + * + * @param token The token to lemmatize. Validated at the public boundary. + * @param tag The part-of-speech tag. Validated at the public boundary. + * @return The candidate lemmas, empty when the word is unknown or the tag maps to no part of + * speech. + */ + private List lemmasOf(String token, String tag) { + final WordNetPOS pos = posFromTag(tag); + if (pos == null) { + return List.of(); + } + final String folded = LemmaFolding.fold(token); + final List irregular = exceptions.lookup(folded, pos); + if (!irregular.isEmpty()) { + return irregular; + } + final List candidates = new ArrayList<>(2); + if (lexicon.contains(folded, pos)) { + candidates.add(folded); + } + for (final String[] rule : rulesFor(pos)) { + final String suffix = rule[0]; + if (folded.length() > suffix.length() && folded.endsWith(suffix)) { + final String candidate = + folded.substring(0, folded.length() - suffix.length()) + rule[1]; + if (!candidates.contains(candidate) && lexicon.contains(candidate, pos)) { + candidates.add(candidate); + } + } + } + return candidates; + } + + /** + * Selects the detachment-rule table for a part of speech. + * + * @param pos The part of speech. + * @return The suffix-substitution rules, empty for adverbs. + */ + private String[][] rulesFor(WordNetPOS pos) { + return switch (pos) { + case NOUN -> NOUN_RULES; + case VERB -> VERB_RULES; + case ADJECTIVE -> ADJECTIVE_RULES; + case ADVERB -> NO_RULES; + }; + } + + /** + * Maps a part-of-speech tag to a {@link WordNetPOS}. Package-private so tests can pin the + * mapping directly. + * + * @param tag The tag to map. Must not be {@code null}. + * @return The part of speech, or {@code null} when the tag names none. + * @throws IllegalArgumentException Thrown if {@code tag} is {@code null}. + */ + static WordNetPOS posFromTag(String tag) { + if (tag == null) { + throw new IllegalArgumentException("tag must not be null"); + } + if (tag.isEmpty()) { + return null; + } + final char first = tag.charAt(0); + if ((first < 'A' || first > 'Z') && (first < 'a' || first > 'z')) { + return null; + } + final String upper = StringUtil.toUpperCase(tag); + if (upper.startsWith("ADJ")) { + return WordNetPOS.ADJECTIVE; + } + if (upper.startsWith("ADV")) { + return WordNetPOS.ADVERB; + } + return switch (upper.charAt(0)) { + case 'N' -> WordNetPOS.NOUN; + case 'V' -> WordNetPOS.VERB; + case 'J' -> WordNetPOS.ADJECTIVE; + // Codes a and s mean adjective only as one-letter tags; AUX, ADP and the like do not. + case 'A', 'S' -> tag.length() == 1 ? WordNetPOS.ADJECTIVE : null; + case 'R' -> WordNetPOS.ADVERB; + default -> null; + }; + } +} diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfDependency.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfDependency.java new file mode 100644 index 0000000000..2c461ef0f1 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfDependency.java @@ -0,0 +1,45 @@ +/* + * 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 opennlp.tools.commons.ThreadSafe; + +/** + * One WN-LMF {@code Requires} declaration. It identifies another lexicon and the required + * version, but does not resolve or load that lexicon. + * + * @param ref The required lexicon id. Must not be {@code null} or empty. + * @param version The required lexicon version. Must not be {@code null} or empty. + * @since 3.0.0 + */ +@ThreadSafe +public record WnLmfDependency(String ref, String version) { + + /** + * Creates a dependency descriptor. + * + * @throws IllegalArgumentException Thrown if a component is {@code null} or empty. + */ + public WnLmfDependency { + if (ref == null || ref.isEmpty()) { + throw new IllegalArgumentException("ref must not be null or empty"); + } + if (version == null || version.isEmpty()) { + throw new IllegalArgumentException("version must not be null or empty"); + } + } +} diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfLexicon.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfLexicon.java new file mode 100644 index 0000000000..83e1c92aa8 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfLexicon.java @@ -0,0 +1,97 @@ +/* + * 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.Map; +import javax.xml.namespace.QName; + +import opennlp.tools.wordnet.LexicalKnowledgeBase; + +/** + * One lexicon in a WN-LMF lexical resource, with its identity, metadata, and independently + * queryable knowledge base. + * + *

The {@link #metadata()} map contains every XML attribute on the {@code Lexicon} element + * except {@code id}, {@code label}, {@code language}, and {@code version}. Keys are namespace-aware + * {@link QName} values, so Dublin Core attributes do not collide with unqualified attributes. + * The dependency list preserves the source order of WN-LMF {@code Requires} declarations. It is + * descriptive metadata only; parsing does not resolve or load the referenced lexicons. The list + * and map are immutable. Thread safety of the knowledge base depends on its implementation.

+ * + * @param id The WN-LMF lexicon id. Must not be {@code null} or empty. + * @param label The human-readable label. Must not be {@code null} or empty. + * @param language The BCP 47 language tag carried by the source. Must not be {@code null} or + * empty. + * @param version The source's version string. Must not be {@code null} or empty. + * @param metadata The remaining Lexicon attributes. Must not be {@code null} and must not + * contain null keys or values. + * @param dependencies The required lexicons in source order. Must not be {@code null} and must + * not contain null elements. + * @param knowledgeBase The independently queryable lexicon. Must not be {@code null}. + * @since 3.0.0 + */ +public record WnLmfLexicon( + String id, + String label, + String language, + String version, + Map metadata, + List dependencies, + LexicalKnowledgeBase knowledgeBase) { + + /** + * Creates a WN-LMF lexicon descriptor. + * + * @throws IllegalArgumentException Thrown if a component violates its documented constraint. + */ + public WnLmfLexicon { + if (id == null || id.isEmpty()) { + throw new IllegalArgumentException("id must not be null or empty"); + } + if (label == null || label.isEmpty()) { + throw new IllegalArgumentException("label must not be null or empty"); + } + if (language == null || language.isEmpty()) { + throw new IllegalArgumentException("language must not be null or empty"); + } + if (version == null || version.isEmpty()) { + throw new IllegalArgumentException("version must not be null or empty"); + } + if (metadata == null) { + throw new IllegalArgumentException("metadata must not be null"); + } + for (final Map.Entry 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 + ">" + + "" + 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" + + "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 +
plural
+ + + 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} + + opennlp-wordnet + ${project.groupId} + ${project.version} + + opennlp-uima ${project.groupId} diff --git a/rat-excludes b/rat-excludes index 5a5d86b90c..561869bd46 100644 --- a/rat-excludes +++ b/rat-excludes @@ -70,3 +70,12 @@ src/main/resources/opennlp/tools/tokenize/uax29/WordBreakProperty.txt src/main/resources/opennlp/tools/tokenize/uax29/ExtendedPictographic.txt src/main/resources/opennlp/tools/util/normalizer/confusables.txt src/test/resources/opennlp/tools/tokenize/uax29/WordBreakTest.txt + + +src/test/resources/opennlp/wordnet/mini-wndb/noun.exc +src/test/resources/opennlp/wordnet/mini-wndb/verb.exc +src/test/resources/opennlp/wordnet/mini-wndb/adj.exc +src/test/resources/opennlp/wordnet/mini-wndb/adv.exc