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));
+  }
 }

Reply via email to