This is an automated email from the ASF dual-hosted git repository.

mawiesne pushed a commit to branch OPENNLP-1850-2b-term
in repository https://gitbox.apache.org/repos/asf/opennlp.git


The following commit(s) were added to refs/heads/OPENNLP-1850-2b-term by this 
push:
     new 56c472e64 OPENNLP-1864: Per-language NormalizationProfile registry 
(#1112)
56c472e64 is described below

commit 56c472e64985c7e45b3d77eb9cbdfcc9e8ece600
Author: Kristian Rickert <[email protected]>
AuthorDate: Mon Jul 6 08:37:25 2026 -0400

    OPENNLP-1864: Per-language NormalizationProfile registry (#1112)
    
    * OPENNLP-1864: Per-language NormalizationProfile registry (2c)
    
    * For broader context, see epic: 
https://issues.apache.org/jira/browse/OPENNLP-1850
---
 .../util/normalizer/NormalizationProfile.java      |  85 +++++++++
 .../util/normalizer/NormalizationProfiles.java     | 139 ++++++++++++++
 .../util/normalizer/NormalizationProfilesTest.java | 204 +++++++++++++++++++++
 3 files changed, 428 insertions(+)

diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
new file mode 100644
index 000000000..e7a47477e
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
@@ -0,0 +1,85 @@
+/*
+ * 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.util.normalizer;
+
+import opennlp.tools.stemmer.Stemmer;
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+
+/**
+ * Per-language normalization settings, mirroring how OpenNLP already selects 
a Snowball stemmer by
+ * language. A profile pairs a language with its Snowball {@link 
SnowballStemmer.ALGORITHM} and the
+ * diacritic fold appropriate for that language (if any).
+ *
+ * <p>The {@code accentFold} normalizer is the language's diacritic transform 
for a matching form, or
+ * {@code null} when folding is not appropriate. It is the generic
+ * {@link AccentFoldCharSequenceNormalizer} for English and the major Romance 
languages (where
+ * accented letters are matching variants of their base letter), the 
German-specific
+ * {@link GermanUmlautCharSequenceNormalizer} (a-umlaut to {@code ae}, eszett 
to {@code ss}, ...) for
+ * German, and {@code null} where diacritics mark distinct letters (the Nordic 
languages and the
+ * non-Latin scripts), because folding there is language-wrong. This is a 
search-recall choice, not a
+ * statement of linguistic correctness; callers can build a {@link 
TermAnalyzer} directly to
+ * override it.</p>
+ *
+ * @param language         The language, as an ISO 639-3 code (for example 
{@code "eng"}). Must
+ *                         not be {@code null} or blank.
+ * @param stemmerAlgorithm The Snowball algorithm for the language. Must not 
be {@code null}.
+ * @param accentFold       The diacritic fold for the language, or {@code 
null} for none.
+ */
+public record NormalizationProfile(String language, SnowballStemmer.ALGORITHM 
stemmerAlgorithm,
+    CharSequenceNormalizer accentFold) {
+
+  /**
+   * Validates the components.
+   *
+   * @throws IllegalArgumentException if {@code language} or {@code 
stemmerAlgorithm} is
+   *     {@code null}, or if {@code language} is blank.
+   */
+  public NormalizationProfile {
+    if (language == null) {
+      throw new IllegalArgumentException("language must not be null");
+    }
+    if (stemmerAlgorithm == null) {
+      throw new IllegalArgumentException("stemmerAlgorithm must not be null");
+    }
+    if (language.isBlank()) {
+      throw new IllegalArgumentException("language must not be blank");
+    }
+  }
+
+  /**
+   * {@return a new {@link Stemmer} for this language} A fresh instance is 
returned on each call
+   * because the Snowball stemmers are stateful and not thread-safe.
+   */
+  public Stemmer newStemmer() {
+    return new SnowballStemmer(stemmerAlgorithm);
+  }
+
+  /**
+   * Returns a matching analyzer for this language: NFC, case folding, the 
language's
+   * {@linkplain #accentFold() diacritic fold} when it has one, then stemming. 
Each call builds an
+   * independent analyzer with its own stemmer, so use one per thread when 
stemming.
+   *
+   * @return the analyzer.
+   */
+  public TermAnalyzer matchingAnalyzer() {
+    final TermAnalyzer.Builder builder = 
TermAnalyzer.builder().nfc().caseFold();
+    if (accentFold != null) {
+      builder.transform(Dimension.ACCENT_FOLD, accentFold);
+    }
+    return builder.stem(newStemmer()).build();
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfiles.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfiles.java
new file mode 100644
index 000000000..70b2f560c
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfiles.java
@@ -0,0 +1,139 @@
+/*
+ * 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.util.normalizer;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.MissingResourceException;
+import java.util.Optional;
+import java.util.Set;
+
+import opennlp.tools.langdetect.LanguageDetector;
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+
+/**
+ * A registry of {@link NormalizationProfile}s by language, with 
detection-based fallback. This is
+ * the language dispatch the design note calls for: pick the profile for a 
requested language, or
+ * detect the language with a {@link LanguageDetector} when it is unspecified. 
The covered languages
+ * are the Snowball stemmer algorithms that name a natural language -- every
+ * {@link SnowballStemmer.ALGORITHM} except {@code PORTER}, which is an 
English-only algorithm
+ * variant rather than a distinct language. Several codes can map to one 
algorithm (the three
+ * Norwegian written standards all use {@code NORWEGIAN}).
+ *
+ * <p>Profiles are keyed by ISO 639-3 code (what {@link LanguageDetector} 
produces);
+ * {@link #forLanguage(String)} also accepts ISO 639-1 two-letter codes.</p>
+ */
+public final class NormalizationProfiles {
+
+  private static final Map<String, NormalizationProfile> BY_LANGUAGE = build();
+
+  private NormalizationProfiles() {
+  }
+
+  private static Map<String, NormalizationProfile> build() {
+    final Map<String, NormalizationProfile> map = new HashMap<>();
+    // The generic accent fold is used for English and the major Romance 
languages, German uses its
+    // own ae/oe/ue/ss fold, and folding is disabled elsewhere (Nordic, 
non-Latin) where diacritics
+    // mark distinct letters.
+    final CharSequenceNormalizer latin = 
AccentFoldCharSequenceNormalizer.getInstance();
+    final CharSequenceNormalizer german = 
GermanUmlautCharSequenceNormalizer.getInstance();
+    add(map, "ara", SnowballStemmer.ALGORITHM.ARABIC, null);
+    add(map, "cat", SnowballStemmer.ALGORITHM.CATALAN, latin);
+    add(map, "dan", SnowballStemmer.ALGORITHM.DANISH, null);
+    add(map, "deu", SnowballStemmer.ALGORITHM.GERMAN, german);
+    add(map, "ell", SnowballStemmer.ALGORITHM.GREEK, null);
+    add(map, "eng", SnowballStemmer.ALGORITHM.ENGLISH, latin);
+    add(map, "fin", SnowballStemmer.ALGORITHM.FINNISH, null);
+    add(map, "fra", SnowballStemmer.ALGORITHM.FRENCH, latin);
+    add(map, "gle", SnowballStemmer.ALGORITHM.IRISH, null);
+    add(map, "hun", SnowballStemmer.ALGORITHM.HUNGARIAN, null);
+    add(map, "ind", SnowballStemmer.ALGORITHM.INDONESIAN, null);
+    add(map, "ita", SnowballStemmer.ALGORITHM.ITALIAN, latin);
+    add(map, "nld", SnowballStemmer.ALGORITHM.DUTCH, null);
+    add(map, "nob", SnowballStemmer.ALGORITHM.NORWEGIAN, null); // Bokmal 
(nb), the dominant standard
+    add(map, "nno", SnowballStemmer.ALGORITHM.NORWEGIAN, null); // Nynorsk (nn)
+    add(map, "nor", SnowballStemmer.ALGORITHM.NORWEGIAN, null); // 
macrolanguage / 639-1 "no"
+    add(map, "por", SnowballStemmer.ALGORITHM.PORTUGUESE, latin);
+    add(map, "ron", SnowballStemmer.ALGORITHM.ROMANIAN, null);
+    add(map, "rus", SnowballStemmer.ALGORITHM.RUSSIAN, null);
+    add(map, "spa", SnowballStemmer.ALGORITHM.SPANISH, latin);
+    add(map, "swe", SnowballStemmer.ALGORITHM.SWEDISH, null);
+    // Turkish diacritics are distinct letters, so there is no accent fold. 
The matching analyzer's
+    // case fold stays locale-generic: the Turkish dotted/dotless-i pair folds 
by the Unicode default
+    // rather than Turkish rules -- a deliberate recall choice, not 
Turkish-correct casing.
+    add(map, "tur", SnowballStemmer.ALGORITHM.TURKISH, null);
+    return Map.copyOf(map);
+  }
+
+  private static void add(Map<String, NormalizationProfile> map, String 
language,
+      SnowballStemmer.ALGORITHM algorithm, CharSequenceNormalizer accentFold) {
+    map.put(language, new NormalizationProfile(language, algorithm, 
accentFold));
+  }
+
+  /**
+   * Returns the {@link NormalizationProfile profile} for a language.
+   *
+   * @param language An ISO 639-3 or ISO 639-1 language code; 
case-insensitive. Must not be
+   *                 {@code null}.
+   * @return The profile, or empty if the language has no Snowball stemmer.
+   * @throws IllegalArgumentException if {@code language} is {@code null}.
+   */
+  public static Optional<NormalizationProfile> forLanguage(String language) {
+    if (language == null) {
+      throw new IllegalArgumentException("language must not be null");
+    }
+    String code = language.strip().toLowerCase(Locale.ROOT);
+    if (code.length() == 2) {
+      try {
+        final String iso3 = Locale.of(code).getISO3Language();
+        if (!iso3.isEmpty()) {
+          code = iso3;
+        }
+      } catch (MissingResourceException ignored) {
+        // No ISO 639-3 code for this two-letter code; fall through and look 
up as given.
+      }
+    }
+    return Optional.ofNullable(BY_LANGUAGE.get(code));
+  }
+
+  /**
+   * Detects the language of {@code text} and returns its {@link 
NormalizationProfile profile}.
+   *
+   * @param text     The text to detect. Must not be {@code null}.
+   * @param detector The language detector to use. Must not be {@code null}.
+   * @return The profile for the detected language, or empty if it has no 
Snowball stemmer.
+   * @throws IllegalArgumentException if {@code text} or {@code detector} is 
{@code null}.
+   */
+  public static Optional<NormalizationProfile> detect(CharSequence text,
+      LanguageDetector detector) {
+    if (text == null) {
+      throw new IllegalArgumentException("text must not be null");
+    }
+    if (detector == null) {
+      throw new IllegalArgumentException("detector must not be null");
+    }
+    return forLanguage(detector.predictLanguage(text).getLang());
+  }
+
+  /**
+   * {@return the ISO 639-3 codes of the supported languages}
+   */
+  public static Set<String> supportedLanguages() {
+    return BY_LANGUAGE.keySet();
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
new file mode 100644
index 000000000..ba39a5cf1
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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.util.normalizer;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.langdetect.Language;
+import opennlp.tools.langdetect.LanguageDetector;
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+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 NormalizationProfilesTest {
+
+  @Test
+  void testEnglishUsesTheGenericAccentFold() {
+    final NormalizationProfile profile = 
NormalizationProfiles.forLanguage("eng").orElseThrow();
+    assertEquals(SnowballStemmer.ALGORITHM.ENGLISH, 
profile.stemmerAlgorithm());
+    assertSame(AccentFoldCharSequenceNormalizer.getInstance(), 
profile.accentFold());
+    assertEquals(List.of(Dimension.NFC, Dimension.CASE_FOLD, 
Dimension.ACCENT_FOLD, Dimension.STEM),
+        profile.matchingAnalyzer().dimensions());
+  }
+
+  @Test
+  void testProfileRejectsInvalidComponents() {
+    assertThrows(IllegalArgumentException.class,
+        () -> new NormalizationProfile(null, 
SnowballStemmer.ALGORITHM.ENGLISH, null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new NormalizationProfile("eng", null, null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new NormalizationProfile("  ", 
SnowballStemmer.ALGORITHM.ENGLISH, null));
+  }
+
+  @Test
+  void testTwoLetterCodeResolvesToProfile() {
+    assertEquals(SnowballStemmer.ALGORITHM.GERMAN,
+        
NormalizationProfiles.forLanguage("de").orElseThrow().stemmerAlgorithm());
+  }
+
+  @Test
+  void testNorwegianWrittenStandardsResolveToTheNorwegianProfile() {
+    // "nb" (Bokmal) and "nn" (Nynorsk) are the standard modern written codes; 
both convert to the
+    // ISO 639-3 codes "nob"/"nno", which must resolve to Norwegian even 
though the registry also
+    // keys the "nor" macrolanguage. Without the aliases, forLanguage("nb") 
returns empty -- and
+    // detect()'s "nob"/"nno" output gets no profile for a language the 
registry claims to support.
+    assertEquals(SnowballStemmer.ALGORITHM.NORWEGIAN,
+        
NormalizationProfiles.forLanguage("nb").orElseThrow().stemmerAlgorithm());
+    assertEquals(SnowballStemmer.ALGORITHM.NORWEGIAN,
+        
NormalizationProfiles.forLanguage("nn").orElseThrow().stemmerAlgorithm());
+    assertTrue(NormalizationProfiles.forLanguage("nob").isPresent());
+    assertTrue(NormalizationProfiles.forLanguage("nno").isPresent());
+  }
+
+  @Test
+  void testGermanUsesTheGermanSpecificFold() {
+    final NormalizationProfile profile = 
NormalizationProfiles.forLanguage("deu").orElseThrow();
+    assertSame(GermanUmlautCharSequenceNormalizer.getInstance(), 
profile.accentFold());
+    assertEquals(List.of(Dimension.NFC, Dimension.CASE_FOLD, 
Dimension.ACCENT_FOLD, Dimension.STEM),
+        profile.matchingAnalyzer().dimensions());
+  }
+
+  @Test
+  void testRomanceLanguagesUseTheGenericFold() {
+    for (final String language : List.of("fra", "spa", "por", "ita", "cat")) {
+      assertSame(AccentFoldCharSequenceNormalizer.getInstance(),
+          
NormalizationProfiles.forLanguage(language).orElseThrow().accentFold());
+    }
+  }
+
+  @Test
+  void testNordicLanguageHasNoFold() {
+    final NormalizationProfile swedish = 
NormalizationProfiles.forLanguage("swe").orElseThrow();
+    assertNull(swedish.accentFold());
+    assertEquals(List.of(Dimension.NFC, Dimension.CASE_FOLD, Dimension.STEM),
+        swedish.matchingAnalyzer().dimensions());
+  }
+
+  @Test
+  void testUnsupportedLanguageIsEmpty() {
+    assertTrue(NormalizationProfiles.forLanguage("jpn").isEmpty());
+    assertTrue(NormalizationProfiles.forLanguage("zzz").isEmpty());
+  }
+
+  @Test
+  void testMatchingAnalyzerStemsThroughTheChain() {
+    final NormalizationProfile english = 
NormalizationProfiles.forLanguage("eng").orElseThrow();
+    assertEquals("cat", 
english.matchingAnalyzer().analyze("Cats").get(0).normalized());
+  }
+
+  @Test
+  void testDetectDispatchesThroughTheDetector() {
+    final LanguageDetector detector = new LanguageDetector() {
+      @Override
+      public Language[] predictLanguages(CharSequence content) {
+        return new Language[] {new Language("deu")};
+      }
+
+      @Override
+      public Language predictLanguage(CharSequence content) {
+        return new Language("deu");
+      }
+
+      @Override
+      public String[] getSupportedLanguages() {
+        return new String[] {"deu"};
+      }
+    };
+    final NormalizationProfile profile =
+        NormalizationProfiles.detect("Guten Tag", detector).orElseThrow();
+    assertEquals(SnowballStemmer.ALGORITHM.GERMAN, profile.stemmerAlgorithm());
+  }
+
+  @Test
+  void testDetectUnsupportedLanguageIsEmpty() {
+    final LanguageDetector detector = new LanguageDetector() {
+      @Override
+      public Language[] predictLanguages(CharSequence content) {
+        return new Language[] {new Language("jpn")};
+      }
+
+      @Override
+      public Language predictLanguage(CharSequence content) {
+        return new Language("jpn");
+      }
+
+      @Override
+      public String[] getSupportedLanguages() {
+        return new String[] {"jpn"};
+      }
+    };
+    assertTrue(NormalizationProfiles.detect("text", detector).isEmpty());
+  }
+
+  @Test
+  void testSupportedLanguagesCoverEverySnowballLanguage() {
+    // Every Snowball algorithm that names a language has a profile; PORTER is 
an English-only
+    // algorithm variant, not a language, so it is the sole expected omission. 
Deriving the
+    // expectation from the enum makes this fail loudly if a future algorithm 
is added unmapped.
+    final Set<SnowballStemmer.ALGORITHM> covered = 
NormalizationProfiles.supportedLanguages().stream()
+        .map(code -> 
NormalizationProfiles.forLanguage(code).orElseThrow().stemmerAlgorithm())
+        .collect(Collectors.toCollection(() -> 
EnumSet.noneOf(SnowballStemmer.ALGORITHM.class)));
+    
assertEquals(EnumSet.complementOf(EnumSet.of(SnowballStemmer.ALGORITHM.PORTER)),
 covered);
+    // The three Norwegian written codes share the single NORWEGIAN algorithm.
+    
assertTrue(NormalizationProfiles.supportedLanguages().containsAll(List.of("nob",
 "nno", "nor")));
+  }
+
+  @Test
+  void testTwoLetterCodeWithNoIso3FallsBackToRawLookup() {
+    // A two-letter code with no ISO 639-3 mapping makes getISO3Language() 
throw
+    // MissingResourceException; forLanguage() must catch it and fall through 
to a raw lookup
+    // (which finds nothing here) rather than propagating the exception.
+    assertTrue(NormalizationProfiles.forLanguage("qq").isEmpty());
+  }
+
+  @Test
+  void testForLanguageRejectsNull() {
+    assertThrows(IllegalArgumentException.class, () -> 
NormalizationProfiles.forLanguage(null));
+  }
+
+  @Test
+  void testDetectRejectsNull() {
+    final LanguageDetector detector = new LanguageDetector() {
+      @Override
+      public Language[] predictLanguages(CharSequence content) {
+        return new Language[0];
+      }
+
+      @Override
+      public Language predictLanguage(CharSequence content) {
+        return new Language("eng");
+      }
+
+      @Override
+      public String[] getSupportedLanguages() {
+        return new String[0];
+      }
+    };
+    assertThrows(IllegalArgumentException.class, () -> 
NormalizationProfiles.detect(null, detector));
+    assertThrows(IllegalArgumentException.class, () -> 
NormalizationProfiles.detect("text", null));
+  }
+}

Reply via email to