This is an automated email from the ASF dual-hosted git repository. krickert pushed a commit to branch wordnet-expansion in repository https://gitbox.apache.org/repos/asf/opennlp.git
commit 710f03718c068c95f845b72cb05305b7a762c029 Author: Kristian Rickert <[email protected]> AuthorDate: Thu Jul 16 06:41:49 2026 -0400 OPENNLP-1887: Drop expansions whose decay weight underflows to zero and validate the record's documented ranges --- .../main/java/opennlp/wordnet/LexicalExpander.java | 39 +++++++++++++++++- .../java/opennlp/wordnet/LexicalExpanderTest.java | 46 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/LexicalExpander.java b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/LexicalExpander.java index 0aae27c3e..b21f45126 100644 --- a/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/LexicalExpander.java +++ b/opennlp-extensions/opennlp-wordnet/src/main/java/opennlp/wordnet/LexicalExpander.java @@ -40,6 +40,8 @@ import opennlp.tools.wordnet.WordNetRelation; * <p>Each {@link Expansion} carries a deterministic heuristic weight, not a probability: the * first sense of a term starts at {@code 1.0}, each later sense is multiplied by the configurable * sense decay, and every hypernym or hyponym step multiplies by the configurable depth decay. + * A decay product that underflows to zero in double arithmetic carries no ranking signal, so + * such expansions are dropped rather than emitted outside the {@code (0, 1]} weight range. * When the term itself is not in the lexicon and a {@link Lemmatizer} is configured, the term is * lemmatized and the lemma expanded instead; the lemmatizer is invoked with the * {@link WordNetPOS} name as the tag.</p> @@ -80,6 +82,31 @@ public final class LexicalExpander { * @param weight The heuristic weight in {@code (0, 1]}; higher is closer to the input term. */ public record Expansion(String term, Kind kind, int depth, int senseRank, double weight) { + + /** + * Validates every component against the documented ranges. + * + * @throws IllegalArgumentException Thrown if {@code term} is {@code null} or blank, + * {@code kind} is {@code null}, {@code depth} or {@code senseRank} is negative, or + * {@code weight} is not in {@code (0, 1]}. + */ + public Expansion { + if (term == null || term.isBlank()) { + throw new IllegalArgumentException("term must not be null or blank"); + } + if (kind == null) { + throw new IllegalArgumentException("kind must not be null"); + } + if (depth < 0) { + throw new IllegalArgumentException("depth must not be negative: " + depth); + } + if (senseRank < 0) { + throw new IllegalArgumentException("senseRank must not be negative: " + senseRank); + } + if (!(weight > 0.0 && weight <= 1.0)) { + throw new IllegalArgumentException("weight must be in (0, 1]: " + weight); + } + } } private final LexicalKnowledgeBase lexicon; @@ -226,6 +253,12 @@ public final class LexicalExpander { */ private void expandSense(Synset sense, int rank, double senseWeight, Map<String, Expansion> best, Set<String> excluded) { + if (senseWeight == 0.0) { + // The decay product underflowed to zero in double arithmetic. A zero weight + // carries no ranking signal, so the sense and everything derived from it is + // dropped instead of emitted outside the documented (0, 1] weight range. + return; + } for (final String lemma : sense.lemmas()) { offer(best, excluded, new Expansion(lemma, Kind.SYNONYM, 0, rank, senseWeight)); } @@ -238,6 +271,10 @@ public final class LexicalExpander { final ArrayDeque<String> next = new ArrayDeque<>(); for (int depth = 1; depth <= hypernymDepth && !frontier.isEmpty(); depth++) { final double depthWeight = senseWeight * Math.pow(depthDecay, depth); + if (depthWeight == 0.0) { + // Deeper levels only shrink further, so the walk stops at the first underflow. + break; + } while (!frontier.isEmpty()) { final String id = frontier.poll(); if (!visited.add(id)) { @@ -258,7 +295,7 @@ public final class LexicalExpander { } } - if (includeHyponyms) { + if (includeHyponyms && senseWeight * depthDecay > 0.0) { final double hyponymWeight = senseWeight * depthDecay; final List<String> children = new ArrayList<>(sense.related(WordNetRelation.HYPONYM)); children.addAll(sense.related(WordNetRelation.INSTANCE_HYPONYM)); diff --git a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LexicalExpanderTest.java b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LexicalExpanderTest.java index ee80dca47..d5fcad036 100644 --- a/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LexicalExpanderTest.java +++ b/opennlp-extensions/opennlp-wordnet/src/test/java/opennlp/wordnet/LexicalExpanderTest.java @@ -282,4 +282,50 @@ class LexicalExpanderTest { assertThrows(IllegalArgumentException.class, () -> expander.expand(" ")); assertThrows(IllegalArgumentException.class, () -> expander.expand("dog", null)); } + + /** + * Verifies that decay products which underflow to zero are dropped instead of emitted: + * with the smallest positive depth decay, the first hypernym level keeps the smallest + * positive weight while the second level underflows to zero and never appears, and no + * reported expansion carries a weight outside the documented range. + */ + @Test + void testUnderflowedWeightsAreDropped() { + final List<Expansion> expansions = LexicalExpander.builder(lexicon()) + .depthDecay(Double.MIN_VALUE).hypernymDepth(2).maxSenses(1).build() + .expand("domestic dog", WordNetPOS.NOUN); + + final Expansion canid = find(expansions, "canid"); + assertNotNull(canid); + assertEquals(Double.MIN_VALUE, canid.weight(), 0.0); + assertNull(find(expansions, "carnivore")); + for (final Expansion expansion : expansions) { + assertTrue(expansion.weight() > 0.0 && expansion.weight() <= 1.0, + "weight out of (0, 1]: " + expansion); + } + } + + /** + * Verifies that the {@link Expansion} record rejects every component + * outside its documented range with a loud exception. + */ + @Test + void testExpansionValidatesItsComponents() { + assertThrows(IllegalArgumentException.class, () -> new Expansion( + null, Kind.SYNONYM, 0, 0, 1.0)); + assertThrows(IllegalArgumentException.class, () -> new Expansion( + " ", Kind.SYNONYM, 0, 0, 1.0)); + assertThrows(IllegalArgumentException.class, () -> new Expansion( + "dog", null, 0, 0, 1.0)); + assertThrows(IllegalArgumentException.class, () -> new Expansion( + "dog", Kind.SYNONYM, -1, 0, 1.0)); + assertThrows(IllegalArgumentException.class, () -> new Expansion( + "dog", Kind.SYNONYM, 0, -1, 1.0)); + assertThrows(IllegalArgumentException.class, () -> new Expansion( + "dog", Kind.SYNONYM, 0, 0, 0.0)); + assertThrows(IllegalArgumentException.class, () -> new Expansion( + "dog", Kind.SYNONYM, 0, 0, 1.5)); + assertThrows(IllegalArgumentException.class, () -> new Expansion( + "dog", Kind.SYNONYM, 0, 0, Double.NaN)); + } }
