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..214ef74db1 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/wordnet/LexicalKnowledgeBase.java @@ -0,0 +1,89 @@ +/* + * 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 + * identity is opaque and source-qualified (see {@link Synset#id()}). Lookups return their matches + * in the source's sense order and never return {@code null}. + * + *

Lemma matching semantics are the implementation's concern. The reference implementations + * match case-insensitively (case folding with the root locale) and treat the underscore some + * formats store in multiword lemmas as a space; an implementation with different semantics must + * document them. Returned {@link Synset#lemmas() lemmas} preserve the source's written forms, + * with spaces in multiword lemmas.

+ * + *

Implementations must be immutable and thread-safe after loading: one instance is meant to + * be shared across an application's threads for concurrent lookups.

+ */ +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 The synset identifier, as minted 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 (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. This is the membership + * check morphological rules validate their candidates against; implementations may override + * it with a cheaper check than {@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) { + 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..477fa38d90 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/wordnet/Synset.java @@ -0,0 +1,123 @@ +/* + * 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 minted 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} or + * empty, and must not contain {@code null} or empty elements. + * @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. + */ +@ThreadSafe +public record Synset( + String id, + WordNetPOS pos, + List lemmas, + String gloss, + Map> relations) { + + /** + * 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 || lemmas.isEmpty()) { + throw new IllegalArgumentException("Lemmas must not be null or empty 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 " + 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 " + 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..65f6e691ec --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/wordnet/WordNetPOS.java @@ -0,0 +1,40 @@ +/* + * 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 carries none of the single-letter codes the on-disk formats use; readers own the + * mapping from their format's codes to these values. Adjective satellites normalize to + * {@link #ADJECTIVE}, with the cluster structure preserved through + * {@link WordNetRelation#SIMILAR_TO}.

+ */ +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..10da3cea34 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/wordnet/WordNetRelation.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.tools.wordnet; + +/** + * The typed relations a wordnet-style lexicon draws between {@link Synset synsets}. 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) surface here at the synset level: the synset containing the source + * sense carries the relation to the synset containing the target sense.

+ */ +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 worth consulting. */ + 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 +} 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..2b698797f1 --- /dev/null +++ b/opennlp-api/src/test/java/opennlp/tools/wordnet/LexicalKnowledgeBaseTest.java @@ -0,0 +1,105 @@ +/* + * 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; + +/** + * Exercises the {@link LexicalKnowledgeBase} default methods against a minimal in-memory + * implementation, so the defaults are validated independently of any 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"))); + + // A deliberately tiny implementation of only the two abstract methods. + 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(); + } + }; + + @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)); + } +} 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..e9cbb4c063 --- /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 testRejectsNullOrEmptyLemmas() { + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, null, "", Map.of())); + assertThrows(IllegalArgumentException.class, + () -> new Synset("test-1-n", WordNetPOS.NOUN, List.of(), "", Map.of())); + 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-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..2401bfab6a --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/InMemoryWordNetLexicon.java @@ -0,0 +1,156 @@ +/* + * 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.List; +import java.util.Map; +import java.util.Optional; + +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}. + * + *

Keys and queries are folded identically (see {@link LemmaFolding}). Construction verifies + * referential integrity: every relation target of every synset must resolve to a synset in the + * table, so a lexicon can never hand out a dangling identifier. After construction all state is + * immutable, making instances safe for concurrent lookups.

+ */ +@ThreadSafe +final class InMemoryWordNetLexicon implements LexicalKnowledgeBase { + + 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 = new HashMap<>(synsetsById.size() * 2); + 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 = new HashMap<>(senseOrder.size() * 2); + for (final Map.Entry> entry : senseOrder.entrySet()) { + final List senses = new ArrayList<>(entry.getValue().size()); + for (final String synsetId : entry.getValue()) { + final Synset synset = byId.get(synsetId); + if (synset == null) { + throw new IllegalArgumentException("Sense index entry " + entry.getKey().lemma() + + " (" + entry.getKey().pos() + ") references unknown synset " + synsetId); + } + senses.add(synset); + } + index.put(entry.getKey(), 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 must not be null"); + } + if (pos == null) { + throw new IllegalArgumentException("Pos must not be 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)} so every key passes + * through the same fold as every query. + * + * @param lemma The folded lemma. + * @param pos The part of speech. + */ + record LemmaKey(String lemma, WordNetPOS pos) { + + /** + * Folds a written form into a key: lowercase with the root locale, underscore as space. + * + * @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. + */ + 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..4da713d520 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/LemmaFolding.java @@ -0,0 +1,71 @@ +/* + * 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 java.util.Locale; + +/** + * The single home of the lemma fold and the space-separated field split this package relies on. + * {@link MorphyExceptions} keys, the {@link InMemoryWordNetLexicon.LemmaKey sense-index keys}, + * and every query must fold through {@link #fold(String)} so their canonical forms agree. + */ +final class LemmaFolding { + + /** Not instantiable. */ + private LemmaFolding() { + } + + /** + * Folds a written form into its canonical shape: lowercase with the root locale, 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 writtenForm.replace('_', ' ').toLowerCase(Locale.ROOT); + } + + /** + * 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}. + */ + static List splitOnSpaces(String value) { + 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..4f6c0b2934 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/MorphyExceptions.java @@ -0,0 +1,145 @@ +/* + * 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 fold the queried word the same way the lexicon seam folds lemmas. Instances are + * immutable after loading and safe for concurrent lookups.

+ */ +@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 = new HashMap<>(lines.size() * 2); + 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..a004643f82 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/MorphyLemmatizer.java @@ -0,0 +1,212 @@ +/* + * 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 java.util.Locale; + +import opennlp.tools.commons.ThreadSafe; +import opennlp.tools.lemmatizer.Lemmatizer; +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 iterative detachment rules, with every rule-derived candidate validated + * against a {@link LexicalKnowledgeBase} before it is returned. A token is folded (lowercase with + * the root locale, underscore as space) before lookup, and returned lemmas are in 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 {@code "O"} from {@link #lemmatize(String[], String[])} and {@code ["O"]} from + * {@link #lemmatize(List, List)}. Both a lexicon and exception lists are required. Instances are + * immutable and safe for concurrent use.

+ */ +@ThreadSafe +public final class MorphyLemmatizer implements Lemmatizer { + + /** The unknown-word output, matching the DictionaryLemmatizer convention. */ + private static final String UNKNOWN = "O"; + + 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} */ + @Override + public String[] lemmatize(String[] toks, String[] tags) { + if (toks == null || tags == null) { + throw new IllegalArgumentException("Toks and tags must not be 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++) { + final List candidates = lemmasOf(toks[i], tags[i]); + lemmas[i] = candidates.isEmpty() ? UNKNOWN : candidates.get(0); + } + return lemmas; + } + + /** {@inheritDoc} */ + @Override + public List> lemmatize(List toks, List tags) { + if (toks == null || tags == null) { + throw new IllegalArgumentException("Toks and tags must not be 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++) { + final List candidates = lemmasOf(toks.get(i), tags.get(i)); + lemmas.add(candidates.isEmpty() ? List.of(UNKNOWN) : candidates); + } + return lemmas; + } + + /** + * Finds all lemmas of one token, most preferred first. + * + * @param token The token to lemmatize. + * @param tag The part-of-speech tag. + * @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) { + if (token == null || tag == null) { + throw new IllegalArgumentException("Tokens and tags must not contain null elements"); + } + 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 static 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 String upper = tag.toUpperCase(Locale.ROOT); + 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/WnLmfReader.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfReader.java new file mode 100644 index 0000000000..bcb7bea0ce --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WnLmfReader.java @@ -0,0 +1,590 @@ +/* + * 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.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +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.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 the subset of the format the contract serves: lexical entries, synsets with their + * definitions and typed relations, and sense relations, which are lifted to the synset level as + * documented on {@link WordNetRelation}. Elements outside that subset are skipped, as are + * relations of type {@code other} (the format's untyped escape hatch); any other unknown + * relation type fails loud.

+ * + *

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 fails loud with 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}. The returned lexicon is immutable and safe for concurrent + * lookups.

+ */ +public final class WnLmfReader { + + private static final Map RELATION_NAMES = relationNames(); + + /** The format's escape-hatch relation type; carries no type the contract can express. */ + private static final String OTHER_RELATION = "other"; + + /** 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; 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 { + 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 read(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; 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 { + 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(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.build(); + } + + /** + * 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 but never resolved. + * + * @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, refusing " + systemId); + }); + return factory; + } + + /** Holds the streaming parse state and performs post-parse resolution. */ + private static final class Parser { + + private final String resourceName; + + // Entry state. + private final Set entryIds = new HashSet<>(); + private final Map lemmaByEntryId = new HashMap<>(); + private final Map posByEntryId = new HashMap<>(); + private final Map synsetBySenseId = new HashMap<>(); + private final Map> senseOrder = + new LinkedHashMap<>(); + private final List senseRelations = new ArrayList<>(); + private final Map rawSynsets = new LinkedHashMap<>(); + // Fallback membership (entry ids per synset in document order) when members is absent. + private final Map> entryIdsBySynset = 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. + */ + 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. + */ + void parse(XMLStreamReader reader) throws XMLStreamException, InvalidFormatException { + while (reader.hasNext()) { + final int event = reader.next(); + // A DTD event carries nothing that can affect parsing once the factory is hardened. + if (event == XMLStreamConstants.START_ELEMENT) { + startElement(reader); + } else if (event == XMLStreamConstants.END_ELEMENT) { + endElement(reader.getLocalName()); + } + } + } + + /** + * 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 "LexicalEntry" -> { + currentEntryId = requireAttribute(reader, "id"); + if (!entryIds.add(currentEntryId)) { + throw malformed(reader.getLocation(), + "Duplicate lexical entry id " + currentEntryId, null); + } + currentEntryLemma = null; + currentEntryPos = null; + } + case "Lemma" -> { + if (currentEntryId == null) { + throw malformed(reader.getLocation(), "Lemma outside a LexicalEntry", null); + } + currentEntryLemma = requireAttribute(reader, "writtenForm"); + currentEntryPos = parsePos(requireAttribute(reader, "partOfSpeech"), + reader.getLocation()); + lemmaByEntryId.put(currentEntryId, currentEntryLemma); + posByEntryId.put(currentEntryId, currentEntryPos); + } + case "Sense" -> { + if (currentEntryLemma == null) { + throw malformed(reader.getLocation(), + "Sense before its entry's Lemma in LexicalEntry " + currentEntryId, null); + } + currentSenseId = requireAttribute(reader, "id"); + final String synsetId = requireAttribute(reader, "synset"); + if (synsetBySenseId.putIfAbsent(currentSenseId, synsetId) != null) { + throw malformed(reader.getLocation(), "Duplicate sense id " + currentSenseId, null); + } + entryIdsBySynset.computeIfAbsent(synsetId, unused -> new ArrayList<>(2)) + .add(currentEntryId); + 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); + } + senseRelations.add(new RawSenseRelation(currentSenseId, + requireAttribute(reader, "relType"), requireAttribute(reader, "target"), + line(reader.getLocation()))); + } + case "Synset" -> { + final String id = requireAttribute(reader, "id"); + final WordNetPOS pos = parsePos(requireAttribute(reader, "partOfSpeech"), + 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); + } + } + case "Definition" -> { + if (currentSynset != null && currentSynset.gloss == null) { + currentSynset.gloss = reader.getElementText(); + } + } + case "SynsetRelation" -> { + if (currentSynset == null) { + throw malformed(reader.getLocation(), "SynsetRelation outside a Synset", null); + } + final String relType = requireAttribute(reader, "relType"); + final String target = requireAttribute(reader, "target"); + // 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()))); + } + } + default -> { + // Pronunciation, Form, Example, SyntacticBehaviour, ILIDefinition, and other + // elements outside the contract subset are skipped. + } + } + } + + /** + * Clears cursor state when a tracked element closes. + * + * @param name The local name of the closing element. + */ + private void endElement(String name) { + switch (name) { + case "LexicalEntry" -> { + currentEntryId = null; + currentEntryLemma = null; + currentEntryPos = null; + } + case "Sense" -> currentSenseId = null; + case "Synset" -> currentSynset = null; + default -> { + // Nothing to close for skipped elements. + } + } + } + + /** + * Resolves the collected raw state into an immutable lexicon: validates sense targets, lifts + * sense relations to the synset level, and materializes the contract synsets. + * + * @return The loaded lexicon. + * @throws InvalidFormatException Thrown if a sense or relation references an undeclared + * target, or a synset has no members. + */ + LexicalKnowledgeBase build() throws InvalidFormatException { + // Every sense must point to a declared synset, with a consistent part of speech. + for (final Map.Entry sense : synsetBySenseId.entrySet()) { + final RawSynset target = rawSynsets.get(sense.getValue()); + if (target == null) { + throw malformed(null, + "Sense " + sense.getKey() + " references undeclared synset " + sense.getValue(), + null); + } + } + // Lift sense relations to the synset level. + for (final RawSenseRelation relation : senseRelations) { + if (OTHER_RELATION.equals(relation.relType)) { + continue; + } + final String sourceSynsetId = synsetBySenseId.get(relation.sourceSenseId); + final String targetSynsetId = synsetBySenseId.get(relation.targetSenseId); + if (targetSynsetId == null) { + throw malformed(null, "SenseRelation at line " + relation.line + " from sense " + + relation.sourceSenseId + " references undeclared sense " + relation.targetSenseId, + null); + } + final RawSynset source = rawSynsets.get(sourceSynsetId); + source.relations.add(new RawRelation(relation.relType, targetSynsetId, relation.line)); + } + // Resolve raw synsets into contract synsets. + final Map synsetsById = new LinkedHashMap<>(rawSynsets.size() * 2); + for (final RawSynset raw : rawSynsets.values()) { + final Map> relations = resolveRelations(raw); + synsetsById.put(raw.id, + new Synset(raw.id, raw.pos, memberLemmas(raw), raw.gloss == null ? "" : raw.gloss, + relations)); + } + return new InMemoryWordNetLexicon(synsetsById, senseOrder); + } + + /** + * 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); + 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 = new LinkedHashMap<>(typed.size() * 2); + for (final Map.Entry> entry : typed.entrySet()) { + relations.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return relations; + } + + /** + * Resolves a synset's member entry ids to their lemmas, from the {@code members} attribute + * when present and otherwise from the senses that pointed at the synset. + * + * @param raw The raw synset. + * @return The member lemmas in source order, deduplicated. + * @throws InvalidFormatException Thrown if the synset has no members, names an undeclared + * entry, or a member's part of speech disagrees with the synset's. + */ + private List memberLemmas(RawSynset raw) throws InvalidFormatException { + final List entryIds; + if (raw.members != null && !raw.members.isEmpty()) { + entryIds = LemmaFolding.splitOnSpaces(raw.members); + } else { + final List fromSenses = entryIdsBySynset.get(raw.id); + entryIds = fromSenses == null ? List.of() : fromSenses; + } + if (entryIds.isEmpty()) { + throw malformed(null, "Synset " + raw.id + " at line " + raw.line + + " has no member entries", null); + } + final List lemmas = new ArrayList<>(entryIds.size()); + for (final String entryId : entryIds) { + final String lemma = lemmaByEntryId.get(entryId); + if (lemma == null) { + throw malformed(null, "Synset " + raw.id + " at line " + raw.line + + " lists undeclared member entry " + entryId, 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); + } + } + 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}. 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. + * @return The mapped relation. + * @throws InvalidFormatException Thrown if the relation name is unknown. + */ + private WordNetRelation parseRelation(String relType, WordNetPOS sourcePos, int line) + throws InvalidFormatException { + if ("similar".equals(relType)) { + return 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. + */ + InvalidFormatException malformed(Location location, String message, Throwable cause) { + final int line = line(location); + final String prefix = line < 0 ? "Malformed WN-LMF document " + resourceName + ": " + : "Malformed WN-LMF document " + 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 static int line(Location location) { + return location == null ? -1 : location.getLineNumber(); + } + } + + 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 String gloss; + + /** + * 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. + */ + 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) { + } + + /** 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("antonym", WordNetRelation.ANTONYM); + 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("holo_member", WordNetRelation.MEMBER_HOLONYM); + names.put("holo_substance", WordNetRelation.SUBSTANCE_HOLONYM); + names.put("holo_part", WordNetRelation.PART_HOLONYM); + names.put("mero_member", WordNetRelation.MEMBER_MERONYM); + names.put("mero_substance", WordNetRelation.SUBSTANCE_MERONYM); + names.put("mero_part", WordNetRelation.PART_MERONYM); + names.put("attribute", WordNetRelation.ATTRIBUTE); + names.put("derivation", WordNetRelation.DERIVATIONALLY_RELATED); + names.put("entails", WordNetRelation.ENTAILMENT); + names.put("is_entailed_by", WordNetRelation.ENTAILED_BY); + names.put("causes", WordNetRelation.CAUSE); + names.put("is_caused_by", WordNetRelation.CAUSED_BY); + names.put("also", WordNetRelation.ALSO_SEE); + names.put("participle", WordNetRelation.PARTICIPLE); + names.put("pertainym", WordNetRelation.PERTAINYM); + names.put("domain_topic", WordNetRelation.DOMAIN_TOPIC); + names.put("has_domain_topic", WordNetRelation.MEMBER_OF_DOMAIN_TOPIC); + names.put("domain_region", WordNetRelation.DOMAIN_REGION); + names.put("has_domain_region", WordNetRelation.MEMBER_OF_DOMAIN_REGION); + // The usage domain carries both its current WN-LMF name and the legacy alias. + names.put("exemplifies", WordNetRelation.DOMAIN_USAGE); + names.put("domain_usage", WordNetRelation.DOMAIN_USAGE); + names.put("is_exemplified_by", WordNetRelation.MEMBER_OF_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/WndbReader.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WndbReader.java new file mode 100644 index 0000000000..67e280e3a9 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/WndbReader.java @@ -0,0 +1,610 @@ +/* + * 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 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 legacy Princeton WNDB database directory ({@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 are minted as {@code wndb-}offset{@code -}pos 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 fails loud with 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.

+ */ +public final class WndbReader { + + private static final Map POINTER_SYMBOLS = pointerSymbols(); + + /** Not instantiable. */ + private WndbReader() { + } + + /** + * 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 keyed by minted 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), 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); + } + tokens.next("lex_filenum"); + final String ssType = tokens.next("ss_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.nextInt("w_cnt", 16); + 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); + tokens.nextInt("lex_id", 16); + if (!lemmas.contains(lemma)) { + lemmas.add(lemma); + } + } + final int pointerCount = tokens.nextInt("p_cnt", 10); + 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); + tokens.next("pointer source/target"); + pointers.add(new RawPointer(relation, "wndb-" + targetOffset + "-" + targetPos, lineNumber)); + } + if (filePos == FilePos.VERB) { + final int frameCount = tokens.nextInt("f_cnt", 10); + for (int i = 0; i < frameCount; i++) { + tokens.next("frame marker"); + tokens.next("f_num"); + tokens.next("w_num"); + } + } + final String gloss = tokens.gloss(); + final String id = "wndb-" + 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 = new LinkedHashMap<>(rawSynsets.size() * 2); + 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); + } + // 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 = new LinkedHashMap<>(typed.size() * 2); + 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), 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.nextInt("synset_cnt", 10); + if (synsetCount < 1) { + throw malformed(fileName, lineNumber, + "Synset count must be at least 1, got: " + synsetCount); + } + final int pointerTypeCount = tokens.nextInt("p_cnt", 10); + for (int i = 0; i < pointerTypeCount; i++) { + // The summary symbols are informational; the data file's pointers are authoritative. + tokens.next("ptr_symbol"); + } + tokens.next("sense_cnt"); + tokens.next("tagsense_cnt"); + 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 = "wndb-" + 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)) { + order.add(synsetId); + } + } + 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. + * @return The cleaned lemma. + * @throws InvalidFormatException Thrown if the word carries an unknown marker or is empty. + */ + private static String cleanLemma(String word, String fileName, int lineNumber) + 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); + } + 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("Synset offset must be 8 digits, got: " + 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("Synset offset must be 8 digits, got: " + 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. + * @param fileName The file name, for error reporting. + * @return The file bytes. + * @throws InvalidFormatException Thrown if the file is missing. + * @throws IOException Thrown if reading fails. + */ + private static byte[] readAll(Path file, String fileName) 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( + "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 the next field as an integer in the given radix. + * + * @param field The field name, for error reporting. + * @param radix The numeric radix. + * @return The parsed value. + * @throws InvalidFormatException Thrown if the field is missing or not a valid integer. + */ + int nextInt(String field, int radix) throws InvalidFormatException { + final String token = next(field); + try { + return Integer.parseInt(token, radix); + } catch (NumberFormatException e) { + throw new InvalidFormatException(malformed(fileName, lineNumber, + "Field " + field + " is not a base-" + radix + " integer: " + token).getMessage(), e); + } + } + + /** + * 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 lineNumber) { + } + + 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 minted 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..8a238832fb --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/InMemoryWordNetLexiconTest.java @@ -0,0 +1,89 @@ +/* + * 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 org.junit.jupiter.api.Test; + +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; + +/** + * Exercises the constructor's referential-integrity validation directly, with deliberately + * inconsistent maps a reader would never produce: any future reader relies on these checks, + * so they are pinned independently of both existing 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")); + } + + @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..3d32eb4431 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LemmaFoldingTest.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.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; + +/** + * Pins the shared fold and split behavior every user of {@link LemmaFolding} depends on: + * the exception lists, the sense index keys, and the WN-LMF members parsing all fold and + * split through this one implementation. + */ +public class LemmaFoldingTest { + + @Test + void testFoldLowercasesWithRootLocaleAndTreatsUnderscoreAsSpace() { + 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() { + // The agreement that makes Morphy correct: a key built from a stored written form and a + // query folded at lookup time land on the same canonical shape. + 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)); + } +} 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..0292f7ffe2 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LexiconConcurrencyTest.java @@ -0,0 +1,91 @@ +/* + * 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; + +/** + * Exercises the immutable-after-load contract: one loaded lexicon serves many threads issuing + * concurrent lookups, and every thread observes exactly the single-threaded results. + */ +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 (!"wndb-00001075-n".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("wndb-00001160-n") + .equals(lexicon.related("wndb-00001075-n", 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..b8ed76181d --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/MorphyExceptionsTest.java @@ -0,0 +1,108 @@ +/* + * 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 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 { + + static MorphyExceptions fixture() { + try { + return MorphyExceptions.load(WndbReaderTest.fixtureDirectory()); + } catch (IOException e) { + throw new IllegalStateException("Unexpected IOException reading the fixture lists", e); + } + } + + @Test + void testLookupPerPartOfSpeech() { + final MorphyExceptions exceptions = fixture(); + assertEquals(List.of("mouse"), exceptions.lookup("mice", WordNetPOS.NOUN)); + assertEquals(List.of("go"), exceptions.lookup("went", WordNetPOS.VERB)); + assertEquals(List.of("good"), exceptions.lookup("better", WordNetPOS.ADJECTIVE)); + assertEquals(List.of("well"), exceptions.lookup("best", WordNetPOS.ADVERB)); + // Entries are part-of-speech scoped: went is only a verb exception. + assertTrue(exceptions.lookup("went", WordNetPOS.NOUN).isEmpty()); + assertTrue(exceptions.lookup("dog", WordNetPOS.NOUN).isEmpty()); + } + + @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 { + Files.writeString(tempDir.resolve("noun.exc"), "mice mouse\n"); + Files.writeString(tempDir.resolve("verb.exc"), "went go\n"); + Files.writeString(tempDir.resolve("adj.exc"), "better good\n"); + final InvalidFormatException e = assertThrows(InvalidFormatException.class, + () -> MorphyExceptions.load(tempDir)); + assertTrue(e.getMessage().contains("adv.exc")); + } + + @Test + void testLoadRejectsMalformedLine(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("noun.exc"), "mice mouse\nlonely\n"); + Files.writeString(tempDir.resolve("verb.exc"), "went go\n"); + Files.writeString(tempDir.resolve("adj.exc"), "better good\n"); + Files.writeString(tempDir.resolve("adv.exc"), "best well\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 { + Files.writeString(tempDir.resolve("noun.exc"), "axes axis ax\n"); + Files.writeString(tempDir.resolve("verb.exc"), "went go\n"); + Files.writeString(tempDir.resolve("adj.exc"), "better good\n"); + Files.writeString(tempDir.resolve("adv.exc"), "best well\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..0dd3ca6ff0 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/MorphyLemmatizerTest.java @@ -0,0 +1,182 @@ +/* + * 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 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.assertNull; +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 that merely begin 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"), List.of("NNS", "NNS")); + assertEquals(List.of("O"), lemmas.get(0)); + assertEquals(List.of("berry"), lemmas.get(1)); + } + + @Test + void testWorksIdenticallyOverTheWnLmfLexicon() { + 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"})); + } + + @Test + void testPosFromTagMapping() { + assertEquals(WordNetPOS.NOUN, MorphyLemmatizer.posFromTag("NNP")); + assertEquals(WordNetPOS.VERB, MorphyLemmatizer.posFromTag("VBZ")); + assertEquals(WordNetPOS.ADJECTIVE, MorphyLemmatizer.posFromTag("JJ")); + assertEquals(WordNetPOS.ADVERB, MorphyLemmatizer.posFromTag("RBR")); + assertEquals(WordNetPOS.ADJECTIVE, MorphyLemmatizer.posFromTag("a")); + assertEquals(WordNetPOS.ADJECTIVE, MorphyLemmatizer.posFromTag("s")); + assertEquals(WordNetPOS.ADJECTIVE, MorphyLemmatizer.posFromTag("ADJ")); + assertEquals(WordNetPOS.ADVERB, MorphyLemmatizer.posFromTag("ADV")); + assertEquals(WordNetPOS.ADVERB, MorphyLemmatizer.posFromTag("r")); + assertNull(MorphyLemmatizer.posFromTag("DT")); + assertNull(MorphyLemmatizer.posFromTag("")); + // 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, never adjectives. + assertNull(MorphyLemmatizer.posFromTag("AUX")); + assertNull(MorphyLemmatizer.posFromTag("ADP")); + assertNull(MorphyLemmatizer.posFromTag("SCONJ")); + assertNull(MorphyLemmatizer.posFromTag("SYM")); + assertThrows(IllegalArgumentException.class, () -> MorphyLemmatizer.posFromTag(null)); + } + + @Test + void testConstructorFailsLoudWithoutInputs() { + 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})); + } +} 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..b9f3523cbc --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/ReaderEquivalenceTest.java @@ -0,0 +1,121 @@ +/* + * 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; + +/** + * Asserts that the WN-LMF fixture and the WNDB fixture, which encode the same miniature + * wordnet, load into equivalent lexicon views. Synset ids are reader-minted and intentionally + * differ, so the comparison is structural, joining synsets on their glosses (unique within the + * fixtures) and comparing everything else through that join. + */ +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; + } + + // A synset's relations with targets replaced by their glosses, id-scheme independent. + 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/WnLmfReaderTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfReaderTest.java new file mode 100644 index 0000000000..5d48d17969 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WnLmfReaderTest.java @@ -0,0 +1,405 @@ +/* + * 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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +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 { + + 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); + } + } + + private static LexicalKnowledgeBase parse(String document) throws IOException { + return WnLmfReader.read( + new ByteArrayInputStream(document.getBytes(StandardCharsets.UTF_8)), "inline.xml"); + } + + private static 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 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); + // Not just equal: the identical instance from the synset table, so a loaded lexicon keeps + // one copy of each id no matter how many relations point at it. + assertSame(lexicon.synset("mini-n2").orElseThrow().id(), target); + } + + @Test + void testSenseRelationsAreLiftedToSynsetLevel() { + 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 carries 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 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")); + // The I/O failure must surface as itself, not be misreported as a malformed document. + assertFalse(e instanceof InvalidFormatException); + } + + @Test + void testSkipsDoctypeDeclaration() throws IOException { + // Real Open English WordNet releases ship exactly this shape: a DOCTYPE naming the schema + // DTD by an unreachable SYSTEM identifier (example.invalid is the RFC 2606 reserved domain + // that must never resolve). The reader must parse past it without attempting to fetch it. + 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 { + // A DOCTYPE-declared internal-subset entity is the classic XXE payload: if the parser ever + // honored it, the entity reference below would be replaced by the target file's content. + // With SUPPORT_DTD disabled the declaration itself is never registered, so the reference is + // undefined and parsing must fail loud rather than silently expand it. + 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 parse( + wrap("" + + ""))); + assertTrue(e.getMessage().contains("synset")); + } + + @Test + void testRejectsSenseToUndeclaredSynset() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + ""))); + assertTrue(e.getMessage().contains("t-9")); + } + + @Test + void testRejectsRelationToUndeclaredSynset() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "a feline" + + ""))); + assertTrue(e.getMessage().contains("t-9")); + } + + @Test + void testRejectsUnknownRelationType() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "a feline" + + ""))); + assertTrue(e.getMessage().contains("quasi_synonym")); + } + + @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()); + } + + @Test + void testRejectsUnknownPartOfSpeech() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "a feline"))); + assertTrue(e.getMessage().contains("x")); + } + + @Test + void testRejectsSynsetWithoutMembers() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("orphan"))); + assertTrue(e.getMessage().contains("t-1")); + } + + @Test + void testRejectsDuplicateSynsetId() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "a feline" + + "a repeat"))); + assertTrue(e.getMessage().contains("Duplicate synset id t-1")); + } + + @Test + void testRejectsDuplicateLexicalEntryId() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "" + + "" + + "a feline"))); + assertTrue(e.getMessage().contains("Duplicate lexical entry id t-cat-n")); + } + + @Test + void testRejectsDuplicateSenseId() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "" + + "a feline" + + "a second"))); + assertTrue(e.getMessage().contains("Duplicate sense id t-cat-n-1")); + } + + @Test + void testRejectsSynsetMemberPosMismatch() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "a feline"))); + assertTrue(e.getMessage().contains("t-cat-n")); + assertTrue(e.getMessage().contains("VERB")); + assertTrue(e.getMessage().contains("NOUN")); + } + + @Test + void testRejectsSenseRelationToUndeclaredSense() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "" + + "a feline"))); + assertTrue(e.getMessage().contains("t-ghost-1")); + } + + @Test + void testRejectsLemmaOutsideLexicalEntry() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, + () -> parse(wrap(""))); + assertTrue(e.getMessage().contains("Lemma outside a LexicalEntry")); + } + + @Test + void testRejectsSenseBeforeLemma() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "a feline"))); + assertTrue(e.getMessage().contains("Sense before its entry's Lemma")); + } + + @Test + void testRejectsSenseRelationOutsideSense() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> parse( + wrap("" + + "" + + "" + + "a feline"))); + assertTrue(e.getMessage().contains("SenseRelation outside a Sense")); + } + + @Test + void testRejectsSynsetRelationOutsideSynset() { + final InvalidFormatException e = assertThrows(InvalidFormatException.class, + () -> parse(wrap(""))); + assertTrue(e.getMessage().contains("SynsetRelation outside a Synset")); + } +} 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..5931957e3b --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/WndbReaderTest.java @@ -0,0 +1,273 @@ +/* + * 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 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 { + + private static final String DOG_ID = "wndb-00001075-n"; + private static final String CANID_ID = "wndb-00001160-n"; + + 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); + } + } + + 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); + // Not just equal: the identical instance from the synset table, so a loaded lexicon keeps + // one copy of each id no matter how many pointers reference it. + 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")); + } + + 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. The mutations only ever keep or shrink + // line lengths of the affected line's own fields, so surrounding offsets stay valid. + 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/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..10e039214b --- /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..3868b4d103 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/.gitattributes @@ -0,0 +1,7 @@ +# WNDB is a byte-offset format: each data line embeds its own byte position in the +# file, and WndbReader validates that offset against the actual position. Line-ending +# normalization on checkout (the repo root's `* text=auto`) would insert a CR before +# every LF on Windows, shifting every offset after the first line and breaking every +# fixture that has more than one line. Disable it here so checkout is byte-identical +# on every platform. +* -text 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..e5b3080a88 --- /dev/null +++ b/opennlp-extensions/opennlp-wordnet/src/test/resources/opennlp/wordnet/mini-wndb/noun.exc @@ -0,0 +1,3 @@ +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/pom.xml b/opennlp-extensions/pom.xml index 9afcd3fe3c..8c0e432a58 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 c74e2dc021..bf8a790d5f 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