You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opennlp.apache.org by kr...@apache.org on 2026/07/16 10:47:37 UTC
(opennlp) 07/07: OPENNLP-1887: Drop expansions whose decay weight underflows to zero and validate the record's documented ranges
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 <kr...@gmail.com>
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));
+ }
}