feat(benchmarks): expand multilingual stemming quality evaluation

* cover all Radixor dictionary languages
* add PRIMARY_OUTPUT, ANY_CANDIDATE, and ALL_CANDIDATES policies
* measure pairwise over-stemming and under-stemming
* add balanced accuracy and complementary quality metrics
* compare single-output and multi-output stemmers fairly
* improve result validation, reporting, and documentation
* move stemming quality tests into the standard test source set
* preserve the existing JMH benchmark structure and badge output
This commit is contained in:
2026-07-20 23:20:17 +02:00
parent 6d35f01303
commit 05f3855b99
62 changed files with 11472 additions and 75 deletions

View File

@@ -254,7 +254,9 @@ public class HunspellStemmerComparisonBenchmarkQuality {
outputs[inputIndex] = termAttribute.toString();
recordedForPosition = true;
}
blackhole.consume(termAttribute);
if (blackhole != null) {
blackhole.consume(termAttribute);
}
}
output.end();
output.close();
@@ -285,6 +287,53 @@ public class HunspellStemmerComparisonBenchmarkQuality {
}
}
/**
* Stems one analytical batch through the exact Hunspell quality-benchmark path.
*
* @param languageCase declared Hunspell language case
* @param tokens original dictionary forms
* @return first Hunspell output per input form
* @throws IOException if dictionary parsing or token streaming fails
*/
static String[] stemForQuality(final HunspellLanguageCase languageCase, final String[] tokens) throws IOException {
try {
return firstHunspellOutputs(tokens, loadDictionary(languageCase), null);
} catch (ParseException exception) {
throw new IOException("Unable to parse the JMH Hunspell dictionary for " + languageCase + ".", exception);
}
}
/** Returns all distinct Hunspell stems per token through the quality-benchmark dictionary. */
static List<List<String>> stemCandidatesForQuality(final HunspellLanguageCase languageCase,
final String[] tokens) throws IOException {
try {
final Dictionary dictionary = loadDictionary(languageCase);
final List<java.util.LinkedHashSet<String>> candidates = new java.util.ArrayList<>(tokens.length);
for (int index = 0; index < tokens.length; index++) { candidates.add(new java.util.LinkedHashSet<>()); }
final BenchmarkTokenStream input = new BenchmarkTokenStream(tokens);
final TokenStream output = new HunspellStemFilter(new LowerCaseFilter(input), dictionary, true);
final CharTermAttribute term = output.addAttribute(CharTermAttribute.class);
final PositionIncrementAttribute position = output.addAttribute(PositionIncrementAttribute.class);
int inputIndex = -1;
output.reset();
while (output.incrementToken()) {
if (position.getPositionIncrement() > 0) { inputIndex += position.getPositionIncrement(); }
if (inputIndex >= 0 && inputIndex < candidates.size()) { candidates.get(inputIndex).add(term.toString()); }
}
output.end();
output.close();
final String[] primary = firstHunspellOutputs(tokens, dictionary, null);
final List<List<String>> result = new java.util.ArrayList<>(tokens.length);
for (int index = 0; index < tokens.length; index++) {
candidates.get(index).add(primary[index]);
result.add(List.copyOf(candidates.get(index)));
}
return List.copyOf(result);
} catch (ParseException exception) {
throw new IOException("Unable to parse the JMH Hunspell dictionary for " + languageCase + ".", exception);
}
}
/**
* Opens a required classpath resource.
*
@@ -303,7 +352,7 @@ public class HunspellStemmerComparisonBenchmarkQuality {
/**
* Benchmark language mapping.
*/
private enum HunspellLanguageCase {
enum HunspellLanguageCase {
/**
* English Hunspell dictionary over the Radixor English corpus.

View File

@@ -0,0 +1,133 @@
package org.egothor.stemmer.benchmark;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.ArrayList;
import java.util.EnumSet;
import org.egothor.stemmer.StemmerPatchTrieLoader.Language;
/** Authoritative analytical view of the candidate matrix defined by the JMH quality benchmark. */
public final class QualityStemmerMatrix {
/** Utility class. */
private QualityStemmerMatrix() {
throw new AssertionError("No instances.");
}
/**
* Returns every currently registered JMH quality candidate in declaration order.
* The returned list is immutable and is derived directly from the benchmark enum.
*
* @return complete immutable candidate list
*/
public static List<Candidate> candidates() {
final List<Candidate> candidates = new ArrayList<>();
Arrays.stream(StemmerComparisonBenchmarkQuality.QualityCandidate.values())
.map(candidate -> new Candidate(candidate.name(), candidate.radixorLanguage(),
() -> adapt(candidate.createStemmer())))
.forEach(candidates::add);
final EnumSet<Language> registeredRadixorLanguages = candidates.stream()
.filter(candidate -> candidate.name().endsWith("_RADIXOR"))
.map(Candidate::language).collect(() -> EnumSet.noneOf(Language.class), EnumSet::add, EnumSet::addAll);
Arrays.stream(Language.values()).filter(language -> !registeredRadixorLanguages.contains(language))
.map(language -> new Candidate(language.name() + "_RADIXOR", language,
() -> adapt(StemmerComparisonBenchmarkQuality.createRadixorQualityStemmer(language))))
.forEach(candidates::add);
Arrays.stream(HunspellStemmerComparisonBenchmarkQuality.HunspellLanguageCase.values())
.map(languageCase -> new Candidate("HUNSPELL_" + languageCase.name() + "_LUCENE_FILTER",
languageCase.radixorLanguage(),
() -> new BatchStemmer() {
/** {@inheritDoc} */
@Override public String[] stem(final String[] forms) throws IOException {
return HunspellStemmerComparisonBenchmarkQuality.stemForQuality(languageCase, forms);
}
/** {@inheritDoc} */
@Override public List<List<String>> stemCandidates(final String[] forms) throws IOException {
return HunspellStemmerComparisonBenchmarkQuality.stemCandidatesForQuality(languageCase, forms);
}
/** {@inheritDoc} */
@Override public boolean supportsMultipleOutputs() { return true; }
}))
.forEach(candidates::add);
return List.copyOf(candidates);
}
/** Adapts one authoritative general-matrix stemmer without changing capability semantics. */
private static BatchStemmer adapt(final StemmerComparisonBenchmarkQuality.CandidateStemmer stemmer) {
return new BatchStemmer() {
/** {@inheritDoc} */
@Override public String[] stem(final String[] forms) throws IOException { return stemmer.stem(forms); }
/** {@inheritDoc} */
@Override public List<List<String>> stemCandidates(final String[] forms) throws IOException {
return stemmer.stemCandidates(forms);
}
/** {@inheritDoc} */
@Override public boolean supportsMultipleOutputs() { return stemmer.supportsMultipleOutputs(); }
};
}
/** One JMH candidate and its authoritative dictionary-language mapping. */
public static final class Candidate {
private final String name;
private final Language language;
private final StemmerFactory factory;
/** Creates an immutable facade over one benchmark candidate. */
private Candidate(final String name, final Language language,
final StemmerFactory factory) {
this.name = Objects.requireNonNull(name, "name");
this.language = Objects.requireNonNull(language, "language");
this.factory = Objects.requireNonNull(factory, "factory");
}
/** @return stable JMH candidate name */
public String name() {
return this.name;
}
/** @return registered Radixor gold-standard dictionary language */
public Language language() {
return this.language;
}
/**
* Creates a scenario-confined adapter using exactly the JMH factory and preprocessing path.
*
* @return sequential batch stemmer
* @throws IOException if benchmark-only resources cannot be loaded
*/
public BatchStemmer createStemmer() throws IOException {
return this.factory.create();
}
}
/** Internal checked factory shared by the JMH quality registries. */
@FunctionalInterface
private interface StemmerFactory {
/** @return a scenario-confined adapter @throws IOException if resources fail */
BatchStemmer create() throws IOException;
}
/** Sequential, scenario-confined batch stemmer contract. */
@FunctionalInterface
public interface BatchStemmer {
/**
* Stems all supplied forms in order.
*
* @param forms input forms, never {@code null}
* @return one non-null output per form
* @throws IOException when the JMH adapter fails
*/
String[] stem(String[] forms) throws IOException;
/** Returns complete candidate sets; single-output adapters return singleton sets. */
default List<List<String>> stemCandidates(final String[] forms) throws IOException {
return Arrays.stream(stem(forms)).map(List::of).toList();
}
/** @return whether this adapter exposes genuine alternative outputs */
default boolean supportsMultipleOutputs() { return false; }
}
}

View File

@@ -30,7 +30,10 @@
******************************************************************************/
package org.egothor.stemmer.benchmark;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.egothor.stemmer.CompiledPatchCommand;
import org.egothor.stemmer.FrequencyTrie;
@@ -79,4 +82,22 @@ final class RadixorBenchmarkStemmer {
}
return patch.apply(token);
}
/**
* Returns every distinct candidate stem from the ranked {@code getAll} path,
* always including the deterministic primary output.
*
* @param token original input token
* @return immutable candidate list in deterministic ranked order
*/
List<String> stemAll(final String token) {
final String primary = stem(token);
final Set<String> candidates = new LinkedHashSet<>();
candidates.add(primary);
final CompiledPatchCommand[] patches = this.trie.getAll(token);
for (CompiledPatchCommand patch : patches) {
candidates.add(patch.preservesAllSources() ? token : patch.apply(token));
}
return List.copyOf(candidates);
}
}

View File

@@ -313,7 +313,7 @@ public class StemmerComparisonBenchmarkQuality {
/**
* Candidate stemmers that can be evaluated against a Radixor resource.
*/
private enum QualityCandidate {
enum QualityCandidate {
ENGLISH_RADIXOR(StemmerPatchTrieLoader.Language.US_UK),
ENGLISH_SNOWBALL_ORIGINAL_PORTER(StemmerPatchTrieLoader.Language.US_UK),
ENGLISH_SNOWBALL_PORTER2(StemmerPatchTrieLoader.Language.US_UK),
@@ -444,9 +444,9 @@ public class StemmerComparisonBenchmarkQuality {
* @return quality evaluator
* @throws IOException if stemmer resources cannot be loaded
*/
QualityEvaluator createEvaluator() throws IOException {
CandidateStemmer createStemmer() throws IOException {
if (name().endsWith("_RADIXOR")) {
return direct(createRadixorStemmer(this.radixorLanguage));
return radixor(createRadixorStemmer(this.radixorLanguage));
}
if (name().endsWith("_DIRECT") && this.snowballLanguageCase != null) {
return direct(this.snowballLanguageCase.createDirectStemmer()::stem);
@@ -508,7 +508,7 @@ public class StemmerComparisonBenchmarkQuality {
}
case POLISH_LUCENE_STEMPEL_FILTER ->
tokenFilter(input -> new StempelFilter(input, new StempelStemmer(PolishAnalyzer.getDefaultTable())));
case POLISH_LUCENE_MORFOLOGIK_FILTER -> tokenFilter(MorfologikFilter::new);
case POLISH_LUCENE_MORFOLOGIK_FILTER -> tokenFilter(MorfologikFilter::new, true);
case PORTUGUESE_LUCENE_PORTUGUESE_STEM_FILTER ->
tokenFilter(input -> new PortugueseStemFilter(lowercase(input)));
case PORTUGUESE_LUCENE_PORTUGUESE_LIGHT_STEM_FILTER ->
@@ -523,15 +523,20 @@ public class StemmerComparisonBenchmarkQuality {
tokenFilter(input -> new SwedishMinimalStemFilter(lowercase(input)));
case UKRAINIAN_MORFOLOGIK_DIRECT -> {
final DictionaryLookup lookup = new DictionaryLookup(loadUkrainianMorfologikDictionary());
yield direct(token -> firstMorfologikStem(token, lookup));
yield morphologik(lookup);
}
case UKRAINIAN_LUCENE_MORFOLOGIK_FILTER -> {
final Dictionary dictionary = loadUkrainianMorfologikDictionary();
yield tokenFilter(input -> new MorfologikFilter(input, dictionary));
yield tokenFilter(input -> new MorfologikFilter(input, dictionary), true);
}
default -> throw new IllegalStateException("No evaluator for " + this + ".");
};
}
/** Creates the exact-root evaluator used by the JMH quality benchmark. */
QualityEvaluator createEvaluator() throws IOException {
return exactRootEvaluator(createStemmer());
}
}
/**
@@ -575,6 +580,68 @@ public class StemmerComparisonBenchmarkQuality {
QualityResult evaluate(LanguageBenchmarkCorpus.Corpus corpus, Blackhole blackhole) throws IOException;
}
/** Stateful candidate adapter confined to one sequential evaluation scenario. */
@FunctionalInterface
interface CandidateStemmer {
/**
* Stems a deterministic batch through the authoritative JMH invocation path.
*
* @param tokens input tokens, never {@code null}
* @return one non-null output for every input token
* @throws IOException if a token-stream implementation fails
*/
String[] stem(String[] tokens) throws IOException;
/**
* Returns complete distinct candidate sets, each containing its primary output.
* Single-output adapters expose singleton lists.
*
* @param tokens input tokens
* @return immutable candidate list for every token
* @throws IOException if adapter processing fails
*/
default List<List<String>> stemCandidates(final String[] tokens) throws IOException {
final String[] primary = stem(tokens);
return java.util.Arrays.stream(primary).map(List::of).toList();
}
/** @return whether the adapter exposes genuine alternative outputs */
default boolean supportsMultipleOutputs() {
return false;
}
}
/** Creates the candidate-capable Radixor adapter backed by ranked {@code getAll}. */
private static CandidateStemmer radixor(final RadixorBenchmarkStemmer stemmer) {
return new CandidateStemmer() {
/** {@inheritDoc} */
@Override public String[] stem(final String[] tokens) {
final String[] outputs = new String[tokens.length];
for (int index = 0; index < tokens.length; index++) { outputs[index] = stemmer.stem(tokens[index]); }
return outputs;
}
/** {@inheritDoc} */
@Override public List<List<String>> stemCandidates(final String[] tokens) {
return java.util.Arrays.stream(tokens).map(stemmer::stemAll).toList();
}
/** {@inheritDoc} */
@Override public boolean supportsMultipleOutputs() { return true; }
};
}
/**
* Creates the authoritative multi-output Radixor adapter for a validated dictionary language.
*
* @param language bundled Radixor language
* @return scenario-confined adapter using the JMH invocation path
* @throws IOException if the compiled dictionary cannot be loaded
*/
static CandidateStemmer createRadixorQualityStemmer(final StemmerPatchTrieLoader.Language language)
throws IOException {
return radixor(createRadixorStemmer(language));
}
/**
* Exact-root agreement counters for one quality operation.
*
@@ -590,57 +657,79 @@ public class StemmerComparisonBenchmarkQuality {
}
/**
* Creates a direct evaluator.
* Creates a direct candidate adapter.
*
* @param stemmer direct stemmer
* @return quality evaluator
* @return sequential batch adapter
*/
private static QualityEvaluator direct(final Stemmer stemmer) {
private static CandidateStemmer direct(final Stemmer stemmer) {
Objects.requireNonNull(stemmer, "stemmer");
return (corpus, blackhole) -> {
int correct = 0;
int changedCorrect = 0;
int changedEvaluated = 0;
int rootPreserved = 0;
int rootEvaluated = 0;
final String[] tokens = corpus.tokens();
final String[] expectedRoots = corpus.expectedRoots();
return tokens -> {
final String[] outputs = new String[tokens.length];
for (int index = 0; index < tokens.length; index++) {
final String token = tokens[index];
final String expectedRoot = expectedRoots[index];
final String actual = stemmer.stem(token);
blackhole.consume(actual);
final boolean exact = Objects.equals(expectedRoot, actual);
if (exact) {
correct++;
}
if (Objects.equals(token, expectedRoot)) {
rootEvaluated++;
if (exact) {
rootPreserved++;
}
} else {
changedEvaluated++;
if (exact) {
changedCorrect++;
}
}
outputs[index] = stemmer.stem(tokens[index]);
}
return new QualityResult(correct, tokens.length, changedCorrect, changedEvaluated, rootPreserved,
rootEvaluated);
return outputs;
};
}
/**
* Creates a TokenFilter evaluator.
* Creates a TokenFilter candidate adapter.
*
* @param factory token stream factory
* @return quality evaluator
* @return sequential batch adapter
*/
private static QualityEvaluator tokenFilter(final Function<TokenStream, TokenStream> factory) {
private static CandidateStemmer tokenFilter(final Function<TokenStream, TokenStream> factory) {
Objects.requireNonNull(factory, "factory");
return tokens -> firstTokenFilterOutputs(tokens, factory, null);
}
/** Creates a TokenFilter adapter that preserves all terms emitted per position. */
private static CandidateStemmer tokenFilter(final Function<TokenStream, TokenStream> factory,
final boolean multipleOutputs) {
if (!multipleOutputs) { return tokenFilter(factory); }
return new CandidateStemmer() {
/** {@inheritDoc} */
@Override public String[] stem(final String[] tokens) throws IOException {
return firstTokenFilterOutputs(tokens, factory, null);
}
/** {@inheritDoc} */
@Override public List<List<String>> stemCandidates(final String[] tokens) throws IOException {
return allTokenFilterOutputs(tokens, factory);
}
/** {@inheritDoc} */
@Override public boolean supportsMultipleOutputs() { return true; }
};
}
/** Creates a multi-analysis Morphologik direct adapter. */
private static CandidateStemmer morphologik(final DictionaryLookup lookup) {
return new CandidateStemmer() {
/** {@inheritDoc} */
@Override public String[] stem(final String[] tokens) {
final String[] outputs = new String[tokens.length];
for (int index = 0; index < tokens.length; index++) { outputs[index] = firstMorfologikStem(tokens[index], lookup); }
return outputs;
}
/** {@inheritDoc} */
@Override public List<List<String>> stemCandidates(final String[] tokens) {
return java.util.Arrays.stream(tokens).map(token -> allMorfologikStems(token, lookup)).toList();
}
/** {@inheritDoc} */
@Override public boolean supportsMultipleOutputs() { return true; }
};
}
/**
* Creates exact-root accounting around an authoritative candidate adapter.
*
* @param stemmer candidate adapter
* @return JMH exact-root evaluator
*/
private static QualityEvaluator exactRootEvaluator(final CandidateStemmer stemmer) {
Objects.requireNonNull(stemmer, "stemmer");
return (corpus, blackhole) -> {
final String[] actualStems = firstTokenFilterOutputs(corpus.tokens(), factory, blackhole);
final String[] actualStems = stemmer.stem(corpus.tokens());
final String[] expectedRoots = corpus.expectedRoots();
final String[] tokens = corpus.tokens();
int correct = 0;
@@ -651,6 +740,7 @@ public class StemmerComparisonBenchmarkQuality {
for (int index = 0; index < actualStems.length; index++) {
final String token = tokens[index];
final String expectedRoot = expectedRoots[index];
blackhole.consume(actualStems[index]);
final boolean exact = Objects.equals(expectedRoot, actualStems[index]);
if (exact) {
correct++;
@@ -679,10 +769,9 @@ public class StemmerComparisonBenchmarkQuality {
* @return direct stemmer
* @throws IOException if the trie cannot be loaded
*/
private static Stemmer createRadixorStemmer(final StemmerPatchTrieLoader.Language language) throws IOException {
final RadixorBenchmarkStemmer stemmer = new RadixorBenchmarkStemmer(StemmerPatchTrieLoader.loadCompiled(
private static RadixorBenchmarkStemmer createRadixorStemmer(final StemmerPatchTrieLoader.Language language) throws IOException {
return new RadixorBenchmarkStemmer(StemmerPatchTrieLoader.loadCompiled(
language, true, ReductionMode.MERGE_SUBTREES_WITH_EQUIVALENT_RANKED_GET_ALL_RESULTS));
return stemmer::stem;
}
/**
@@ -715,6 +804,14 @@ public class StemmerComparisonBenchmarkQuality {
return analyses.get(0).getStem().toString();
}
/** Returns all distinct Morphologik lemma strings and always includes the primary output. */
private static List<String> allMorfologikStems(final String token, final DictionaryLookup lookup) {
final java.util.LinkedHashSet<String> stems = new java.util.LinkedHashSet<>();
stems.add(firstMorfologikStem(token, lookup));
for (WordData analysis : lookup.lookup(token)) { stems.add(analysis.getStem().toString()); }
return List.copyOf(stems);
}
/**
* Extracts the first emitted term for each input token from a TokenFilter
* pipeline.
@@ -746,7 +843,9 @@ public class StemmerComparisonBenchmarkQuality {
outputs[inputIndex] = termAttribute.toString();
recordedForPosition = true;
}
blackhole.consume(termAttribute);
if (blackhole != null) {
blackhole.consume(termAttribute);
}
}
output.end();
output.close();
@@ -759,6 +858,35 @@ public class StemmerComparisonBenchmarkQuality {
return outputs;
}
/**
* Extracts every distinct emitted term for each input position and includes the
* deterministic primary output even when a filter omits it.
*/
private static List<List<String>> allTokenFilterOutputs(final String[] tokens,
final Function<TokenStream, TokenStream> factory) throws IOException {
final List<java.util.LinkedHashSet<String>> candidates = new java.util.ArrayList<>(tokens.length);
for (int index = 0; index < tokens.length; index++) { candidates.add(new java.util.LinkedHashSet<>()); }
final BenchmarkTokenStream input = new BenchmarkTokenStream(tokens);
final TokenStream output = factory.apply(input);
final CharTermAttribute term = output.addAttribute(CharTermAttribute.class);
final PositionIncrementAttribute position = output.addAttribute(PositionIncrementAttribute.class);
int inputIndex = -1;
output.reset();
while (output.incrementToken()) {
if (position.getPositionIncrement() > 0) { inputIndex += position.getPositionIncrement(); }
if (inputIndex >= 0 && inputIndex < candidates.size()) { candidates.get(inputIndex).add(term.toString()); }
}
output.end();
output.close();
final String[] primary = firstTokenFilterOutputs(tokens, factory, null);
final List<List<String>> result = new java.util.ArrayList<>(tokens.length);
for (int index = 0; index < tokens.length; index++) {
candidates.get(index).add(primary[index]);
result.add(List.copyOf(candidates.get(index)));
}
return List.copyOf(result);
}
/**
* Adds Lucene lower-case normalization.
*

View File

@@ -58,10 +58,10 @@ final class PaiceHuskLancasterStemmerTest {
*/
private static final String[][] SAMPLE_STEMS = {
{ "running", "run" },
{ "caresses", "cares" },
{ "happiness", "happi" },
{ "caresses", "caress" },
{ "happiness", "happy" },
{ "connected", "connect" },
{ "dancing", "danc" },
{ "dancing", "dant" },
{ "happy", "happy" }
};
@@ -116,7 +116,7 @@ final class PaiceHuskLancasterStemmerTest {
final Object stemmer = createStemmer();
final Method stemMethod = stemMethod();
assertEquals("running", stemMethod.invoke(stemmer, "running"));
assertEquals("run", stemMethod.invoke(stemmer, "running"));
assertEquals(null, stemMethod.invoke(stemmer, new Object[] { null }));
assertNotNull(stemMethod.invoke(stemmer, "connected"));
}

View File

@@ -0,0 +1,58 @@
package org.egothor.stemmer.benchmark.quality;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.zip.GZIPInputStream;
import org.egothor.stemmer.CaseProcessingMode;
import org.egothor.stemmer.StemmerDictionaryParser;
import org.egothor.stemmer.StemmerPatchTrieLoader.Language;
/** Loads gold-standard groups from authoritative bundled dictionary resources. */
public final class BundledGoldStandardLoader {
/** Utility class. */
private BundledGoldStandardLoader() { throw new AssertionError("No instances."); }
/**
* Parses one compressed UTF-8 dictionary with case preserved.
* @param language registered bundled language
* @return immutable groups in source-row order
* @throws IOException if the resource is absent, malformed, or unreadable
*/
public static List<GoldStandardGroup> load(final Language language) throws IOException {
Objects.requireNonNull(language, "language");
final String resource = language.resourcePath();
final List<GoldStandardGroup> groups = new ArrayList<>();
try (InputStream raw = openResource(language, resource); InputStream gzip = new GZIPInputStream(raw);
BufferedReader reader = new BufferedReader(new InputStreamReader(gzip, StandardCharsets.UTF_8))) {
StemmerDictionaryParser.parse(reader, resource, CaseProcessingMode.AS_IS, (stem, variants, row) -> {
final List<String> forms = new ArrayList<>(variants.length + 1);
forms.add(stem);
forms.addAll(Arrays.asList(variants));
try {
groups.add(new GoldStandardGroup(row, forms));
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid dictionary group for language " + language + ", resource "
+ resource + ", row " + row + ": " + exception.getMessage(), exception);
}
});
}
return List.copyOf(groups);
}
/** Opens one required classpath resource with a precise language diagnostic. */
private static InputStream openResource(final Language language, final String resource) throws IOException {
final InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
if (input == null) {
throw new IOException("Dictionary resource is missing for language " + language + ": " + resource + ".");
}
return input;
}
}

View File

@@ -0,0 +1,165 @@
package org.egothor.stemmer.benchmark.quality;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix.BatchStemmer;
/**
* Calculates exact candidate-intersection pair metrics from canonical candidate-set signatures.
* Candidate-aware output defines an overlap relation rather than a partition. The algorithm
* aggregates signature frequencies and uses an inverted candidate index; it never enumerates
* complete dictionary word pairs. All pair arithmetic is checked.
*/
final class CandidateAwareEvaluator {
/** Utility class. */
private CandidateAwareEvaluator() { throw new AssertionError("No instances."); }
/** Evaluates one genuinely multi-output scenario through its authoritative JMH adapter. */
static QualityResult evaluate(final String stemmerName, final String language, final ProcessingMode mode,
final OutputPolicy policy, final List<GoldStandardGroup> groups, final BatchStemmer stemmer) throws IOException {
if (policy == OutputPolicy.PRIMARY_OUTPUT) {
throw new IllegalArgumentException("Candidate-aware evaluation requires ANY_CANDIDATE or ALL_CANDIDATES.");
}
final List<GoldStandardGroup> includedGroups = groups.stream().filter(group -> mode.includes(group.forms())).toList();
final List<String> forms = new ArrayList<>();
final List<Integer> groupIndexes = new ArrayList<>();
long singletonRows = 0;
long pairRows = 0;
long underPossible = 0;
for (int groupIndex = 0; groupIndex < includedGroups.size(); groupIndex++) {
final GoldStandardGroup group = includedGroups.get(groupIndex);
if (group.forms().size() == 1) { singletonRows = add(singletonRows, 1, "singleton rows"); }
else { pairRows = add(pairRows, 1, "rows contributing under-stemming pairs"); }
underPossible = add(underPossible, QualityEvaluator.chooseTwo(group.forms().size()), "under denominator");
for (String form : group.forms()) { forms.add(form); groupIndexes.add(groupIndex); }
}
final String[] input = forms.toArray(String[]::new);
final String[] primary = stemmer.stem(input);
final List<List<String>> rawCandidates = stemmer.stemCandidates(input);
if (primary == null || primary.length != input.length || rawCandidates == null || rawCandidates.size() != input.length) {
throw failure(stemmerName, language, mode, policy, "the adapter returned an invalid output batch");
}
final Map<Signature, SignatureCount> counts = new HashMap<>();
final Set<String> distinctCandidates = new HashSet<>();
long oneCandidate = 0;
long multipleCandidates = 0;
long maximumCandidates = 0;
long assignments = 0;
for (int index = 0; index < input.length; index++) {
final Signature signature = signature(rawCandidates.get(index), primary[index], stemmerName, language,
mode, policy, includedGroups.get(groupIndexes.get(index)).rowNumber(), input[index]);
final int size = signature.candidates().size();
if (size == 1) { oneCandidate = add(oneCandidate, 1, "single-candidate forms"); }
else { multipleCandidates = add(multipleCandidates, 1, "multi-candidate forms"); }
maximumCandidates = Math.max(maximumCandidates, size);
assignments = add(assignments, size, "candidate assignments");
distinctCandidates.addAll(signature.candidates());
counts.computeIfAbsent(signature, ignored -> new SignatureCount()).increment(groupIndexes.get(index));
}
final List<Map.Entry<Signature, SignatureCount>> signatures = new ArrayList<>(counts.entrySet());
signatures.sort(Map.Entry.comparingByKey());
long sameGroupRelated = 0;
long crossGroupRelated = 0;
final Map<String, List<Integer>> inverted = new HashMap<>();
for (int index = 0; index < signatures.size(); index++) {
final Map.Entry<Signature, SignatureCount> entry = signatures.get(index);
long sameWithin = 0;
for (long groupCount : entry.getValue().byGroup().values()) {
sameWithin = add(sameWithin, QualityEvaluator.chooseTwo(groupCount), "same-signature group pairs");
}
sameGroupRelated = add(sameGroupRelated, sameWithin, "same-group related pairs");
if (policy == OutputPolicy.ALL_CANDIDATES || entry.getKey().candidates().size() == 1) {
crossGroupRelated = add(crossGroupRelated,
subtract(QualityEvaluator.chooseTwo(entry.getValue().total()), sameWithin, "same-signature cross pairs"),
"cross-group related pairs");
}
for (String candidate : entry.getKey().candidates()) {
inverted.computeIfAbsent(candidate, ignored -> new ArrayList<>()).add(index);
}
}
final Set<SignaturePair> relatedSignaturePairs = new HashSet<>();
for (List<Integer> indexes : inverted.values()) {
for (int left = 0; left < indexes.size(); left++) {
for (int right = left + 1; right < indexes.size(); right++) {
relatedSignaturePairs.add(new SignaturePair(indexes.get(left), indexes.get(right)));
}
}
}
for (SignaturePair pair : relatedSignaturePairs) {
final SignatureCount left = signatures.get(pair.left()).getValue();
final SignatureCount right = signatures.get(pair.right()).getValue();
long same = 0;
for (Map.Entry<Integer, Long> group : left.byGroup().entrySet()) {
same = add(same, multiply(group.getValue(), right.byGroup().getOrDefault(group.getKey(), 0L),
"different-signature same-group pairs"), "same-group related pairs");
}
final long total = multiply(left.total(), right.total(), "different-signature pairs");
sameGroupRelated = add(sameGroupRelated, same, "same-group related pairs");
if (policy == OutputPolicy.ALL_CANDIDATES) {
crossGroupRelated = add(crossGroupRelated, subtract(total, same, "different-signature cross pairs"),
"cross-group related pairs");
}
}
final long wordCount = input.length;
final long overPossible = subtract(QualityEvaluator.chooseTwo(wordCount), underPossible, "over denominator");
final long underError = subtract(underPossible, sameGroupRelated, "candidate under errors");
return new QualityResult(stemmerName, language, mode, policy,
includedGroups.size(), wordCount, singletonRows, pairRows, oneCandidate, multipleCandidates,
maximumCandidates, assignments, distinctCandidates.size(), crossGroupRelated, overPossible,
underError, underPossible, null);
}
/** Canonicalizes and validates one adapter candidate collection. */
private static Signature signature(final List<String> raw, final String primary, final String stemmer,
final String language, final ProcessingMode mode, final OutputPolicy policy,
final int row, final String form) throws IOException {
if (primary == null) { throw failure(stemmer, language, mode, policy, "null primary output at row " + row + " for '" + form + "'"); }
if (raw == null || raw.isEmpty()) { throw failure(stemmer, language, mode, policy, "null or empty candidate collection at row " + row + " for '" + form + "'"); }
final TreeSet<String> candidates = new TreeSet<>();
for (String candidate : raw) {
if (candidate == null) { throw failure(stemmer, language, mode, policy, "null candidate at row " + row + " for '" + form + "'"); }
candidates.add(candidate);
}
if (!candidates.contains(primary)) { throw failure(stemmer, language, mode, policy, "candidate set omits primary output '" + primary + "' at row " + row + " for '" + form + "'"); }
return new Signature(List.copyOf(candidates));
}
/** Checked addition with diagnostic context. */
private static long add(final long left, final long right, final String context) { try { return Math.addExact(left, right); } catch (ArithmeticException exception) { throw new IllegalStateException("Arithmetic overflow in " + context + ".", exception); } }
/** Checked subtraction with diagnostic context. */
private static long subtract(final long left, final long right, final String context) { try { return Math.subtractExact(left, right); } catch (ArithmeticException exception) { throw new IllegalStateException("Arithmetic overflow in " + context + ".", exception); } }
/** Checked multiplication with diagnostic context. */
private static long multiply(final long left, final long right, final String context) { try { return Math.multiplyExact(left, right); } catch (ArithmeticException exception) { throw new IllegalStateException("Arithmetic overflow in " + context + ".", exception); } }
/** Creates one scenario-qualified adapter failure. */
private static IOException failure(final String stemmer, final String language, final ProcessingMode mode,
final OutputPolicy policy, final String reason) { return new IOException("Candidate-aware evaluation failed for stemmer " + stemmer + ", language " + language + ", dictionary mode " + mode + ", and output policy " + policy + ": " + reason + "."); }
/** Deterministic immutable candidate-set signature. */
private record Signature(List<String> candidates) implements Comparable<Signature> {
/** Orders signatures lexicographically without depending on map iteration. */
@Override public int compareTo(final Signature other) {
final int common = Math.min(candidates.size(), other.candidates.size());
for (int index = 0; index < common; index++) { final int compared = candidates.get(index).compareTo(other.candidates.get(index)); if (compared != 0) { return compared; } }
return Integer.compare(candidates.size(), other.candidates.size());
}
}
/** Aggregated global and per-group frequency of one signature. */
private static final class SignatureCount {
private long total;
private final Map<Integer, Long> byGroup = new HashMap<>();
/** Adds one word occurrence. */ private void increment(final int group) { total = add(total, 1, "signature frequency"); byGroup.merge(group, 1L, (left, right) -> add(left, right, "signature group frequency")); }
/** @return global signature frequency */ private long total() { return total; }
/** @return mutable internally owned per-group frequencies */ private Map<Integer, Long> byGroup() { return byGroup; }
}
/** Unordered pair of distinct canonical signature indexes. */
private record SignaturePair(int left, int right) { }
}

View File

@@ -0,0 +1,199 @@
package org.egothor.stemmer.benchmark.quality;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix.BatchStemmer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/** Exact candidate-relation tests, including an independent quadratic oracle. */
@Tag("unit")
@DisplayName("Candidate-aware pairwise stemming quality")
final class CandidateAwareEvaluatorTest {
/** Verifies intersections repair under-stemming while several shared candidates count once. */
@Test @DisplayName("Candidate intersections repair primary under-stemming and count each pair once")
void intersectionsRepairUnderStemming() throws IOException {
final List<GoldStandardGroup> groups = List.of(new GoldStandardGroup(1, List.of("a", "b", "c")));
final Map<String, String> primary = Map.of("a", "y", "b", "x", "c", "z");
final Map<String, List<String>> candidates = Map.of("a", List.of("y", "x", "x"),
"b", List.of("x", "shared"), "c", List.of("z", "x", "shared"));
final QualityResult primaryResult = QualityEvaluator.evaluateBatch("Synthetic", "MULTI",
ProcessingMode.ALL_WORDS, groups, adapter(primary, candidates));
final QualityResult candidateResult = CandidateAwareEvaluator.evaluate("Synthetic", "MULTI",
ProcessingMode.ALL_WORDS, OutputPolicy.ALL_CANDIDATES, groups, adapter(primary, candidates));
assertEquals(3, primaryResult.underErrorPairs());
assertEquals(0, candidateResult.underErrorPairs());
assertEquals(7, candidateResult.totalCandidateAssignments(), "Duplicate candidates must be removed per word.");
assertTrue(candidateResult.underErrorPairs() <= primaryResult.underErrorPairs());
}
/** Verifies exact within-row disconnections and cross-row candidate collisions. */
@Test @DisplayName("Disjoint sets and cross-group intersections produce exact candidate-aware counts")
void disjointAndCollidingSets() throws IOException {
final List<GoldStandardGroup> groups = List.of(
new GoldStandardGroup(1, List.of("a", "b")), new GoldStandardGroup(2, List.of("c", "d")));
final Map<String, String> primary = Map.of("a", "a", "b", "b", "c", "c", "d", "d");
final Map<String, List<String>> candidates = Map.of("a", List.of("a", "collision"), "b", List.of("b"),
"c", List.of("c", "collision", "other"), "d", List.of("d", "other"));
final QualityResult result = CandidateAwareEvaluator.evaluate("Synthetic", "MULTI",
ProcessingMode.ALL_WORDS, OutputPolicy.ALL_CANDIDATES, groups, adapter(primary, candidates));
assertEquals(1, result.underErrorPairs());
assertEquals(2, result.underPossiblePairs());
assertEquals(1, result.overErrorPairs(), "Only the cross-group a-c pair shares a candidate.");
assertEquals(4, result.overPossiblePairs());
}
/** Verifies optimistic and all-active cross-group semantics for canonical examples. */
@Test @DisplayName("ANY_CANDIDATE and ALL_CANDIDATES apply their distinct over-stemming relations")
void policySpecificOverStemming() throws IOException {
assertPolicyOver(List.of("x"), List.of("x"), 1, 1);
assertPolicyOver(List.of("x"), List.of("y"), 0, 0);
assertPolicyOver(List.of("x"), List.of("x", "y"), 0, 1);
assertPolicyOver(List.of("x", "y"), List.of("x", "y"), 0, 1);
assertPolicyOver(List.of("x", "y"), List.of("x", "z"), 0, 1);
}
/** Verifies both candidate policies have identical same-group under-stemming. */
@Test @DisplayName("Candidate policies share the exact same within-group intersection rule")
void candidatePoliciesShareUnderStemming() throws IOException {
final List<GoldStandardGroup> groups = List.of(new GoldStandardGroup(1, List.of("a", "b", "c")));
final Map<String, String> primary = Map.of("a", "x", "b", "y", "c", "z");
final Map<String, List<String>> candidates = Map.of("a", List.of("x", "shared"),
"b", List.of("y", "shared"), "c", List.of("z"));
final QualityResult any = CandidateAwareEvaluator.evaluate("Synthetic", "MULTI", ProcessingMode.ALL_WORDS,
OutputPolicy.ANY_CANDIDATE, groups, adapter(primary, candidates));
final QualityResult all = CandidateAwareEvaluator.evaluate("Synthetic", "MULTI", ProcessingMode.ALL_WORDS,
OutputPolicy.ALL_CANDIDATES, groups, adapter(primary, candidates));
assertEquals(2, any.underErrorPairs()); assertEquals(any.underErrorPairs(), all.underErrorPairs());
}
/** Compares the optimized signature algorithm with an independent fixed-seed oracle. */
@Test @DisplayName("Optimized candidate metrics equal a deterministic randomized brute-force oracle")
void randomizedOracleAgreement() throws IOException {
final Random random = new Random(0x5EEDC0DEL);
for (int trial = 0; trial < 150; trial++) {
final int groupCount = 1 + random.nextInt(5);
final List<GoldStandardGroup> groups = new ArrayList<>();
final Map<String, String> primary = new HashMap<>();
final Map<String, List<String>> candidates = new HashMap<>();
int word = 0;
for (int group = 0; group < groupCount; group++) {
final List<String> forms = new ArrayList<>();
for (int member = 0; member < 1 + random.nextInt(5); member++) {
final String form = "w" + word++;
forms.add(form);
final String primaryStem = "s" + random.nextInt(7);
primary.put(form, primaryStem);
final List<String> raw = new ArrayList<>();
raw.add(primaryStem);
for (int candidate = 0; candidate < random.nextInt(4); candidate++) {
raw.add("s" + random.nextInt(7));
}
candidates.put(form, raw);
}
groups.add(new GoldStandardGroup(group + 1, forms));
}
final QualityResult optimized = CandidateAwareEvaluator.evaluate("Random", "MULTI",
ProcessingMode.ALL_WORDS, OutputPolicy.ALL_CANDIDATES, groups, adapter(primary, candidates));
final QualityResult any = CandidateAwareEvaluator.evaluate("Random", "MULTI",
ProcessingMode.ALL_WORDS, OutputPolicy.ANY_CANDIDATE, groups, adapter(primary, candidates));
final QualityResult primaryResult = QualityEvaluator.evaluateBatch("Random", "MULTI",
ProcessingMode.ALL_WORDS, groups, adapter(primary, candidates));
final long[] oracle = oracle(groups, candidates);
assertEquals(oracle[0], optimized.underErrorPairs(), "Under errors differ in trial " + trial);
assertEquals(oracle[1], optimized.underPossiblePairs(), "Under denominator differs in trial " + trial);
assertEquals(oracle[2], optimized.overErrorPairs(), "Over errors differ in trial " + trial);
assertEquals(oracle[3], optimized.overPossiblePairs(), "Over denominator differs in trial " + trial);
assertEquals(oracle[4], any.overErrorPairs(), "Optimistic over errors differ in trial " + trial);
assertEquals(any.underErrorPairs(), optimized.underErrorPairs());
assertTrue(any.underErrorPairs() <= primaryResult.underErrorPairs());
assertTrue(any.overErrorPairs() <= primaryResult.overErrorPairs());
assertTrue(optimized.overErrorPairs() >= primaryResult.overErrorPairs());
}
}
/** Verifies candidate contract violations fail with scenario and word context. */
@Test @DisplayName("Invalid candidate collections fail with precise contextual diagnostics")
void invalidCandidateOutput() {
final List<GoldStandardGroup> groups = List.of(new GoldStandardGroup(7, List.of("žluťoučký")));
final BatchStemmer invalid = adapter(Map.of("žluťoučký", "stem"), Map.of("žluťoučký", List.of("other")));
final IOException exception = assertThrows(IOException.class, () -> CandidateAwareEvaluator.evaluate(
"Invalid", "CS_CZ", ProcessingMode.ALL_WORDS, OutputPolicy.ALL_CANDIDATES, groups, invalid));
assertTrue(exception.getMessage().contains("row 7"));
assertTrue(exception.getMessage().contains("žluťoučký"));
assertTrue(exception.getMessage().contains("omits primary output"));
}
/** Creates a deterministic multi-output adapter from per-form fixtures. */
private static BatchStemmer adapter(final Map<String, String> primary,
final Map<String, List<String>> candidates) {
return new BatchStemmer() {
/** {@inheritDoc} */
@Override public String[] stem(final String[] forms) {
final String[] outputs = new String[forms.length];
for (int index = 0; index < forms.length; index++) { outputs[index] = primary.get(forms[index]); }
return outputs;
}
/** {@inheritDoc} */
@Override public List<List<String>> stemCandidates(final String[] forms) {
final List<List<String>> outputs = new ArrayList<>();
for (String form : forms) { outputs.add(candidates.get(form)); }
return outputs;
}
/** {@inheritDoc} */
@Override public boolean supportsMultipleOutputs() { return true; }
};
}
/** Evaluates one two-row example and checks both policy numerators. */
private static void assertPolicyOver(final List<String> left, final List<String> right,
final long expectedAny, final long expectedAll) throws IOException {
final List<GoldStandardGroup> groups = List.of(new GoldStandardGroup(1, List.of("a")),
new GoldStandardGroup(2, List.of("b")));
final Map<String, String> primary = Map.of("a", left.get(0), "b", right.get(0));
final Map<String, List<String>> candidates = Map.of("a", left, "b", right);
final QualityResult any = CandidateAwareEvaluator.evaluate("Synthetic", "MULTI", ProcessingMode.ALL_WORDS,
OutputPolicy.ANY_CANDIDATE, groups, adapter(primary, candidates));
final QualityResult all = CandidateAwareEvaluator.evaluate("Synthetic", "MULTI", ProcessingMode.ALL_WORDS,
OutputPolicy.ALL_CANDIDATES, groups, adapter(primary, candidates));
assertEquals(expectedAny, any.overErrorPairs()); assertEquals(expectedAll, all.overErrorPairs());
}
/** Enumerates small word pairs independently and returns under error/possible and over error/possible counts. */
private static long[] oracle(final List<GoldStandardGroup> groups,
final Map<String, List<String>> candidates) {
final List<String> forms = new ArrayList<>();
final List<Integer> labels = new ArrayList<>();
for (int group = 0; group < groups.size(); group++) {
for (String form : groups.get(group).forms()) { forms.add(form); labels.add(group); }
}
long underError = 0; long underPossible = 0; long overError = 0; long overPossible = 0; long anyOverError = 0;
for (int left = 0; left < forms.size(); left++) {
for (int right = left + 1; right < forms.size(); right++) {
final Set<String> intersection = new LinkedHashSet<>(candidates.get(forms.get(left)));
intersection.retainAll(new LinkedHashSet<>(candidates.get(forms.get(right))));
if (labels.get(left).equals(labels.get(right))) {
underPossible++; if (intersection.isEmpty()) { underError++; }
} else {
overPossible++; if (!intersection.isEmpty()) { overError++; }
final Set<String> leftSet = new LinkedHashSet<>(candidates.get(forms.get(left)));
final Set<String> rightSet = new LinkedHashSet<>(candidates.get(forms.get(right)));
if (leftSet.size() == 1 && leftSet.equals(rightSet)) { anyOverError++; }
}
}
}
return new long[] {underError, underPossible, overError, overPossible, anyOverError};
}
}

View File

@@ -0,0 +1,121 @@
package org.egothor.stemmer.benchmark.quality;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix.BatchStemmer;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix.Candidate;
/** Produces deterministic word-level diagnostics for genuinely multi-output adapters. */
final class CandidateQualityAudit {
/** Utility class. */
private CandidateQualityAudit() { throw new AssertionError("No instances."); }
/** Evaluates candidate output and retains the largest candidate sets for reproducible inspection. */
static Scenario evaluate(final Candidate candidate, final ProcessingMode mode,
final List<GoldStandardGroup> groups, final QualityResult primary, final QualityResult any,
final int limit) throws IOException {
final List<String> forms = new ArrayList<>();
final List<Integer> groupIndexes = new ArrayList<>();
final List<Integer> rows = new ArrayList<>();
for (int group = 0; group < groups.size(); group++) {
final GoldStandardGroup item = groups.get(group);
if (!mode.includes(item.forms())) { continue; }
for (String form : item.forms()) { forms.add(form); groupIndexes.add(group); rows.add(item.rowNumber()); }
}
final BatchStemmer stemmer = candidate.createStemmer();
final String[] primaryOutputs = stemmer.stem(forms.toArray(String[]::new));
final List<List<String>> rawCandidates = stemmer.stemCandidates(forms.toArray(String[]::new));
final Map<String, List<Integer>> inverted = new HashMap<>();
final Map<Integer, Long> candidateCountDistribution = new java.util.TreeMap<>();
final List<List<String>> candidateSets = new ArrayList<>();
for (int index = 0; index < forms.size(); index++) {
final TreeSet<String> canonical = new TreeSet<>(rawCandidates.get(index));
canonical.add(primaryOutputs[index]);
final List<String> set = List.copyOf(canonical);
candidateSets.add(set);
candidateCountDistribution.merge(set.size(), 1L, Math::addExact);
for (String value : set) { inverted.computeIfAbsent(value, ignored -> new ArrayList<>()).add(index); }
}
final QualityResult candidateResult = CandidateAwareEvaluator.evaluate(candidate.name(), candidate.language().name(),
mode, OutputPolicy.ALL_CANDIDATES, groups, candidate.createStemmer());
final List<Integer> selected = new ArrayList<>();
for (int index = 0; index < forms.size(); index++) { if (candidateSets.get(index).size() > 1) { selected.add(index); } }
selected.sort(Comparator.<Integer>comparingInt(index -> candidateSets.get(index).size()).reversed()
.thenComparing(index -> forms.get(index)).thenComparingInt(index -> rows.get(index)));
final List<Word> words = new ArrayList<>();
for (int index : selected.subList(0, Math.min(limit, selected.size()))) {
final Set<Integer> partners = new HashSet<>();
for (String value : candidateSets.get(index)) { partners.addAll(inverted.get(value)); }
partners.remove(index);
long repaired = 0; long introduced = 0;
for (int partner : partners) {
final boolean primaryRelated = primaryOutputs[index].equals(primaryOutputs[partner]);
if (groupIndexes.get(index).equals(groupIndexes.get(partner)) && !primaryRelated) { repaired++; }
if (!groupIndexes.get(index).equals(groupIndexes.get(partner)) && !primaryRelated) { introduced++; }
}
words.add(new Word(rows.get(index), forms.get(index), primaryOutputs[index], candidateSets.get(index),
repaired, introduced));
}
return new Scenario(primary, any, candidateResult, Map.copyOf(candidateCountDistribution), List.copyOf(words));
}
/** Appends candidate diagnostics to the freshly generated audit report. */
static void append(final Path path, final List<Scenario> scenarios) throws IOException {
if (scenarios.isEmpty()) { return; }
final StringBuilder text = new StringBuilder(4096);
text.append("\n# Candidate-aware audit\n\nWord-level sections below retain original Unicode forms. Per-word repaired and introduced counts describe relations involving that word and are diagnostic, not additive scenario totals.\n\n");
for (Scenario scenario : scenarios.stream().sorted(Comparator.comparing(Scenario::candidate, QualityResult.ORDER)).toList()) {
final QualityResult primary = scenario.primary();
final QualityResult any = scenario.any();
final QualityResult candidate = scenario.candidate();
text.append("## ").append(candidate.stemmer()).append(" / ").append(candidate.language()).append(" / ")
.append(candidate.processingMode()).append(" / ALL_CANDIDATES\n\n")
.append("- Primary under-stemming pairs: ").append(primary.underErrorPairs()).append(" / ").append(primary.underPossiblePairs()).append("\n")
.append("- ANY_CANDIDATE under-stemming pairs: ").append(any.underErrorPairs()).append(" / ").append(any.underPossiblePairs()).append("\n")
.append("- ALL_CANDIDATES under-stemming pairs: ").append(candidate.underErrorPairs()).append(" / ").append(candidate.underPossiblePairs()).append("\n")
.append("- Under-stemming pairs repaired by alternatives: ").append(primary.underErrorPairs() - candidate.underErrorPairs()).append("\n")
.append("- Primary over-stemming pairs: ").append(primary.overErrorPairs()).append(" / ").append(primary.overPossiblePairs()).append("\n")
.append("- ANY_CANDIDATE over-stemming pairs: ").append(any.overErrorPairs()).append(" / ").append(any.overPossiblePairs()).append("\n")
.append("- Best-case over-stemming pairs avoided: ").append(primary.overErrorPairs() - any.overErrorPairs()).append("\n")
.append("- ALL_CANDIDATES over-stemming pairs: ").append(candidate.overErrorPairs()).append(" / ").append(candidate.overPossiblePairs()).append("\n")
.append("- Additional candidate collision pairs: ").append(candidate.overErrorPairs() - primary.overErrorPairs()).append("\n")
.append("- Forms with multiple candidates: ").append(candidate.formsWithMultipleCandidates()).append("\n")
.append("- Maximum candidates for one word: ").append(candidate.maximumCandidatesForOneWord()).append("\n\n")
.append("- Candidate-count distribution: ").append(new java.util.TreeMap<>(scenario.candidateCountDistribution())).append("\n\n")
.append("### Forms with the largest candidate sets\n\n");
for (Word word : scenario.words()) {
text.append("- Row ").append(word.row()).append(", form `").append(escape(word.form()))
.append("`, primary `").append(escape(word.primary())).append("`, candidates ")
.append(word.candidates().stream().map(value -> "`" + escape(value) + "`").toList())
.append(", repaired same-group relations ").append(word.repairedUnderRelations())
.append(", introduced cross-group relations ").append(word.introducedOverRelations()).append(".\n");
}
text.append('\n');
}
Files.writeString(path, text.toString(), StandardCharsets.UTF_8, StandardOpenOption.APPEND);
}
/** Escapes Markdown code-span delimiters without altering linguistic content. */
private static String escape(final String value) { return value.replace("`", "\\`"); }
/** Immutable candidate-aware audit scenario. */
record Scenario(QualityResult primary, QualityResult any, QualityResult candidate,
Map<Integer, Long> candidateCountDistribution,
List<Word> words) { }
/** Immutable word-level candidate diagnostic. */
record Word(int row, String form, String primary, List<String> candidates,
long repairedUnderRelations, long introducedOverRelations) { }
}

View File

@@ -0,0 +1,35 @@
package org.egothor.stemmer.benchmark.quality;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/** Immutable gold-standard equivalence class originating from one dictionary row. */
public record GoldStandardGroup(int rowNumber, List<String> forms) {
private static final int FIRST_ROW_NUMBER = 1;
/**
* Creates a group while removing exact duplicates within this row.
*
* @param rowNumber positive physical dictionary row number
* @param forms supplied forms; encounter order has no metric significance
* @throws IllegalArgumentException if the row or forms are invalid
*/
public GoldStandardGroup {
if (rowNumber < FIRST_ROW_NUMBER) {
throw new IllegalArgumentException("Dictionary row number must be positive.");
}
Objects.requireNonNull(forms, "forms");
final Set<String> distinct = new LinkedHashSet<>();
for (String form : forms) {
if (form == null || form.isEmpty()) {
throw new IllegalArgumentException("Dictionary group forms must be non-empty strings.");
}
distinct.add(form);
}
if (distinct.isEmpty()) {
throw new IllegalArgumentException("A dictionary group must contain at least one usable form.");
}
forms = List.copyOf(distinct);
}
}

View File

@@ -0,0 +1,53 @@
package org.egothor.stemmer.benchmark.quality;
import java.io.IOException;
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 java.util.Set;
import java.util.TreeSet;
import org.egothor.stemmer.StemmerPatchTrieLoader.Language;
/** Reconciles bundled dictionary resources with every production language enumeration value. */
record LanguageUniverse(Map<Language, Path> dictionaries, List<String> resourceDirectories,
List<String> enumerationValues) {
/** Discovers and validates a one-to-one resource mapping without silent exclusions. */
static LanguageUniverse discover(final Path resourcesDirectory) throws IOException {
final Map<String, Path> resources = new HashMap<>();
try (java.util.stream.Stream<Path> paths = Files.list(resourcesDirectory)) {
for (Path directory : paths.filter(Files::isDirectory).toList()) {
final Path dictionary = directory.resolve("stemmer.gz");
if (Files.isRegularFile(dictionary)) {
final Path previous = resources.put(directory.getFileName().toString(), dictionary);
if (previous != null) { throw new IOException("Two dictionary resources map to directory " + directory + "."); }
}
}
}
final Map<Language, Path> mappings = new EnumMap<>(Language.class);
final Set<String> mappedDirectories = new TreeSet<>();
for (Language language : Language.values()) {
final Path dictionary = resources.get(language.resourceDirectory());
if (dictionary == null) {
throw new IOException("Enumeration language " + language + " has no stemmer.gz dictionary under "
+ resourcesDirectory + ".");
}
mappings.put(language, dictionary);
mappedDirectories.add(language.resourceDirectory());
}
final Set<String> unmatched = new TreeSet<>(resources.keySet());
unmatched.removeAll(mappedDirectories);
if (!unmatched.isEmpty()) {
throw new IOException("Dictionary resource directories have no StemmerPatchTrieLoader.Language mapping: "
+ unmatched + ".");
}
final List<String> enumValues = new ArrayList<>();
for (Language language : Language.values()) { enumValues.add(language.name()); }
return new LanguageUniverse(Map.copyOf(mappings), List.copyOf(new TreeSet<>(resources.keySet())),
List.copyOf(enumValues));
}
}

View File

@@ -0,0 +1,55 @@
package org.egothor.stemmer.benchmark.quality;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.egothor.stemmer.StemmerPatchTrieLoader.Language;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Regression tests for independent dictionary-resource and enumeration reconciliation. */
@Tag("integration")
@DisplayName("Authoritative Radixor language universe")
final class LanguageUniverseTest {
/** Temporary resource tree. */ @TempDir Path temporaryDirectory;
/** Verifies every production enumeration value has exactly one bundled dictionary. */
@Test @DisplayName("Production resources reconcile with every language enumeration value")
void productionResourcesReconcile() throws IOException {
final LanguageUniverse universe = LanguageUniverse.discover(Path.of("src/main/resources"));
assertEquals(Language.values().length, universe.dictionaries().size());
assertTrue(universe.dictionaries().containsKey(Language.DA_DK));
assertTrue(universe.dictionaries().containsKey(Language.YI));
}
/** Verifies a missing enumerated resource produces an exact diagnostic. */
@Test @DisplayName("Missing enumeration resources fail validation")
void missingResourceFails() throws IOException {
final Path first = this.temporaryDirectory.resolve(Language.CS_CZ.resourceDirectory());
Files.createDirectories(first); Files.createFile(first.resolve("stemmer.gz"));
final IOException exception = assertThrows(IOException.class,
() -> LanguageUniverse.discover(this.temporaryDirectory));
assertTrue(exception.getMessage().contains("DA_DK"));
}
/** Verifies an unenumerated dictionary directory is rejected. */
@Test @DisplayName("Unmapped dictionary directories fail validation")
void extraResourceFails() throws IOException {
for (Language language : Language.values()) {
final Path directory = this.temporaryDirectory.resolve(language.resourceDirectory());
Files.createDirectories(directory); Files.createFile(directory.resolve("stemmer.gz"));
}
final Path extra = this.temporaryDirectory.resolve("unmapped_language");
Files.createDirectories(extra); Files.createFile(extra.resolve("stemmer.gz"));
final IOException exception = assertThrows(IOException.class,
() -> LanguageUniverse.discover(this.temporaryDirectory));
assertTrue(exception.getMessage().contains("unmapped_language"));
}
}

View File

@@ -0,0 +1,108 @@
package org.egothor.stemmer.benchmark.quality;
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.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalDouble;
import java.util.function.Function;
/** Writes deterministic Pearson and tied-rank Spearman correlations within compatible cohorts. */
final class MetricCorrelationWriter {
/** Stable metric extractors used for correlation analysis. */
private static final Map<String, Function<QualityResult, OptionalDouble>> METRICS = metrics();
/** Utility class. */
private MetricCorrelationWriter() { throw new AssertionError("No instances."); }
/** Writes both correlation reports from unrounded per-language scenario values. */
static void write(final Path pearson, final Path spearman, final List<QualityResult> results) throws IOException {
writeOne(pearson, results, false); writeOne(spearman, results, true);
}
/** Writes one correlation method with explicit missing-value reasons. */
private static void writeOne(final Path path, final List<QualityResult> results, final boolean ranks) throws IOException {
final StringBuilder output = new StringBuilder("Aggregation,Dictionary mode,Output policy,Metric A,Metric B,Observation count,Correlation,Missing-value reason\n");
for (ProcessingMode mode : ProcessingMode.values()) {
for (OutputPolicy policy : OutputPolicy.values()) {
final List<QualityResult> cohort = results.stream().filter(row -> row.processingMode() == mode
&& row.outputPolicy() == policy).toList();
final List<String> names = new ArrayList<>(METRICS.keySet());
if (policy != OutputPolicy.PRIMARY_OUTPUT) { names.remove("Adjusted Rand Index"); }
for (int left = 0; left < names.size(); left++) {
for (int right = left; right < names.size(); right++) {
append(output, mode, policy, names.get(left), names.get(right), cohort, ranks);
}
}
}
}
final Path parent = path.toAbsolutePath().getParent(); if (parent != null) { Files.createDirectories(parent); }
Files.writeString(path, output.toString(), StandardCharsets.UTF_8);
}
/** Appends one coefficient after pairwise removal of undefined observations. */
private static void append(final StringBuilder output, final ProcessingMode mode, final OutputPolicy policy,
final String leftName, final String rightName, final List<QualityResult> cohort, final boolean ranks) {
final List<Double> left = new ArrayList<>(); final List<Double> right = new ArrayList<>();
for (QualityResult row : cohort) {
final OptionalDouble a = METRICS.get(leftName).apply(row); final OptionalDouble b = METRICS.get(rightName).apply(row);
if (a.isPresent() && b.isPresent()) { left.add(a.getAsDouble()); right.add(b.getAsDouble()); }
}
String value = ""; String reason = "";
if (left.size() < 3) { reason = "Fewer than three defined observations."; }
else {
final double[] a = ranks ? ranks(left) : values(left); final double[] b = ranks ? ranks(right) : values(right);
final OptionalDouble correlation = pearson(a, b);
if (correlation.isEmpty()) { reason = "At least one metric has zero variance."; }
else { value = String.format(java.util.Locale.ROOT, "%.12f", correlation.getAsDouble()); }
}
output.append("Per-language scenario,").append(mode).append(',').append(policy).append(',')
.append(csv(leftName)).append(',').append(csv(rightName)).append(',').append(left.size()).append(',')
.append(value).append(',').append(csv(reason)).append('\n');
}
/** Calculates Pearson correlation with an empty result for zero variance. */
private static OptionalDouble pearson(final double[] left, final double[] right) {
double leftMean = 0.0; double rightMean = 0.0;
for (int index = 0; index < left.length; index++) { leftMean += left[index]; rightMean += right[index]; }
leftMean /= left.length; rightMean /= right.length;
double covariance = 0.0; double leftVariance = 0.0; double rightVariance = 0.0;
for (int index = 0; index < left.length; index++) {
final double a = left[index] - leftMean; final double b = right[index] - rightMean;
covariance += a * b; leftVariance += a * a; rightVariance += b * b;
}
return leftVariance == 0.0 || rightVariance == 0.0 ? OptionalDouble.empty()
: OptionalDouble.of(covariance / Math.sqrt(leftVariance * rightVariance));
}
/** Assigns deterministic average ranks to tied values. */
private static double[] ranks(final List<Double> input) {
final List<Integer> order = new ArrayList<>(); for (int index = 0; index < input.size(); index++) { order.add(index); }
order.sort(Comparator.comparingDouble(input::get)); final double[] ranks = new double[input.size()];
int start = 0; while (start < order.size()) {
int end = start + 1; while (end < order.size() && input.get(order.get(start)).equals(input.get(order.get(end)))) { end++; }
final double rank = (start + 1 + end) / 2.0; for (int index = start; index < end; index++) { ranks[order.get(index)] = rank; }
start = end;
}
return ranks;
}
/** Copies boxed values into a primitive array. */
private static double[] values(final List<Double> values) { final double[] result = new double[values.size()]; for (int index = 0; index < result.length; index++) { result[index] = values.get(index); } return result; }
/** Defines stable metric names and unrounded extractors. */
private static Map<String, Function<QualityResult, OptionalDouble>> metrics() {
final Map<String, Function<QualityResult, OptionalDouble>> values = new LinkedHashMap<>();
values.put("Pairwise F0.5", row -> row.pairwiseMetrics().f05()); values.put("Pairwise F1", row -> row.pairwiseMetrics().f1());
values.put("Pairwise F2", row -> row.pairwiseMetrics().f2()); values.put("Jaccard", row -> row.pairwiseMetrics().jaccard());
values.put("Fowlkes-Mallows", row -> row.pairwiseMetrics().fowlkesMallows());
values.put("Matthews correlation coefficient", row -> row.pairwiseMetrics().matthewsCorrelationCoefficient());
values.put("Balanced accuracy", row -> row.pairwiseMetrics().balancedAccuracy());
values.put("Adjusted Rand Index", row -> row.partitionMetrics() == null ? OptionalDouble.empty() : OptionalDouble.of(row.partitionMetrics().adjustedRandIndex()));
return java.util.Collections.unmodifiableMap(values);
}
/** Quotes one CSV field. */
private static String csv(final String value) { return '"' + value.replace("\"", "\"\"") + '"'; }
}

View File

@@ -0,0 +1,11 @@
package org.egothor.stemmer.benchmark.quality;
/** Defines which outputs of a JMH stemmer adapter establish the measured relation. */
enum OutputPolicy {
/** Uses only the deterministic output selected by the existing JMH comparison. */
PRIMARY_OUTPUT,
/** Uses an optimistic pair-specific choice from the complete candidate sets. */
ANY_CANDIDATE,
/** Treats all candidates as active and uses the complete intersection relation. */
ALL_CANDIDATES
}

View File

@@ -0,0 +1,68 @@
package org.egothor.stemmer.benchmark.quality;
import java.util.OptionalDouble;
/**
* Derives scientifically labelled pairwise confusion metrics from unrounded raw counts.
* Undefined ratios are represented by empty optionals; no method returns NaN or infinity.
*/
record PairwiseMetrics(long truePositivePairs, long falsePositivePairs, long falseNegativePairs,
long trueNegativePairs) {
/** Creates checked confusion counts from one quality result. */
static PairwiseMetrics from(final QualityResult result) {
return new PairwiseMetrics(Math.subtractExact(result.underPossiblePairs(), result.underErrorPairs()),
result.overErrorPairs(), result.underErrorPairs(),
Math.subtractExact(result.overPossiblePairs(), result.overErrorPairs()));
}
/** @return pairwise precision */ OptionalDouble precision() { return ratio(truePositivePairs, Math.addExact(truePositivePairs, falsePositivePairs)); }
/** @return pairwise recall */ OptionalDouble recall() { return ratio(truePositivePairs, Math.addExact(truePositivePairs, falseNegativePairs)); }
/** @return pairwise specificity */ OptionalDouble specificity() { return ratio(trueNegativePairs, Math.addExact(trueNegativePairs, falsePositivePairs)); }
/** @return pairwise accuracy, potentially dominated by true negatives */
OptionalDouble accuracy() { return ratio(Math.addExact(truePositivePairs, trueNegativePairs), total()); }
/** @return arithmetic mean of recall and specificity */
OptionalDouble balancedAccuracy() { return mean(recall(), specificity()); }
/** @return pairwise F0.5 */ OptionalDouble f05() { return fBeta(0.25); }
/** @return pairwise F1 */ OptionalDouble f1() { return fBeta(1.0); }
/** @return pairwise F2 */ OptionalDouble f2() { return fBeta(4.0); }
/** @return Jaccard index */
OptionalDouble jaccard() { return ratio(truePositivePairs, Math.addExact(Math.addExact(truePositivePairs, falsePositivePairs), falseNegativePairs)); }
/** @return Fowlkes-Mallows index */
OptionalDouble fowlkesMallows() {
final OptionalDouble precisionValue = precision(); final OptionalDouble recallValue = recall();
return precisionValue.isEmpty() || recallValue.isEmpty() ? OptionalDouble.empty()
: OptionalDouble.of(Math.sqrt(precisionValue.getAsDouble() * recallValue.getAsDouble()));
}
/** @return Matthews correlation coefficient using scaled double arithmetic */
OptionalDouble matthewsCorrelationCoefficient() {
final double a = (double) truePositivePairs + falsePositivePairs;
final double b = (double) truePositivePairs + falseNegativePairs;
final double c = (double) trueNegativePairs + falsePositivePairs;
final double d = (double) trueNegativePairs + falseNegativePairs;
final double denominator = Math.sqrt(a * b * c * d);
if (denominator == 0.0) { return OptionalDouble.empty(); }
final double numerator = (double) truePositivePairs * trueNegativePairs
- (double) falsePositivePairs * falseNegativePairs;
return OptionalDouble.of(numerator / denominator);
}
/** @return pairwise error rate */
OptionalDouble errorRate() { return ratio(Math.addExact(falsePositivePairs, falseNegativePairs), total()); }
/** Calculates F-beta directly from raw counts. */
private OptionalDouble fBeta(final double betaSquared) {
final double numerator = (1.0 + betaSquared) * truePositivePairs;
final double denominator = numerator + betaSquared * falseNegativePairs + falsePositivePairs;
return denominator == 0.0 ? OptionalDouble.empty() : OptionalDouble.of(numerator / denominator);
}
/** Returns the checked total pair population. */
private long total() { return Math.addExact(Math.addExact(truePositivePairs, falsePositivePairs), Math.addExact(falseNegativePairs, trueNegativePairs)); }
/** Calculates one ratio with explicit zero-denominator handling. */
private static OptionalDouble ratio(final long numerator, final long denominator) {
return denominator == 0 ? OptionalDouble.empty() : OptionalDouble.of((double) numerator / denominator);
}
/** Averages two defined ratios. */
private static OptionalDouble mean(final OptionalDouble left, final OptionalDouble right) {
return left.isEmpty() || right.isEmpty() ? OptionalDouble.empty()
: OptionalDouble.of((left.getAsDouble() + right.getAsDouble()) / 2.0);
}
}

View File

@@ -0,0 +1,43 @@
package org.egothor.stemmer.benchmark.quality;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/** Formula and degenerate-case tests for aggregate pairwise metrics. */
@Tag("unit")
@DisplayName("Pairwise aggregate metrics")
final class PairwiseMetricsTest {
/** Verifies all formulas use the supplied raw confusion counts. */
@Test @DisplayName("Aggregate metrics are calculated from raw confusion counts")
void formulas() {
final PairwiseMetrics metrics = new PairwiseMetrics(8, 2, 4, 16);
assertEquals(0.8, metrics.precision().orElseThrow(), 1.0e-12);
assertEquals(8.0 / 12.0, metrics.recall().orElseThrow(), 1.0e-12);
assertEquals(16.0 / 18.0, metrics.specificity().orElseThrow(), 1.0e-12);
assertEquals(24.0 / 30.0, metrics.accuracy().orElseThrow(), 1.0e-12);
assertEquals(8.0 / 14.0, metrics.jaccard().orElseThrow(), 1.0e-12);
assertEquals(6.0 / 30.0, metrics.errorRate().orElseThrow(), 1.0e-12);
assertTrue(metrics.f05().orElseThrow() > metrics.f2().orElseThrow());
}
/** Verifies a perfect nondegenerate relation reaches every applicable maximum. */
@Test @DisplayName("Perfect confusion counts produce maximum defined scores")
void perfect() {
final PairwiseMetrics metrics = new PairwiseMetrics(10, 0, 0, 20);
assertEquals(1.0, metrics.f05().orElseThrow()); assertEquals(1.0, metrics.f1().orElseThrow());
assertEquals(1.0, metrics.f2().orElseThrow()); assertEquals(1.0, metrics.matthewsCorrelationCoefficient().orElseThrow());
assertEquals(1.0, metrics.balancedAccuracy().orElseThrow());
}
/** Verifies undefined denominators remain explicit missing values. */
@Test @DisplayName("Degenerate zero denominators remain undefined")
void undefined() {
final PairwiseMetrics metrics = new PairwiseMetrics(0, 0, 0, 0);
assertTrue(metrics.precision().isEmpty()); assertTrue(metrics.recall().isEmpty());
assertTrue(metrics.matthewsCorrelationCoefficient().isEmpty());
}
}

View File

@@ -0,0 +1,8 @@
package org.egothor.stemmer.benchmark.quality;
/**
* Immutable strict-partition comparison metrics. Values use the arithmetic-mean
* normalization for normalized mutual information and are applicable only to primary output.
*/
record PartitionMetrics(double adjustedRandIndex, double homogeneity, double completeness,
double vMeasure, double normalizedMutualInformation) { }

View File

@@ -0,0 +1,32 @@
package org.egothor.stemmer.benchmark.quality;
/** Selects the gold-standard groups included in a stemming-quality scenario. */
public enum ProcessingMode {
/** Includes every parsed dictionary group. */
ALL_WORDS,
/** Includes only groups containing no uppercase or titlecase Unicode code point. */
LOWERCASE_GROUPS_ONLY;
/**
* Tests whether a group is eligible for this mode.
*
* @param forms distinct word forms in the group, never {@code null}
* @return {@code true} when the complete group is eligible
*/
public boolean includes(final Iterable<String> forms) {
if (this == ALL_WORDS) {
return true;
}
for (String form : forms) {
int offset = 0;
while (offset < form.length()) {
final int codePoint = form.codePointAt(offset);
if (Character.isUpperCase(codePoint) || Character.isTitleCase(codePoint)) {
return false;
}
offset += Character.charCount(codePoint);
}
}
return true;
}
}

View File

@@ -0,0 +1,157 @@
package org.egothor.stemmer.benchmark.quality;
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.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix.Candidate;
/** Produces deterministic scenario and group-contribution diagnostics for audit runs. */
final class QualityAudit {
/** Utility class. */
private QualityAudit() {
throw new AssertionError("No instances.");
}
/**
* Evaluates one scenario and retains its highest under-stemming contributors.
*
* @param candidate authoritative JMH candidate
* @param mode processing mode
* @param groups parsed dictionary groups
* @param limit maximum listed contributors
* @return immutable audited scenario
* @throws IOException if the candidate adapter fails
*/
static Scenario evaluate(final Candidate candidate, final ProcessingMode mode,
final List<GoldStandardGroup> groups, final int limit) throws IOException {
final List<GoldStandardGroup> includedGroups = groups.stream().filter(group -> mode.includes(group.forms())).toList();
final List<String> forms = new ArrayList<>();
for (GoldStandardGroup group : includedGroups) {
forms.addAll(group.forms());
}
final String[] outputs = candidate.createStemmer().stem(forms.toArray(String[]::new));
if (outputs.length != forms.size()) {
throw new IOException("Invalid audit output count for stemmer " + candidate.name() + ", language "
+ candidate.language() + ", and processing mode " + mode + ".");
}
final int[] outputIndex = {0};
final QualityResult result = QualityEvaluator.evaluate(candidate.name(), candidate.language().name(), mode,
groups, word -> outputs[outputIndex[0]++]);
final List<Contributor> contributors = new ArrayList<>();
long exactMatches = 0;
int offset = 0;
final List<Integer> sizes = new ArrayList<>();
for (GoldStandardGroup group : includedGroups) {
final Map<String, List<String>> formsByStem = new LinkedHashMap<>();
final String expected = group.forms().get(0);
long mergedPairs = 0;
for (String form : group.forms()) {
final String output = outputs[offset++];
formsByStem.computeIfAbsent(output, ignored -> new ArrayList<>()).add(form);
if (expected.equals(output)) {
exactMatches++;
}
}
for (List<String> stemForms : formsByStem.values()) {
mergedPairs = Math.addExact(mergedPairs, QualityEvaluator.chooseTwo(stemForms.size()));
}
final long possible = QualityEvaluator.chooseTwo(group.forms().size());
final long errors = Math.subtractExact(possible, mergedPairs);
sizes.add(group.forms().size());
if (errors > 0) {
contributors.add(new Contributor(group.rowNumber(), group.forms().size(), formsByStem, errors, possible));
}
}
contributors.sort(Comparator.comparingLong(Contributor::errorPairs).reversed()
.thenComparingInt(Contributor::rowNumber));
final long contributionSum = contributors.stream().mapToLong(Contributor::errorPairs).reduce(0L, Math::addExact);
if (contributionSum != result.underErrorPairs()) {
throw new IOException("The summed group contributions do not equal the optimized under-stemming total for "
+ candidate.name() + ", " + candidate.language() + ", and " + mode + ".");
}
sizes.sort(Integer::compareTo);
final double mean = sizes.stream().mapToInt(Integer::intValue).average().orElse(0.0);
final double median = median(sizes);
return new Scenario(result, candidate.language().resourcePath(), exactMatches, forms.size(),
sizes.isEmpty() ? 0 : sizes.get(0), sizes.isEmpty() ? 0 : sizes.get(sizes.size() - 1), mean, median,
List.copyOf(contributors.subList(0, Math.min(limit, contributors.size()))), contributionSum);
}
/** Writes all audited scenarios to a fresh UTF-8 Markdown file. */
static void write(final Path path, final List<Scenario> scenarios) throws IOException {
final StringBuilder text = new StringBuilder(8192);
text.append("# Stemming-quality audit\n\nThis report uses original dictionary forms and the exact JMH candidate adapters. Exact-output counts compare outputs with the first parsed field of each group; they are not interchangeable with the existing JMH exact-root counters when that corpus lowercases dictionary fields.\n\n");
for (Scenario scenario : scenarios.stream().sorted(Comparator.comparing(item -> item.result(), QualityResult.ORDER)).toList()) {
final QualityResult result = scenario.result();
text.append("## ").append(result.stemmer()).append(" / ").append(result.language()).append(" / ")
.append(result.processingMode()).append("\n\n")
.append("- Dictionary source: `").append(scenario.dictionarySource()).append("`\n")
.append("- Processed dictionary rows: ").append(result.appliedDictionaryRows()).append("\n")
.append("- Processed unique word forms: ").append(result.processedWordForms()).append("\n")
.append("- Singleton dictionary rows: ").append(result.singletonDictionaryRows()).append("\n")
.append("- Dictionary rows contributing under-stemming pairs: ").append(result.dictionaryRowsContributingUnderPairs()).append("\n")
.append("- Group size minimum / maximum / mean / median: ").append(scenario.minimumGroupSize()).append(" / ")
.append(scenario.maximumGroupSize()).append(" / ").append(String.format(Locale.ROOT, "%.6f", scenario.meanGroupSize()))
.append(" / ").append(String.format(Locale.ROOT, "%.6f", scenario.medianGroupSize())).append("\n")
.append("- Exact first-field matches: ").append(scenario.exactMatches()).append(" / ").append(scenario.exactDenominator()).append("\n")
.append("- Under-stemming pairs: ").append(result.underErrorPairs()).append(" / ").append(result.underPossiblePairs()).append("\n")
.append("- Over-stemming pairs: ").append(result.overErrorPairs()).append(" / ").append(result.overPossiblePairs()).append("\n")
.append("- Independently summed under-stemming contributions: ").append(scenario.contributionSum()).append("\n\n")
.append("### Highest under-stemming contributors\n\n");
for (Contributor contributor : scenario.contributors()) {
text.append("#### Dictionary row ").append(contributor.rowNumber()).append("\n\n")
.append("Unique forms: ").append(contributor.groupSize()).append("; distinct predicted stems: ")
.append(contributor.formsByStem().size()).append("; contribution: ").append(contributor.errorPairs())
.append(" / ").append(contributor.possiblePairs()).append(" pairs.\n\n");
for (Map.Entry<String, List<String>> entry : contributor.formsByStem().entrySet()) {
text.append("- Predicted stem `").append(escape(entry.getKey())).append("` (").append(entry.getValue().size())
.append("): ").append(entry.getValue().stream().map(QualityAudit::quoted).toList()).append("\n");
}
text.append('\n');
}
}
final Path parent = path.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(path, text.toString(), StandardCharsets.UTF_8);
}
/** Calculates the conventional median of a sorted integer list. */
private static double median(final List<Integer> sorted) {
if (sorted.isEmpty()) {
return 0.0;
}
final int middle = sorted.size() / 2;
return sorted.size() % 2 == 0 ? (sorted.get(middle - 1) + sorted.get(middle)) / 2.0 : sorted.get(middle);
}
/** Escapes Markdown code-span delimiters. */
private static String escape(final String value) {
return value.replace("`", "\\`");
}
/** Quotes one original dictionary form for Markdown diagnostics. */
private static String quoted(final String value) {
return "`" + escape(value) + "`";
}
/** Immutable complete audit summary for one scenario. */
record Scenario(QualityResult result, String dictionarySource, long exactMatches, long exactDenominator,
int minimumGroupSize, int maximumGroupSize, double meanGroupSize, double medianGroupSize,
List<Contributor> contributors, long contributionSum) {
}
/** Immutable contribution of one gold-standard group. */
record Contributor(int rowNumber, int groupSize, Map<String, List<String>> formsByStem,
long errorPairs, long possiblePairs) {
}
}

View File

@@ -0,0 +1,197 @@
package org.egothor.stemmer.benchmark.quality;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.ArrayList;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix.BatchStemmer;
/** Evaluates pairwise partition agreement using aggregated frequencies, never explicit pairs. */
public final class QualityEvaluator {
/** Utility class. */
private QualityEvaluator() { throw new AssertionError("No instances."); }
/**
* Evaluates one scenario in time proportional to forms and group-to-stem associations.
* All combinatorial arithmetic is checked and overflow is reported.
*
* @param stemmerName stable stemmer name
* @param language stable language identifier
* @param mode processing mode
* @param groups parsed gold-standard groups
* @param stemmer stemmer implementation
* @return immutable metric result
*/
public static QualityResult evaluate(final String stemmerName, final String language, final ProcessingMode mode,
final Iterable<GoldStandardGroup> groups, final StemmerFunction stemmer) {
Objects.requireNonNull(groups, "groups");
Objects.requireNonNull(stemmer, "stemmer");
final Map<String, Long> global = new HashMap<>();
long rows = 0;
long words = 0;
long singletonRows = 0;
long pairRows = 0;
long underPossible = 0;
long withinSameStem = 0;
final Set<String> stems = new HashSet<>();
final Map<String, Long> local = new HashMap<>();
final List<Map<String, Long>> contingency = new ArrayList<>();
final List<Long> groupSizes = new ArrayList<>();
for (GoldStandardGroup group : groups) {
final List<String> forms = group.forms();
if (!mode.includes(forms)) {
continue;
}
rows = add(rows, 1, "applied dictionary rows");
words = add(words, forms.size(), "processed word forms");
if (forms.size() == 1) {
singletonRows = add(singletonRows, 1, "singleton dictionary rows");
} else {
pairRows = add(pairRows, 1, "dictionary rows contributing under-stemming pairs");
}
underPossible = add(underPossible, chooseTwo(forms.size()), "under-stemming possible pairs");
local.clear();
for (String form : forms) {
final String output;
try {
output = stemmer.stem(form);
} catch (IOException exception) {
throw failure(stemmerName, language, mode, group.rowNumber(), form,
"the stemmer threw an exception", exception);
}
if (output == null) {
throw failure(stemmerName, language, mode, group.rowNumber(), form,
"the stemmer returned null", null);
}
local.merge(output, 1L, (left, right) -> add(left, right, "group-to-stem frequency"));
global.merge(output, 1L, (left, right) -> add(left, right, "global stem frequency"));
stems.add(output);
}
for (long frequency : local.values()) {
withinSameStem = add(withinSameStem, chooseTwo(frequency), "within-group merged pairs");
}
contingency.add(Map.copyOf(local));
groupSizes.add((long) forms.size());
}
long allPairs = chooseTwo(words);
long overPossible = subtract(allPairs, underPossible, "over-stemming possible pairs");
long allSameStem = 0;
for (long frequency : global.values()) {
allSameStem = add(allSameStem, chooseTwo(frequency), "same-stem pairs");
}
final long underError = subtract(underPossible, withinSameStem, "under-stemming error pairs");
final long overError = subtract(allSameStem, withinSameStem, "over-stemming error pairs");
final PartitionMetrics partition = partitionMetrics(words, underPossible, allSameStem,
withinSameStem, groupSizes, global, contingency);
return new QualityResult(stemmerName, language, mode, OutputPolicy.PRIMARY_OUTPUT,
rows, words, singletonRows, pairRows, words, 0, words == 0 ? 0 : 1, words, stems.size(), overError, overPossible,
underError, underPossible, partition);
}
/** Calculates strict-partition metrics from the exact contingency table. */
private static PartitionMetrics partitionMetrics(final long words, final long rowPairs, final long columnPairs,
final long indexPairs, final List<Long> groupSizes, final Map<String, Long> global,
final List<Map<String, Long>> contingency) {
if (words == 0) { return new PartitionMetrics(0.0, 0.0, 0.0, 0.0, 0.0); }
final double totalPairs = chooseTwo(words);
final double expected = totalPairs == 0.0 ? 0.0 : (double) rowPairs * columnPairs / totalPairs;
final double maximum = (rowPairs + (double) columnPairs) / 2.0;
final double adjustedRand = maximum == expected ? 1.0 : (indexPairs - expected) / (maximum - expected);
final double goldEntropy = entropy(words, groupSizes);
final double predictedEntropy = entropy(words, global.values());
double mutualInformation = 0.0;
for (int group = 0; group < contingency.size(); group++) {
final long groupSize = groupSizes.get(group);
for (Map.Entry<String, Long> cell : contingency.get(group).entrySet()) {
final double frequency = cell.getValue();
mutualInformation += frequency / words * Math.log(frequency * words
/ (groupSize * (double) global.get(cell.getKey())));
}
}
final double homogeneity = goldEntropy == 0.0 ? 1.0 : mutualInformation / goldEntropy;
final double completeness = predictedEntropy == 0.0 ? 1.0 : mutualInformation / predictedEntropy;
final double vMeasure = homogeneity + completeness == 0.0 ? 0.0
: 2.0 * homogeneity * completeness / (homogeneity + completeness);
final double nmiDenominator = (goldEntropy + predictedEntropy) / 2.0;
final double nmi = nmiDenominator == 0.0 ? 1.0 : mutualInformation / nmiDenominator;
return new PartitionMetrics(adjustedRand, homogeneity, completeness, vMeasure, nmi);
}
/** Calculates natural-log entropy from category frequencies. */
private static double entropy(final long total, final Iterable<Long> frequencies) {
double weightedLogs = 0.0;
for (long frequency : frequencies) { weightedLogs += frequency * Math.log(frequency); }
return Math.log(total) - weightedLogs / total;
}
/**
* Evaluates one scenario through an authoritative JMH batch adapter.
* The temporary input and output arrays are required to preserve TokenStream
* lifecycle and preprocessing semantics used by the JMH comparison.
*
* @param stemmerName stable JMH candidate name
* @param language registered dictionary language
* @param mode processing mode
* @param groups parsed gold-standard groups
* @param stemmer scenario-confined batch adapter
* @return immutable pairwise result
* @throws IOException when the benchmark adapter fails
*/
public static QualityResult evaluateBatch(final String stemmerName, final String language,
final ProcessingMode mode, final List<GoldStandardGroup> groups, final BatchStemmer stemmer)
throws IOException {
final List<String> included = new ArrayList<>();
for (GoldStandardGroup group : groups) {
if (mode.includes(group.forms())) {
included.addAll(group.forms());
}
}
final String[] outputs = stemmer.stem(included.toArray(String[]::new));
if (outputs == null || outputs.length != included.size()) {
throw new IOException("JMH stemmer " + stemmerName + " returned an invalid output batch for language "
+ language + " and processing mode " + mode + ".");
}
final int[] index = {0};
return evaluate(stemmerName, language, mode, groups, word -> {
final String output = outputs[index[0]++];
if (output == null) {
throw new IOException("JMH stemmer " + stemmerName + " returned null for language " + language
+ ", processing mode " + mode + ", and word form '" + word + "'.");
}
return output;
});
}
/** Calculates C2(n) with checked arithmetic. */
/* default */ static long chooseTwo(final long value) {
if (value < 0) { throw new IllegalArgumentException("Pair population must not be negative."); }
try {
return value % 2 == 0 ? Math.multiplyExact(value / 2, value - 1)
: Math.multiplyExact(value, (value - 1) / 2);
} catch (ArithmeticException exception) {
throw new IllegalStateException("Arithmetic overflow while calculating unordered word-form pairs.", exception);
}
}
/** Checked addition with metric context. */
private static long add(final long left, final long right, final String context) {
try { return Math.addExact(left, right); }
catch (ArithmeticException exception) { throw new IllegalStateException("Arithmetic overflow in " + context + ".", exception); }
}
/** Checked subtraction with metric context. */
private static long subtract(final long left, final long right, final String context) {
try { return Math.subtractExact(left, right); }
catch (ArithmeticException exception) { throw new IllegalStateException("Arithmetic overflow in " + context + ".", exception); }
}
/** Builds a contextual failure without producing a partial result. */
private static IllegalStateException failure(final String stemmer, final String language,
final ProcessingMode mode, final int row, final String form, final String reason, final Exception cause) {
final String message = "Quality evaluation failed for stemmer " + stemmer + ", language " + language
+ ", processing mode " + mode + ", dictionary row " + row + ", word form '" + form + "': " + reason + ".";
return cause == null ? new IllegalStateException(message) : new IllegalStateException(message, cause);
}
}

View File

@@ -0,0 +1,178 @@
package org.egothor.stemmer.benchmark.quality;
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;
import java.util.List;
import java.util.Map;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Random;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/** Mathematical and filtering tests for pairwise quality evaluation. */
@Tag("unit")
@DisplayName("Pairwise stemming-quality evaluator")
final class QualityEvaluatorTest {
/** Verifies a perfect partition. */
@Test @DisplayName("A perfect predicted partition has no errors")
void perfectPartition() {
final QualityResult result = evaluate(List.of(group(1, "a", "b"), group(2, "c", "d")),
Map.of("a", "x", "b", "x", "c", "y", "d", "y"));
assertEquals(0, result.overErrorPairs()); assertEquals(4, result.overPossiblePairs());
assertEquals(0, result.underErrorPairs()); assertEquals(2, result.underPossiblePairs());
assertEquals(2, result.distinctOutputStems());
assertEquals(1.0, result.partitionMetrics().adjustedRandIndex(), 1.0e-12);
assertEquals(1.0, result.partitionMetrics().homogeneity(), 1.0e-12);
assertEquals(1.0, result.partitionMetrics().completeness(), 1.0e-12);
assertEquals(1.0, result.partitionMetrics().vMeasure(), 1.0e-12);
assertEquals(1.0, result.partitionMetrics().normalizedMutualInformation(), 1.0e-12);
}
/** Verifies partial merge and pure under-stemming pair counts. */
@Test @DisplayName("A partial within-group merge is counted by pairs")
void partialMerge() {
final QualityResult result = evaluate(List.of(group(1, "a", "b", "c"), group(2, "d")),
Map.of("a", "x", "b", "x", "c", "z", "d", "q"));
assertEquals(2, result.underErrorPairs()); assertEquals(3, result.underPossiblePairs());
assertEquals(0, result.overErrorPairs()); assertEquals(3, result.overPossiblePairs());
}
/** Verifies multi-group over-stemming combinatorics. */
@Test @DisplayName("Several gold groups colliding in one stem count every cross-group pair")
void multipleGroupsCollide() {
final QualityResult result = evaluate(List.of(group(1, "a", "b"), group(2, "c"), group(3, "d", "e", "f")),
Map.of("a", "x", "b", "x", "c", "x", "d", "x", "e", "x", "f", "x"));
assertEquals(11, result.overErrorPairs()); assertEquals(11, result.overPossiblePairs());
assertEquals(0, result.underErrorPairs());
}
/** Verifies combined split and collision counts. */
@Test @DisplayName("Combined over-stemming and under-stemming are independent")
void combinedErrors() {
final QualityResult result = evaluate(List.of(group(1, "a", "b", "c"), group(2, "d", "e")),
Map.of("a", "x", "b", "x", "c", "y", "d", "y", "e", "y"));
assertEquals(2, result.overErrorPairs()); assertEquals(6, result.overPossiblePairs());
assertEquals(2, result.underErrorPairs()); assertEquals(4, result.underPossiblePairs());
}
/** Verifies duplicate scope and singleton undefined denominator. */
@Test @DisplayName("Duplicates are removed only within a group and singleton under-stemming is undefined")
void duplicateScope() {
final QualityResult result = evaluate(List.of(group(1, "same", "same"), group(2, "same")), Map.of("same", "x"));
assertEquals(2, result.processedWordForms()); assertEquals(1, result.overErrorPairs());
assertTrue(result.underPercentage().isEmpty());
}
/** Verifies the zero over-stemming denominator. */
@Test @DisplayName("One gold group has an undefined over-stemming percentage")
void zeroOverDenominator() {
final QualityResult result = evaluate(List.of(group(1, "a", "b")), Map.of("a", "x", "b", "y"));
assertTrue(result.overPercentage().isEmpty()); assertFalse(result.underPercentage().isEmpty());
}
/** Verifies Unicode code-point filtering and uncased data. */
@Test @DisplayName("Lowercase filtering detects uppercase and titlecase code points without excluding uncased symbols")
void lowercaseFiltering() {
assertTrue(ProcessingMode.LOWERCASE_GROUPS_ONLY.includes(List.of("žluťoučký-123", "தமிழ்")));
assertFalse(ProcessingMode.LOWERCASE_GROUPS_ONLY.includes(List.of("Upper")));
assertFalse(ProcessingMode.LOWERCASE_GROUPS_ONLY.includes(List.of("Džungla")));
assertFalse(ProcessingMode.LOWERCASE_GROUPS_ONLY.includes(List.of("a\uD801\uDC00")));
}
/** Verifies contextual stemmer failures. */
@Test @DisplayName("Stemmer exceptions contain complete scenario context")
void stemmerFailure() {
final IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> QualityEvaluator.evaluate("Broken", "TEST", ProcessingMode.ALL_WORDS,
List.of(group(7, "word")), word -> { throw new IOException("failure"); }));
assertTrue(exception.getMessage().contains("dictionary row 7")); assertTrue(exception.getMessage().contains("word form 'word'"));
}
/** Verifies the largest safe and first overflowing combinatorial values. */
@Test @DisplayName("Pair calculation detects arithmetic overflow")
void arithmeticBoundary() {
assertEquals(4_611_686_013_944_624_251L, QualityEvaluator.chooseTwo(3_037_000_499L));
assertThrows(IllegalStateException.class, () -> QualityEvaluator.chooseTwo(Long.MAX_VALUE));
}
/** Demonstrates the documented denominator difference from exact accuracy. */
@Test @DisplayName("Ninety-nine percent exact accuracy can coexist with sixteen percent pairwise under-stemming")
void exactAccuracyAndPairwiseRateUseDifferentDenominators() {
final List<GoldStandardGroup> groups = new ArrayList<>();
final Map<String, String> stems = new HashMap<>();
for (int index = 0; index < 88; index++) {
final String form = "singleton-" + index;
groups.add(group(index + 1, form));
stems.put(form, form);
}
final String[] largeGroup = new String[12];
for (int index = 0; index < largeGroup.length; index++) {
largeGroup[index] = "form-" + index;
stems.put(largeGroup[index], index == 11 ? "different" : "shared");
}
groups.add(group(89, largeGroup));
final QualityResult result = evaluate(groups, stems);
assertEquals(100, result.processedWordForms());
assertEquals(66, result.underPossiblePairs());
assertEquals(11, result.underErrorPairs());
assertEquals(16.666666666666668, result.underPercentage().orElseThrow(), 0.000000000000001);
}
/** Compares the optimized accumulator with an independent explicit pair oracle. */
@Test @DisplayName("Deterministic randomized partitions agree with a brute-force pair oracle")
void randomizedOracleAgreement() {
final Random random = new Random(0x52414449584f52L);
for (int trial = 0; trial < 250; trial++) {
final List<GoldStandardGroup> groups = new ArrayList<>();
final Map<String, String> stems = new HashMap<>();
final Map<String, Integer> gold = new HashMap<>();
final int groupCount = 1 + random.nextInt(7);
int formIndex = 0;
for (int groupIndex = 0; groupIndex < groupCount; groupIndex++) {
final int size = 1 + random.nextInt(6);
final String[] forms = new String[size];
for (int index = 0; index < size; index++) {
final String form = "t" + trial + "-f" + formIndex++;
forms[index] = form;
gold.put(form, groupIndex);
stems.put(form, "s" + random.nextInt(6));
}
groups.add(group(groupIndex + 1, forms));
}
final QualityResult optimized = evaluate(groups, stems);
final long[] oracle = bruteForce(new ArrayList<>(gold.keySet()), gold, stems);
assertEquals(oracle[0], optimized.underErrorPairs(), "Under-stemming errors differed in trial " + trial);
assertEquals(oracle[1], optimized.underPossiblePairs(), "Under-stemming denominator differed in trial " + trial);
assertEquals(oracle[2], optimized.overErrorPairs(), "Over-stemming errors differed in trial " + trial);
assertEquals(oracle[3], optimized.overPossiblePairs(), "Over-stemming denominator differed in trial " + trial);
}
}
/** Explicit quadratic oracle used only for small controlled test data. */
private static long[] bruteForce(final List<String> forms, final Map<String, Integer> gold,
final Map<String, String> stems) {
long underError = 0;
long underPossible = 0;
long overError = 0;
long overPossible = 0;
for (int left = 0; left < forms.size(); left++) {
for (int right = left + 1; right < forms.size(); right++) {
final boolean sameGold = gold.get(forms.get(left)).equals(gold.get(forms.get(right)));
final boolean sameStem = stems.get(forms.get(left)).equals(stems.get(forms.get(right)));
if (sameGold) {
underPossible++;
if (!sameStem) { underError++; }
} else {
overPossible++;
if (sameStem) { overError++; }
}
}
}
return new long[] {underError, underPossible, overError, overPossible};
}
/** Builds a group. */
private static GoldStandardGroup group(final int row, final String... forms) { return new GoldStandardGroup(row, List.of(forms)); }
/** Runs the common synthetic evaluator. */
private static QualityResult evaluate(final List<GoldStandardGroup> groups, final Map<String, String> stems) {
return QualityEvaluator.evaluate("Synthetic", "TEST", ProcessingMode.ALL_WORDS, groups, stems::get);
}
}

View File

@@ -0,0 +1,256 @@
package org.egothor.stemmer.benchmark.quality;
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.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.OptionalDouble;
import java.util.Set;
import java.util.TreeSet;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix.Candidate;
/** Writes deterministic UTF-8 Markdown and CSV quality reports. */
public final class QualityReportWriter {
private static final String TABLE_DELIMITER = " | ";
/** Utility class. */
private QualityReportWriter() { throw new AssertionError("No instances."); }
/** Writes a report without external coverage metadata for focused formatting tests. */
public static void writeMarkdown(final Path path, final Iterable<QualityResult> input,
final boolean filtered) throws IOException {
writeMarkdown(path, input, filtered, new LanguageUniverse(java.util.Map.of(), List.of(), List.of()),
List.of(), sorted(input).size(), "PAIRWISE_F05");
}
/** Writes the human-readable report with methodology and required table columns. */
public static void writeMarkdown(final Path path, final Iterable<QualityResult> input, final boolean filtered,
final LanguageUniverse universe, final List<Candidate> candidates, final int expectedRows,
final String rankMetric) throws IOException {
final List<QualityResult> rows = sorted(input);
final StringBuilder text = new StringBuilder(4096);
text.append("# Stemming quality\n\n");
if (filtered) {
text.append("> This is a filtered analytical report and is not the complete JMH candidate matrix.\n\n");
}
text.append("## Methodology\n\nEach parsed multilingual dictionary row is a gold-standard equivalence class. Exact duplicates are removed only within that row. `PRIMARY_OUTPUT` is the deterministic JMH partition. `ANY_CANDIDATE` is an optimistic oracle-assisted pairwise upper bound: within-row sets must intersect, while a cross-row error occurs only for two equal singleton sets. `ALL_CANDIDATES` activates the complete overlap relation: within-row disjoint sets are false negatives and cross-row intersections are false positives. A shared pair is counted once. Candidate policies need not define partitions.\n\nTP is a related within-row pair, FN is an unrelated within-row pair, FP is a related cross-row pair, and TN is an unrelated cross-row pair. Under-stemming is FN/(TP+FN); over-stemming is FP/(TN+FP), so their denominators differ. F0.5 emphasizes precision, F1 balances precision and recall, and F2 emphasizes recall. Undefined values are `n/a`. Percentages and scores use `Locale.ROOT`.\n\n| Stemmer | Language | Dictionary mode | Output policy | Applied dictionary rows | Processed word forms | Distinct output stems | Over-stemming | Under-stemming | Pairwise F0.5 | Pairwise F1 | Pairwise F2 |\n|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|\n");
for (QualityResult row : rows) {
text.append("| ").append(escapeMarkdown(row.stemmer())).append(TABLE_DELIMITER)
.append(escapeMarkdown(row.language())).append(TABLE_DELIMITER).append(row.processingMode()).append(TABLE_DELIMITER)
.append(row.outputPolicy()).append(TABLE_DELIMITER).append(row.appliedDictionaryRows()).append(TABLE_DELIMITER)
.append(row.processedWordForms()).append(TABLE_DELIMITER).append(row.distinctOutputStems()).append(TABLE_DELIMITER)
.append(humanMetric(row.overErrorPairs(), row.overPossiblePairs(), row.overPercentage())).append(TABLE_DELIMITER)
.append(humanMetric(row.underErrorPairs(), row.underPossiblePairs(), row.underPercentage())).append(TABLE_DELIMITER)
.append(score(row.pairwiseMetrics().f05())).append(TABLE_DELIMITER)
.append(score(row.pairwiseMetrics().f1())).append(TABLE_DELIMITER)
.append(score(row.pairwiseMetrics().f2())).append(" |\n");
}
appendComparisons(text, rows);
appendCoverage(text, universe, candidates, expectedRows, rows.size());
appendRankings(text, rows, rankMetric);
appendSummaries(text, rows);
text.append("\n## Reproducibility environment\n\n- JDK: `").append(System.getProperty("java.version"))
.append("`\n- Operating system: `").append(System.getProperty("os.name")).append(' ')
.append(System.getProperty("os.version")).append("`\n");
write(path, text.toString());
}
/** Writes machine-readable counts and separate percentage fields. */
public static void writeCsv(final Path path, final Iterable<QualityResult> input) throws IOException {
final StringBuilder text = new StringBuilder(4096);
text.append("Stemmer,Language,Dictionary mode,Output policy,Applied dictionary rows,Processed word forms,Singleton dictionary rows,Forms with one candidate,Forms with multiple candidates,Maximum candidates for one form,Total candidate assignments,Distinct output stems,True-positive pairs,False-positive pairs,False-negative pairs,True-negative pairs,Over-stemming error pairs,Over-stemming possible pairs,Over-stemming percentage,Under-stemming error pairs,Under-stemming possible pairs,Under-stemming percentage,Pairwise precision,Pairwise recall,Pairwise specificity,Pairwise accuracy,Balanced accuracy,Pairwise F0.5,Pairwise F1,Pairwise F2,Jaccard index,Fowlkes-Mallows index,Matthews correlation coefficient,Pairwise error rate,Adjusted Rand Index,Homogeneity,Completeness,V-measure,Normalized mutual information\n");
for (QualityResult row : sorted(input)) {
final PairwiseMetrics metrics = row.pairwiseMetrics();
appendCsv(text, row.stemmer()); appendCsv(text, row.language()); appendCsv(text, row.processingMode().name());
appendCsv(text, row.outputPolicy().name());
appendCsv(text, Long.toString(row.appliedDictionaryRows())); appendCsv(text, Long.toString(row.processedWordForms()));
appendCsv(text, Long.toString(row.singletonDictionaryRows()));
appendCsv(text, Long.toString(row.formsWithOneCandidate()));
appendCsv(text, Long.toString(row.formsWithMultipleCandidates()));
appendCsv(text, Long.toString(row.maximumCandidatesForOneWord()));
appendCsv(text, Long.toString(row.totalCandidateAssignments()));
appendCsv(text, Long.toString(row.distinctOutputStems()));
appendCsv(text, Long.toString(metrics.truePositivePairs())); appendCsv(text, Long.toString(metrics.falsePositivePairs()));
appendCsv(text, Long.toString(metrics.falseNegativePairs())); appendCsv(text, Long.toString(metrics.trueNegativePairs()));
appendCsv(text, Long.toString(row.overErrorPairs()));
appendCsv(text, Long.toString(row.overPossiblePairs())); appendCsv(text, machinePercent(row.overPercentage()));
appendCsv(text, Long.toString(row.underErrorPairs())); appendCsv(text, Long.toString(row.underPossiblePairs()));
appendCsv(text, machinePercent(row.underPercentage()));
appendCsv(text, machineScore(metrics.precision())); appendCsv(text, machineScore(metrics.recall()));
appendCsv(text, machineScore(metrics.specificity())); appendCsv(text, machineScore(metrics.accuracy()));
appendCsv(text, machineScore(metrics.balancedAccuracy())); appendCsv(text, machineScore(metrics.f05()));
appendCsv(text, machineScore(metrics.f1())); appendCsv(text, machineScore(metrics.f2()));
appendCsv(text, machineScore(metrics.jaccard())); appendCsv(text, machineScore(metrics.fowlkesMallows()));
appendCsv(text, machineScore(metrics.matthewsCorrelationCoefficient())); appendCsv(text, machineScore(metrics.errorRate()));
final PartitionMetrics partition = row.partitionMetrics();
appendCsv(text, partition == null ? "" : format(partition.adjustedRandIndex()));
appendCsv(text, partition == null ? "" : format(partition.homogeneity()));
appendCsv(text, partition == null ? "" : format(partition.completeness()));
appendCsv(text, partition == null ? "" : format(partition.vMeasure()));
appendCsv(text, partition == null ? "" : format(partition.normalizedMutualInformation()));
text.setLength(text.length() - 1); text.append('\n');
}
write(path, text.toString());
}
/** Appends deterministic primary-versus-candidate trade-off rows for multi-output scenarios. */
private static void appendComparisons(final StringBuilder text, final List<QualityResult> rows) {
text.append("\n## Primary-versus-candidate comparison\n\n")
.append("| Stemmer | Language | Dictionary mode | Primary under | Any under | All under | Repaired under | Primary over | Any over | Best-case avoided over | All over | Additional all-candidate over | Multi-candidate forms | Multi-candidate percent | Maximum candidates | Candidate assignments |\n")
.append("|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|\n");
for (QualityResult candidate : rows) {
if (candidate.outputPolicy() != OutputPolicy.ANY_CANDIDATE) { continue; }
final QualityResult primary = rows.stream().filter(row -> row.stemmer().equals(candidate.stemmer())
&& row.language().equals(candidate.language()) && row.processingMode() == candidate.processingMode()
&& row.outputPolicy() == OutputPolicy.PRIMARY_OUTPUT).findFirst().orElse(null);
final QualityResult all = rows.stream().filter(row -> row.stemmer().equals(candidate.stemmer())
&& row.language().equals(candidate.language()) && row.processingMode() == candidate.processingMode()
&& row.outputPolicy() == OutputPolicy.ALL_CANDIDATES).findFirst().orElse(null);
if (primary == null || all == null) { continue; }
text.append("| ").append(escapeMarkdown(candidate.stemmer())).append(TABLE_DELIMITER)
.append(escapeMarkdown(candidate.language())).append(TABLE_DELIMITER).append(candidate.processingMode()).append(TABLE_DELIMITER)
.append(primary.underErrorPairs()).append(TABLE_DELIMITER).append(candidate.underErrorPairs()).append(TABLE_DELIMITER)
.append(all.underErrorPairs()).append(TABLE_DELIMITER)
.append(primary.underErrorPairs() - candidate.underErrorPairs()).append(TABLE_DELIMITER)
.append(primary.overErrorPairs()).append(TABLE_DELIMITER).append(candidate.overErrorPairs()).append(TABLE_DELIMITER)
.append(primary.overErrorPairs() - candidate.overErrorPairs()).append(TABLE_DELIMITER)
.append(all.overErrorPairs()).append(TABLE_DELIMITER)
.append(all.overErrorPairs() - primary.overErrorPairs()).append(TABLE_DELIMITER)
.append(candidate.formsWithMultipleCandidates()).append(TABLE_DELIMITER)
.append(String.format(Locale.ROOT, "%.6f%%", 100.0 * candidate.formsWithMultipleCandidates()
/ candidate.processedWordForms())).append(TABLE_DELIMITER)
.append(candidate.maximumCandidatesForOneWord()).append(TABLE_DELIMITER)
.append(candidate.totalCandidateAssignments()).append(" |\n");
}
}
/** Appends validated language, adapter, policy, and row-count coverage. */
private static void appendCoverage(final StringBuilder text, final LanguageUniverse universe,
final List<Candidate> candidates, final int expectedRows, final int actualRows) {
text.append("\n## Matrix coverage\n\n- Discovered dictionary languages: ").append(universe.resourceDirectories()).append("\n")
.append("- Discovered `StemmerPatchTrieLoader.Language` values: ").append(universe.enumerationValues()).append("\n")
.append("- Reconciled mappings: ").append(universe.dictionaries().entrySet().stream()
.sorted(java.util.Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " -> " + entry.getValue().getFileName()).toList()).append("\n")
.append("- Discovered adapter-language mappings: ").append(candidates.size()).append("\n")
.append("- Expected result rows: ").append(expectedRows).append("\n")
.append("- Actual result rows: ").append(actualRows).append("\n\n")
.append("Unsupported third-party combinations are excluded because their authoritative JMH adapter metadata declares no mapping for that language. They are not emitted as zero-valued rows. Radixor is independently registered for every reconciled dictionary language.\n");
final java.util.Map<String, Set<String>> support = new java.util.TreeMap<>();
for (Candidate candidate : candidates) { support.computeIfAbsent(candidate.name(), ignored -> new TreeSet<>()).add(candidate.language().name()); }
text.append("\n| Adapter | Supported language count | Supported languages |\n|---|---:|---|\n");
support.forEach((name, languages) -> text.append("| ").append(escapeMarkdown(name)).append(TABLE_DELIMITER)
.append(languages.size()).append(TABLE_DELIMITER).append(languages).append(" |\n"));
}
/** Appends policy-separated rankings for the requested navigation metric and all required alternatives. */
private static void appendRankings(final StringBuilder text, final List<QualityResult> rows, final String selectedMetric) {
text.append("\n## Rankings\n\nThe default or selected ranking metric (`").append(selectedMetric)
.append("`) is a navigation choice, not a declaration of universal scientific superiority. Policies are ranked separately. Full-coverage and common-language comparisons must not be conflated.\n");
final List<String> metricNames = List.of("Pairwise F0.5", "Pairwise F1", "Pairwise F2", "Jaccard index",
"Fowlkes-Mallows index", "Matthews correlation coefficient", "Balanced accuracy", "Adjusted Rand Index");
for (String metric : metricNames) {
text.append("\n### ").append(metric).append("\n\n| Output policy | Stemmer | Language | Dictionary mode | Score |\n|---|---|---|---|---:|\n");
rows.stream().filter(row -> !metric.equals("Adjusted Rand Index") || row.outputPolicy() == OutputPolicy.PRIMARY_OUTPUT)
.sorted(Comparator.comparingDouble((QualityResult row) -> rankingValue(row, metric)).reversed()
.thenComparingDouble(row -> row.overPercentage().orElse(Double.POSITIVE_INFINITY))
.thenComparingLong(QualityResult::overErrorPairs)
.thenComparingDouble(row -> row.underPercentage().orElse(Double.POSITIVE_INFINITY))
.thenComparing(QualityResult::stemmer).thenComparing(QualityResult::language))
.limit(25).forEach(row -> text.append("| ").append(row.outputPolicy()).append(TABLE_DELIMITER)
.append(escapeMarkdown(row.stemmer())).append(TABLE_DELIMITER).append(row.language()).append(TABLE_DELIMITER)
.append(row.processingMode()).append(TABLE_DELIMITER).append(score(metricValue(row, metric))).append(" |\n"));
}
}
/** Returns one optional ranking metric. */
private static OptionalDouble metricValue(final QualityResult row, final String metric) {
return switch (metric) {
case "Pairwise F0.5" -> row.pairwiseMetrics().f05(); case "Pairwise F1" -> row.pairwiseMetrics().f1();
case "Pairwise F2" -> row.pairwiseMetrics().f2(); case "Jaccard index" -> row.pairwiseMetrics().jaccard();
case "Fowlkes-Mallows index" -> row.pairwiseMetrics().fowlkesMallows();
case "Matthews correlation coefficient" -> row.pairwiseMetrics().matthewsCorrelationCoefficient();
case "Balanced accuracy" -> row.pairwiseMetrics().balancedAccuracy();
case "Adjusted Rand Index" -> row.partitionMetrics() == null ? OptionalDouble.empty()
: OptionalDouble.of(row.partitionMetrics().adjustedRandIndex());
default -> OptionalDouble.empty();
};
}
/** Appends full-coverage micro and macro summaries with explicit coverage. */
private static void appendSummaries(final StringBuilder text, final List<QualityResult> rows) {
text.append("\n## Aggregate summaries\n\nMicro values sum raw confusion counts before calculation. Macro values average defined per-language F1 values.\n\n")
.append("| Stemmer | Dictionary mode | Output policy | Languages | Micro F0.5 | Micro F1 | Micro F2 | Macro F1 | Macro contributing languages |\n")
.append("|---|---|---|---:|---:|---:|---:|---:|---:|\n");
final java.util.Map<String, List<QualityResult>> groups = new java.util.TreeMap<>();
for (QualityResult row : rows) {
groups.computeIfAbsent(row.stemmer() + "\u0000" + row.processingMode() + "\u0000" + row.outputPolicy(),
ignored -> new ArrayList<>()).add(row);
}
for (List<QualityResult> group : groups.values()) {
final QualityResult first = group.get(0); long tp = 0; long fp = 0; long fn = 0; long tn = 0;
double macroF1 = 0.0; int macroCount = 0; final Set<String> languages = new TreeSet<>();
for (QualityResult row : group) {
final PairwiseMetrics metrics = row.pairwiseMetrics();
tp = Math.addExact(tp, metrics.truePositivePairs()); fp = Math.addExact(fp, metrics.falsePositivePairs());
fn = Math.addExact(fn, metrics.falseNegativePairs()); tn = Math.addExact(tn, metrics.trueNegativePairs());
if (metrics.f1().isPresent()) { macroF1 += metrics.f1().getAsDouble(); macroCount++; }
languages.add(row.language());
}
final PairwiseMetrics micro = new PairwiseMetrics(tp, fp, fn, tn);
text.append("| ").append(escapeMarkdown(first.stemmer())).append(TABLE_DELIMITER).append(first.processingMode())
.append(TABLE_DELIMITER).append(first.outputPolicy()).append(TABLE_DELIMITER).append(languages.size())
.append(TABLE_DELIMITER).append(score(micro.f05())).append(TABLE_DELIMITER).append(score(micro.f1()))
.append(TABLE_DELIMITER).append(score(micro.f2())).append(TABLE_DELIMITER)
.append(macroCount == 0 ? "n/a" : format(macroF1 / macroCount)).append(TABLE_DELIMITER)
.append(macroCount).append(" |\n");
}
Set<String> common = null;
final java.util.Map<String, Set<String>> byStemmer = new java.util.TreeMap<>();
for (QualityResult row : rows) { byStemmer.computeIfAbsent(row.stemmer(), ignored -> new TreeSet<>()).add(row.language()); }
for (Set<String> supported : byStemmer.values()) {
if (common == null) { common = new TreeSet<>(supported); } else { common.retainAll(supported); }
}
text.append("\n### Common-language comparison\n\nCommon language intersection across displayed stemmers: ")
.append(common == null ? Set.of() : common).append(". Unsupported languages are not assigned zero scores.\n");
}
/** Converts an undefined metric to negative infinity for descending navigation order. */
private static double rankingValue(final QualityResult row, final String metric) { return metricValue(row, metric).orElse(Double.NEGATIVE_INFINITY); }
/** Returns a sorted defensive list for deterministic output. */
private static List<QualityResult> sorted(final Iterable<QualityResult> input) {
final List<QualityResult> rows = new ArrayList<>();
input.forEach(rows::add); rows.sort(QualityResult.ORDER); return rows;
}
/** Formats one human-readable ratio. */
private static String humanMetric(final long errors, final long possible, final OptionalDouble percentage) {
if (percentage.isEmpty()) { return errors + " / " + possible + " (n/a)"; }
return String.format(Locale.ROOT, "%d / %d (%.6f%%)", errors, possible, percentage.getAsDouble());
}
/** Formats one optional machine-readable percentage. */
private static String machinePercent(final OptionalDouble percentage) {
return percentage.isEmpty() ? "" : String.format(Locale.ROOT, "%.6f", percentage.getAsDouble());
}
/** Formats one bounded or signed score for Markdown. */
private static String score(final OptionalDouble value) { return value.isEmpty() ? "n/a" : format(value.getAsDouble()); }
/** Formats one optional score for machine-readable output. */
private static String machineScore(final OptionalDouble value) { return value.isEmpty() ? "" : format(value.getAsDouble()); }
/** Formats an unrounded calculation deterministically with scientific precision. */
private static String format(final double value) { return String.format(Locale.ROOT, "%.12f", value); }
/** Appends one correctly quoted CSV field and delimiter. */
private static void appendCsv(final StringBuilder output, final String value) {
output.append('"').append(value.replace("\"", "\"\"")).append("\",");
}
/** Escapes Markdown table delimiters. */
private static String escapeMarkdown(final String value) { return value.replace("|", "\\|"); }
/** Creates the parent directory and atomically delegates UTF-8 file writing. */
private static void write(final Path path, final String content) throws IOException {
final Path parent = path.toAbsolutePath().getParent();
if (parent != null) { Files.createDirectories(parent); }
Files.writeString(path, content, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,74 @@
package org.egothor.stemmer.benchmark.quality;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** UTF-8, formatting, ordering, escaping, and write-failure tests for reports. */
@Tag("unit")
@DisplayName("Stemming-quality report writer")
final class QualityReportWriterTest {
/** Temporary output directory owned by JUnit. */
@TempDir Path temporaryDirectory;
/** Verifies required Markdown semantics and deterministic row ordering. */
@Test @DisplayName("Markdown contains the required English columns and deterministic metrics")
void markdownFormat() throws IOException {
final Path report = this.temporaryDirectory.resolve("report.md");
QualityReportWriter.writeMarkdown(report, List.of(result("Zulu", "B", 1, 2), result("Alpha|Stemmer", "A", 0, 0)), false);
final String text = Files.readString(report, StandardCharsets.UTF_8);
assertTrue(text.contains("| Stemmer | Language | Dictionary mode | Output policy | Applied dictionary rows | Processed word forms | Distinct output stems | Over-stemming | Under-stemming | Pairwise F0.5 | Pairwise F1 | Pairwise F2 |"));
assertTrue(text.contains("0 / 0 (n/a)"));
assertTrue(text.contains("1 / 2 (50.000000%)"));
assertTrue(text.indexOf("Alpha\\|Stemmer") < text.indexOf("Zulu"));
assertEquals(text, new String(Files.readAllBytes(report), StandardCharsets.UTF_8));
}
/** Verifies CSV headers, separate missing fields, ordering, and quoting. */
@Test @DisplayName("CSV uses separate English columns, correct quoting, and empty undefined percentages")
void csvFormat() throws IOException {
final Path report = this.temporaryDirectory.resolve("report.csv");
QualityReportWriter.writeCsv(report, List.of(result("Stemmer, \"quoted\"", "A", 0, 0)));
final String text = Files.readString(report, StandardCharsets.UTF_8);
assertTrue(text.startsWith("Stemmer,Language,Dictionary mode,Output policy,Applied dictionary rows,Processed word forms,Singleton dictionary rows,Forms with one candidate,"));
assertTrue(text.contains("\"Stemmer, \"\"quoted\"\"\""));
assertTrue(text.contains("Adjusted Rand Index,Homogeneity,Completeness,V-measure,Normalized mutual information"));
}
/** Verifies that filesystem failures are propagated. */
@Test @DisplayName("A report write failure is propagated")
void writeFailure() throws IOException {
final Path file = this.temporaryDirectory.resolve("parent-file");
Files.writeString(file, "occupied", StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> QualityReportWriter.writeMarkdown(file.resolve("report.md"), List.of(), false));
}
/** Verifies that a second generation replaces stale content instead of appending. */
@Test @DisplayName("Report generation replaces stale content")
void reportReplacement() throws IOException {
final Path report = this.temporaryDirectory.resolve("replacement.md");
QualityReportWriter.writeMarkdown(report, List.of(result("Old", "A", 0, 1)), false);
QualityReportWriter.writeMarkdown(report, List.of(result("New", "B", 0, 1)), false);
final String text = Files.readString(report, StandardCharsets.UTF_8);
assertTrue(text.contains("New"));
assertTrue(!text.contains("Old"));
}
/** Creates a compact valid result for formatting tests. */
private static QualityResult result(final String stemmer, final String language, final long errors, final long possible) {
return new QualityResult(stemmer, language, ProcessingMode.ALL_WORDS, OutputPolicy.PRIMARY_OUTPUT,
1, 1, 1, 0, 1, 0, 1, 1, 1, errors, possible, 0, 0,
new PartitionMetrics(1.0, 1.0, 1.0, 1.0, 1.0));
}
}

View File

@@ -0,0 +1,53 @@
package org.egothor.stemmer.benchmark.quality;
import java.util.Comparator;
import java.util.Objects;
import java.util.OptionalDouble;
/** Immutable pairwise stemming-quality result; all pair quantities are counts. */
public record QualityResult(String stemmer, String language, ProcessingMode processingMode,
OutputPolicy outputPolicy,
long appliedDictionaryRows, long processedWordForms, long singletonDictionaryRows,
long dictionaryRowsContributingUnderPairs, long formsWithOneCandidate, long formsWithMultipleCandidates,
long maximumCandidatesForOneWord, long totalCandidateAssignments, long distinctOutputStems,
long overErrorPairs, long overPossiblePairs, long underErrorPairs, long underPossiblePairs,
PartitionMetrics partitionMetrics) {
/** Stable report ordering by stemmer, language, and processing mode. */
public static final Comparator<QualityResult> ORDER = Comparator.comparing(QualityResult::stemmer)
.thenComparing(QualityResult::language).thenComparing(QualityResult::processingMode)
.thenComparing(QualityResult::outputPolicy);
/** Validates non-null labels, non-negative counts, and bounded errors. */
public QualityResult {
Objects.requireNonNull(stemmer, "stemmer");
Objects.requireNonNull(language, "language");
Objects.requireNonNull(processingMode, "processingMode");
Objects.requireNonNull(outputPolicy, "outputPolicy");
final long[] counts = {appliedDictionaryRows, processedWordForms, singletonDictionaryRows,
dictionaryRowsContributingUnderPairs, formsWithOneCandidate, formsWithMultipleCandidates,
maximumCandidatesForOneWord, totalCandidateAssignments, distinctOutputStems,
overErrorPairs, overPossiblePairs, underErrorPairs, underPossiblePairs};
for (long count : counts) {
if (count < 0) {
throw new IllegalArgumentException("Quality-result counts must not be negative.");
}
}
if (overErrorPairs > overPossiblePairs || underErrorPairs > underPossiblePairs) {
throw new IllegalArgumentException("Error-pair counts must not exceed possible-pair counts.");
}
if (outputPolicy != OutputPolicy.PRIMARY_OUTPUT && partitionMetrics != null) {
throw new IllegalArgumentException("Partition metrics apply only to PRIMARY_OUTPUT.");
}
}
/** @return over-stemming percentage, or empty when its denominator is zero */
public OptionalDouble overPercentage() { return percentage(overErrorPairs, overPossiblePairs); }
/** @return under-stemming percentage, or empty when its denominator is zero */
public OptionalDouble underPercentage() { return percentage(underErrorPairs, underPossiblePairs); }
/** @return aggregate pairwise metrics derived from raw confusion counts */
public PairwiseMetrics pairwiseMetrics() { return PairwiseMetrics.from(this); }
/** Calculates a percentage without manufacturing a value for a zero denominator. */
private static OptionalDouble percentage(final long errors, final long possible) {
return possible == 0 ? OptionalDouble.empty() : OptionalDouble.of(100.0 * errors / possible);
}
}

View File

@@ -0,0 +1,56 @@
package org.egothor.stemmer.benchmark.quality;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix.Candidate;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Integration checks binding report coverage to the authoritative JMH candidate registry. */
@Tag("integration")
@DisplayName("JMH stemming-quality candidate matrix")
final class QualityStemmerMatrixTest {
/** Temporary report location. */
@TempDir Path temporaryDirectory;
/** Verifies discovery includes the complete current benchmark enum rather than Radixor alone. */
@Test @DisplayName("Candidate discovery is derived from every JMH quality candidate")
void discoversEveryCandidate() {
final List<Candidate> candidates = QualityStemmerMatrix.candidates();
assertEquals(92, candidates.size(), "The current adapter-language matrix size changed; report coverage must be reviewed.");
assertTrue(candidates.stream().anyMatch(candidate -> !candidate.name().endsWith("_RADIXOR")));
assertTrue(candidates.stream().anyMatch(candidate -> candidate.name().equals("DA_DK_RADIXOR")));
assertTrue(candidates.stream().anyMatch(candidate -> candidate.name().equals("YI_RADIXOR")));
}
/** Verifies a complete report row exists for both modes of every discovered candidate. */
@Test @DisplayName("Report rendering includes both modes for every discovered candidate")
void reportContainsCompleteMatrix() throws Exception {
final List<QualityResult> rows = new ArrayList<>();
for (Candidate candidate : QualityStemmerMatrix.candidates()) {
for (ProcessingMode mode : ProcessingMode.values()) {
rows.add(new QualityResult(candidate.name(), candidate.language().name(), mode,
OutputPolicy.PRIMARY_OUTPUT, 1, 1, 1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 0,
new PartitionMetrics(1.0, 1.0, 1.0, 1.0, 1.0)));
}
}
final Path report = this.temporaryDirectory.resolve("matrix.csv");
QualityReportWriter.writeCsv(report, rows);
final String text = Files.readString(report, StandardCharsets.UTF_8);
assertEquals(185, text.lines().count());
for (Candidate candidate : QualityStemmerMatrix.candidates()) {
assertTrue(text.contains("\"" + candidate.name() + "\",\"" + candidate.language() + "\",\"ALL_WORDS\""));
assertTrue(text.contains("\"" + candidate.name() + "\",\"" + candidate.language() + "\",\"LOWERCASE_GROUPS_ONLY\""));
}
}
}

View File

@@ -0,0 +1,15 @@
package org.egothor.stemmer.benchmark.quality;
import java.io.IOException;
/** Contract used to apply one production stemmer during quality evaluation. */
@FunctionalInterface
public interface StemmerFunction {
/**
* Stems one word form without test-specific post-processing.
* @param word input form, never {@code null}
* @return output stem, never {@code null}
* @throws IOException when an adapted production stemmer fails
*/
String stem(String word) throws IOException;
}

View File

@@ -0,0 +1,245 @@
package org.egothor.stemmer.benchmark.quality;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.egothor.stemmer.StemmerPatchTrieLoader.Language;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix;
import org.egothor.stemmer.benchmark.QualityStemmerMatrix.Candidate;
/** Command-line entry point for JMH-backed pairwise stemming-quality reports. */
public final class StemmingQualityApplication {
private static final int ARGUMENT_COUNT = 9;
private static final Logger LOGGER = Logger.getLogger(StemmingQualityApplication.class.getName());
/** Utility class. */
private StemmingQualityApplication() {
throw new AssertionError("No instances.");
}
/**
* Generates a complete report or an explicitly labelled filtered report.
*
* @param arguments output directory, language filter, candidate filter, mode
* filter, output-policy filter, audit flag, and audit contributor limit
* @throws IOException if dictionary, JMH adapter, or report processing fails
*/
public static void main(final String[] arguments) throws IOException {
if (arguments.length != ARGUMENT_COUNT) {
throw new IllegalArgumentException("Expected output directory, resource directory, language filter, stemmer filter, dictionary-mode filter, output-policy filter, ranking metric, audit flag, and audit limit.");
}
final Path directory = Path.of(arguments[0]);
final LanguageUniverse universe = LanguageUniverse.discover(Path.of(arguments[1]));
final Set<Language> languages = parseLanguages(arguments[2]);
final Set<ProcessingMode> modes = parseModes(arguments[4]);
final Set<OutputPolicy> policies = parsePolicies(arguments[5]);
final String stemmerFilter = arguments[3].strip();
final String rankMetric = arguments[6].strip();
final boolean audit = Boolean.parseBoolean(arguments[7]);
final int auditLimit = parseAuditLimit(arguments[8]);
final boolean filtered = !arguments[2].isBlank() || !stemmerFilter.isBlank()
|| !arguments[4].isBlank() || !arguments[5].isBlank();
final List<Candidate> candidates = selectCandidates(languages, stemmerFilter);
if (candidates.isEmpty()) {
throw new IllegalArgumentException("The supplied filters select no JMH stemming-quality candidates.");
}
if (!filtered && !languages.equals(universe.dictionaries().keySet())) {
throw new IllegalStateException("The complete evaluation language selection differs from the reconciled dictionary universe.");
}
final Map<Candidate, Boolean> multiOutput = new HashMap<>();
final Set<ResultKey> expected = new HashSet<>();
for (Candidate candidate : candidates) {
final boolean multiple = candidate.createStemmer().supportsMultipleOutputs();
multiOutput.put(candidate, multiple);
for (ProcessingMode mode : modes) {
for (OutputPolicy policy : policies) {
if (policy == OutputPolicy.PRIMARY_OUTPUT || multiple) {
expected.add(new ResultKey(candidate.name(), candidate.language().name(), mode, policy));
}
}
}
}
LOGGER.log(Level.INFO, filtered ? "Starting a filtered stemming-quality report."
: "Starting the complete stemming-quality report.");
final Map<Language, List<GoldStandardGroup>> dictionaries = new EnumMap<>(Language.class);
final List<QualityResult> results = new ArrayList<>();
final List<QualityAudit.Scenario> audits = new ArrayList<>();
final List<CandidateQualityAudit.Scenario> candidateAudits = new ArrayList<>();
for (Candidate candidate : candidates) {
List<GoldStandardGroup> groups = dictionaries.get(candidate.language());
if (groups == null) {
groups = BundledGoldStandardLoader.load(candidate.language());
dictionaries.put(candidate.language(), groups);
}
for (ProcessingMode mode : modes) {
final QualityStemmerMatrix.BatchStemmer primaryStemmer = candidate.createStemmer();
final QualityResult primary;
if (audit && policies.contains(OutputPolicy.PRIMARY_OUTPUT)) {
final QualityAudit.Scenario scenario = QualityAudit.evaluate(candidate, mode, groups, auditLimit);
audits.add(scenario);
primary = scenario.result();
} else {
primary = QualityEvaluator.evaluateBatch(candidate.name(), candidate.language().name(),
mode, groups, primaryStemmer);
}
if (policies.contains(OutputPolicy.PRIMARY_OUTPUT)) {
results.add(primary);
logScenario(candidate, mode, OutputPolicy.PRIMARY_OUTPUT);
}
if (multiOutput.get(candidate)) {
final QualityResult anyCandidate = CandidateAwareEvaluator.evaluate(candidate.name(),
candidate.language().name(), mode, OutputPolicy.ANY_CANDIDATE, groups, candidate.createStemmer());
final QualityResult allCandidates;
if (audit) {
final CandidateQualityAudit.Scenario scenario = CandidateQualityAudit.evaluate(
candidate, mode, groups, primary, anyCandidate, auditLimit);
candidateAudits.add(scenario);
allCandidates = scenario.candidate();
} else {
allCandidates = CandidateAwareEvaluator.evaluate(candidate.name(), candidate.language().name(),
mode, OutputPolicy.ALL_CANDIDATES, groups, candidate.createStemmer());
}
verifyPolicyInvariants(primary, anyCandidate, allCandidates);
if (policies.contains(OutputPolicy.ANY_CANDIDATE)) {
results.add(anyCandidate); logScenario(candidate, mode, OutputPolicy.ANY_CANDIDATE);
}
if (policies.contains(OutputPolicy.ALL_CANDIDATES)) {
results.add(allCandidates); logScenario(candidate, mode, OutputPolicy.ALL_CANDIDATES);
}
}
}
}
validateMatrix(expected, results);
final String suffix = filtered ? "-filtered" : "";
final Path markdown = directory.resolve("stemming-quality" + suffix + ".md");
final Path csv = directory.resolve("stemming-quality" + suffix + ".csv");
QualityReportWriter.writeMarkdown(markdown, results, filtered, universe, candidates, expected.size(), rankMetric);
QualityReportWriter.writeCsv(csv, results);
final Path pearson = directory.resolve("metric-correlations-pearson" + suffix + ".csv");
final Path spearman = directory.resolve("metric-correlations-spearman" + suffix + ".csv");
MetricCorrelationWriter.write(pearson, spearman, results);
System.out.println("Stemming-quality Markdown report: " + markdown.toAbsolutePath());
System.out.println("Stemming-quality CSV report: " + csv.toAbsolutePath());
System.out.println("Pearson metric-correlation report: " + pearson.toAbsolutePath());
System.out.println("Spearman metric-correlation report: " + spearman.toAbsolutePath());
if (audit) {
final Path auditPath = directory.resolve("stemming-quality-audit" + suffix + ".md");
QualityAudit.write(auditPath, audits);
CandidateQualityAudit.append(auditPath, candidateAudits);
System.out.println("Stemming-quality audit report: " + auditPath.toAbsolutePath());
}
LOGGER.log(Level.INFO, "Completed the stemming-quality report with {0} evaluated scenarios.", results.size());
}
/** Selects candidates directly from the authoritative JMH matrix. */
private static List<Candidate> selectCandidates(final Set<Language> languages, final String filter) {
return QualityStemmerMatrix.candidates().stream()
.filter(candidate -> languages.contains(candidate.language()))
.filter(candidate -> filter.isBlank() || candidate.name().equalsIgnoreCase(filter)
|| candidate.name().toUpperCase(Locale.ROOT).endsWith("_" + filter.toUpperCase(Locale.ROOT)))
.toList();
}
/** Parses a comma-separated language filter or selects every language. */
private static Set<Language> parseLanguages(final String filter) {
if (filter.isBlank()) {
return EnumSet.allOf(Language.class);
}
final Set<Language> selected = EnumSet.noneOf(Language.class);
for (String item : filter.split(",")) {
selected.add(Language.valueOf(item.strip().toUpperCase(Locale.ROOT)));
}
return selected;
}
/** Parses a comma-separated mode filter or selects both processing modes. */
private static Set<ProcessingMode> parseModes(final String filter) {
if (filter.isBlank()) {
return EnumSet.allOf(ProcessingMode.class);
}
final Set<ProcessingMode> selected = EnumSet.noneOf(ProcessingMode.class);
for (String item : filter.split(",")) {
selected.add(ProcessingMode.valueOf(item.strip().toUpperCase(Locale.ROOT)));
}
return selected;
}
/** Parses a comma-separated output-policy filter or selects both policies. */
private static Set<OutputPolicy> parsePolicies(final String filter) {
if (filter.isBlank()) { return EnumSet.allOf(OutputPolicy.class); }
final Set<OutputPolicy> selected = EnumSet.noneOf(OutputPolicy.class);
for (String item : filter.split(",")) {
selected.add(OutputPolicy.valueOf(item.strip().toUpperCase(Locale.ROOT)));
}
return selected;
}
/** Enforces the mathematical monotonicity guaranteed by primary-output inclusion. */
private static void verifyPolicyInvariants(final QualityResult primary, final QualityResult any,
final QualityResult all) {
if (any.underErrorPairs() > primary.underErrorPairs() || all.underErrorPairs() > primary.underErrorPairs()
|| any.underErrorPairs() != all.underErrorPairs() || any.overErrorPairs() > primary.overErrorPairs()
|| all.overErrorPairs() < primary.overErrorPairs()) {
throw new IllegalStateException("Output-policy invariants failed for stemmer " + primary.stemmer()
+ ", language " + primary.language() + ", dictionary mode " + primary.processingMode()
+ ": PRIMARY_OUTPUT under/over=" + primary.underErrorPairs() + "/" + primary.overErrorPairs()
+ ", ANY_CANDIDATE under/over=" + any.underErrorPairs() + "/" + any.overErrorPairs()
+ ", ALL_CANDIDATES under/over=" + all.underErrorPairs() + "/" + all.overErrorPairs() + ".");
}
}
/** Validates exact expected and actual result keys, including duplicates. */
private static void validateMatrix(final Set<ResultKey> expected, final List<QualityResult> results) {
final Set<ResultKey> actual = new HashSet<>();
for (QualityResult result : results) {
final ResultKey key = ResultKey.from(result);
if (!actual.add(key)) { throw new IllegalStateException("Duplicate stemming-quality result key: " + key + "."); }
}
if (!expected.equals(actual)) {
final Set<ResultKey> missing = new HashSet<>(expected); missing.removeAll(actual);
final Set<ResultKey> unexpected = new HashSet<>(actual); unexpected.removeAll(expected);
throw new IllegalStateException("Stemming-quality result matrix mismatch. Missing rows: " + missing
+ "; unexpected rows: " + unexpected + ".");
}
}
/** Immutable expected-matrix key. */
private record ResultKey(String stemmer, String language, ProcessingMode mode, OutputPolicy policy) {
/** Creates a key from one immutable result. */
private static ResultKey from(final QualityResult result) {
return new ResultKey(result.stemmer(), result.language(), result.processingMode(), result.outputPolicy());
}
}
/** Parses and validates the deterministic audit contributor limit. */
private static int parseAuditLimit(final String value) {
final int limit = Integer.parseInt(value);
if (limit < 1) {
throw new IllegalArgumentException("The audit contributor limit must be positive.");
}
return limit;
}
/** Logs one completed scenario without per-word noise. */
private static void logScenario(final Candidate candidate, final ProcessingMode mode, final OutputPolicy policy) {
if (LOGGER.isLoggable(Level.INFO)) {
LOGGER.log(Level.INFO, "Completed stemming-quality evaluation for stemmer {0}, language {1}, dictionary mode {2}, and output policy {3}.",
new Object[] {candidate.name(), candidate.language(), mode, policy});
}
}
}

View File

@@ -0,0 +1,743 @@
package org.egothor.stemmer.benchmark.quality;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Publishes validated stemming-quality CSV results into marked sections of the
* existing language benchmark pages. This test-source utility never modifies
* performance benchmark content outside its markers.
*/
public final class StemmingQualityDocumentationPublisher {
private static final String START = "<!-- STEMMING-QUALITY:START -->";
private static final String END = "<!-- STEMMING-QUALITY:END -->";
private static final String OVERVIEW_START = "<!-- STEMMING-QUALITY-OVERVIEW:START -->";
private static final String OVERVIEW_END = "<!-- STEMMING-QUALITY-OVERVIEW:END -->";
private static final List<String> MODES = List.of("ALL_WORDS", "LOWERCASE_GROUPS_ONLY");
private static final Map<String, Integer> POLICY_ORDER = Map.of("PRIMARY_OUTPUT", 0, "ANY_CANDIDATE", 1, "ALL_CANDIDATES", 2);
private static final Pattern PAGE_ROW = Pattern.compile("^\\|[^|]+\\| `([^`]+)` \\| \\[([^]]+)]\\(([^)]+\\.md)\\) \\|$");
private static final Pattern BUILT_IN_LANGUAGE_ROW = Pattern.compile("^\\|[^|]+\\| `([^`]+)` \\|.*$");
/** Prevents construction of this command-line utility. */
private StemmingQualityDocumentationPublisher() { }
/**
* Updates or verifies the documentation from one complete source CSV.
*
* @param arguments source CSV, documentation root, and either {@code update} or {@code verify}
* @throws IOException when source or documentation access fails
*/
public static void main(final String[] arguments) throws IOException {
if (arguments.length != 3) {
throw new IllegalArgumentException("Expected arguments: source CSV, documentation root, and update or verify mode.");
}
final Path source = Path.of(arguments[0]);
final Path documentationRoot = Path.of(arguments[1]);
final boolean update = switch (arguments[2]) {
case "update" -> true;
case "verify" -> false;
default -> throw new IllegalArgumentException("Documentation mode must be update or verify.");
};
publish(source, documentationRoot, update);
}
/**
* Validates the complete result set and updates or verifies every mapped page.
*
* @param source authoritative complete CSV
* @param documentationRoot repository documentation directory
* @param update whether files may be replaced
* @throws IOException when files cannot be read or written
*/
static void publish(final Path source, final Path documentationRoot, final boolean update) throws IOException {
if (!Files.isRegularFile(source) || source.getFileName().toString().contains("filtered")) {
throw new IllegalArgumentException("The documentation source must be an existing complete, unfiltered CSV report: " + source);
}
final List<ResultRow> rows = readRows(source);
final Map<String, Page> pages = readPages(documentationRoot.resolve("benchmarks/languages/index.md"));
final Set<String> languageUniverse = readLanguageUniverse(documentationRoot.resolve("built-in-languages.md"));
validate(rows, pages.keySet(), languageUniverse);
final String checksum = sha256(source);
if (!update) {
final Path checksumFile = documentationRoot.resolve("benchmarks/data/stemming-quality.sha256");
final String recorded = Files.readString(checksumFile, StandardCharsets.UTF_8).strip();
if (!recorded.equals(checksum + " stemming-quality.csv")) {
throw new IllegalStateException("The published stemming-quality checksum does not match the authoritative CSV.");
}
}
for (Page page : pages.values()) {
final List<ResultRow> languageRows = rows.stream().filter(row -> row.language().equals(page.language())).toList();
final String section = render(page, languageRows, checksum);
final Path path = documentationRoot.resolve("benchmarks/languages").resolve(page.file());
final String original = Files.readString(path, StandardCharsets.UTF_8);
final String expected = replaceSection(original, section);
if (update) {
Files.writeString(path, expected, StandardCharsets.UTF_8);
} else if (!original.equals(expected)) {
throw new IllegalStateException("Stemming-quality documentation is stale or manually altered: " + path);
}
}
final Path overviewPath = documentationRoot.resolve("benchmarks/index.md");
final String overview = Files.readString(overviewPath, StandardCharsets.UTF_8);
final String expectedOverview = replaceMarkedSection(overview, renderOverview(pages, rows, checksum),
OVERVIEW_START, OVERVIEW_END);
if (update) {
Files.writeString(overviewPath, expectedOverview, StandardCharsets.UTF_8);
} else if (!overview.equals(expectedOverview)) {
throw new IllegalStateException("The generated benchmark quality overview is stale or manually altered: " + overviewPath);
}
if (update) {
final Path publishedSource = documentationRoot.resolve("benchmarks/data/stemming-quality.csv");
Files.createDirectories(publishedSource.getParent());
Files.copy(source, publishedSource, StandardCopyOption.REPLACE_EXISTING);
Files.writeString(documentationRoot.resolve("benchmarks/data/stemming-quality.sha256"), checksum + " stemming-quality.csv\n", StandardCharsets.UTF_8);
}
System.out.printf(Locale.ROOT, "%s stemming-quality documentation for %d languages from %d validated rows.%n",
update ? "Updated" : "Verified", pages.size(), rows.stream().filter(row -> pages.containsKey(row.language())).count());
}
/** Reads the authoritative built-in language identifiers from the existing registry table. */
private static Set<String> readLanguageUniverse(final Path builtInLanguages) throws IOException {
final Set<String> languages = new HashSet<>();
for (String line : Files.readAllLines(builtInLanguages, StandardCharsets.UTF_8)) {
final Matcher matcher = BUILT_IN_LANGUAGE_ROW.matcher(line);
if (matcher.matches()) {
languages.add(matcher.group(1));
}
}
if (languages.isEmpty()) {
throw new IllegalStateException("No authoritative built-in languages were discovered in " + builtInLanguages);
}
return Set.copyOf(languages);
}
/** Reads the language-code-to-page mapping from the existing documentation index. */
private static Map<String, Page> readPages(final Path index) throws IOException {
final Map<String, Page> pages = new LinkedHashMap<>();
for (String line : Files.readAllLines(index, StandardCharsets.UTF_8)) {
final Matcher matcher = PAGE_ROW.matcher(line);
if (matcher.matches()) {
final Page previous = pages.put(matcher.group(1), new Page(matcher.group(1), matcher.group(2), matcher.group(3)));
if (previous != null) {
throw new IllegalStateException("Duplicate language mapping in benchmark index: " + matcher.group(1));
}
}
}
if (pages.isEmpty()) {
throw new IllegalStateException("No language benchmark pages were discovered in " + index);
}
return pages;
}
/** Reads and schema-validates the quoted UTF-8 CSV. */
private static List<ResultRow> readRows(final Path source) throws IOException {
final List<String> lines = Files.readAllLines(source, StandardCharsets.UTF_8);
if (lines.isEmpty()) {
throw new IllegalStateException("The stemming-quality CSV is empty.");
}
final List<String> header = parseCsv(lines.getFirst());
final List<String> required = List.of("Stemmer", "Language", "Dictionary mode", "Output policy", "Applied dictionary rows",
"Processed word forms", "Forms with multiple candidates", "Maximum candidates for one form", "Total candidate assignments",
"True-positive pairs", "False-positive pairs", "False-negative pairs", "True-negative pairs",
"Over-stemming error pairs", "Over-stemming possible pairs", "Over-stemming percentage", "Under-stemming error pairs",
"Under-stemming possible pairs", "Under-stemming percentage", "Pairwise precision", "Pairwise recall", "Pairwise specificity",
"Pairwise accuracy", "Balanced accuracy", "Pairwise F0.5", "Pairwise F1", "Pairwise F2", "Jaccard index",
"Fowlkes-Mallows index", "Matthews correlation coefficient", "Pairwise error rate", "Adjusted Rand Index", "Homogeneity",
"Completeness", "V-measure", "Normalized mutual information");
if (!header.containsAll(required)) {
throw new IllegalStateException("The stemming-quality CSV does not contain the required publication schema.");
}
final Map<String, Integer> indexes = new HashMap<>();
for (int index = 0; index < header.size(); index++) {
indexes.put(header.get(index), index);
}
final List<ResultRow> rows = new ArrayList<>();
for (int line = 1; line < lines.size(); line++) {
final List<String> values = parseCsv(lines.get(line));
if (values.size() != header.size()) {
throw new IllegalStateException("CSV column count differs from the header at logical row " + (line + 1));
}
rows.add(new ResultRow(values, indexes));
}
return List.copyOf(rows);
}
/** Parses one RFC-4180-compatible line emitted by the quality report writer. */
private static List<String> parseCsv(final String line) {
final List<String> values = new ArrayList<>();
final StringBuilder value = new StringBuilder();
boolean quoted = false;
for (int index = 0; index < line.length(); index++) {
final char character = line.charAt(index);
if (character == '"') {
if (quoted && index + 1 < line.length() && line.charAt(index + 1) == '"') {
value.append('"');
index++;
} else {
quoted = !quoted;
}
} else if (character == ',' && !quoted) {
values.add(value.toString());
value.setLength(0);
} else {
value.append(character);
}
}
if (quoted) {
throw new IllegalStateException("Unterminated quoted CSV value.");
}
values.add(value.toString());
return values;
}
/** Validates uniqueness, coverage, raw arithmetic, metrics, and policy invariants. */
private static void validate(final List<ResultRow> rows, final Set<String> documentedLanguages,
final Set<String> languageUniverse) {
final Set<String> keys = new HashSet<>();
for (ResultRow row : rows) {
if (!keys.add(row.key())) {
throw new IllegalStateException("Duplicate stemming-quality result key: " + row.key());
}
row.validate();
}
final Set<String> resultLanguages = new HashSet<>();
rows.forEach(row -> resultLanguages.add(row.language()));
if (!resultLanguages.equals(languageUniverse)) {
throw new IllegalStateException("Complete-report language coverage differs from the authoritative built-in universe. Results: "
+ resultLanguages + "; authoritative languages: " + languageUniverse);
}
for (String language : languageUniverse) {
for (String mode : MODES) {
for (String policy : POLICY_ORDER.keySet()) {
final boolean present = rows.stream().anyMatch(row -> row.language().equals(language) && row.mode().equals(mode)
&& row.policy().equals(policy) && row.stemmer().endsWith("_RADIXOR"));
if (!present) {
throw new IllegalStateException("The complete report omits Radixor result " + language + "/" + mode + "/" + policy);
}
}
}
}
for (String language : documentedLanguages) {
final List<ResultRow> languageRows = rows.stream().filter(row -> row.language().equals(language)).toList();
if (languageRows.isEmpty()) {
throw new IllegalStateException("No stemming-quality results exist for documented language " + language);
}
for (String mode : MODES) {
if (languageRows.stream().noneMatch(row -> row.mode().equals(mode))) {
throw new IllegalStateException("Missing dictionary mode " + mode + " for documented language " + language);
}
}
validatePolicies(languageRows);
}
if (!documentedLanguages.contains("DA_DK") || !documentedLanguages.contains("YI")) {
throw new IllegalStateException("The documentation mapping must contain DA_DK and YI.");
}
}
/** Validates policy monotonicity for each multi-output scenario. */
private static void validatePolicies(final List<ResultRow> rows) {
final Map<String, Map<String, ResultRow>> scenarios = new HashMap<>();
for (ResultRow row : rows) {
scenarios.computeIfAbsent(row.stemmer() + "\u0000" + row.mode(), ignored -> new HashMap<>()).put(row.policy(), row);
}
for (Map<String, ResultRow> policies : scenarios.values()) {
final ResultRow primary = policies.get("PRIMARY_OUTPUT");
if (primary == null) {
throw new IllegalStateException("Every documented stemmer scenario must contain PRIMARY_OUTPUT.");
}
if (policies.containsKey("ANY_CANDIDATE") || policies.containsKey("ALL_CANDIDATES")) {
final ResultRow any = policies.get("ANY_CANDIDATE");
final ResultRow all = policies.get("ALL_CANDIDATES");
if (any == null || all == null || any.fn() > primary.fn() || all.fn() != any.fn()
|| any.fp() > primary.fp() || all.fp() < primary.fp()) {
throw new IllegalStateException("Output-policy invariants fail for " + primary.key());
}
}
}
}
/** Renders one complete generated section for a language page. */
private static String render(final Page page, final List<ResultRow> rows, final String checksum) {
final StringBuilder output = new StringBuilder(32768);
output.append(START).append("\n\n## Stemming Quality\n\n")
.append("Runtime performance and linguistic grouping quality are independent dimensions. This section evaluates language `")
.append(page.language()).append("` using the complete validated stemming-quality result matrix. Every usable dictionary row is one gold-standard group of forms expected to share a morphological family or lemma. Exact equality with a predetermined lemma is not required. Same-row pairs are positive pairs; pairs from different rows are negative pairs.\n\n")
.append("`ALL_WORDS` includes every valid group and its original forms. `LOWERCASE_GROUPS_ONLY` excludes an entire group when any Unicode code point is uppercase or titlecase; retained words are not lowercased or otherwise rewritten. This isolates case-handling effects without changing retained inputs. [Download the complete machine-readable result snapshot](../data/stemming-quality.csv).\n\n")
.append("### Evaluation Scope and Key Findings\n\n")
.append("The dictionary resource is `src/main/resources/").append(page.language().toLowerCase(Locale.ROOT)).append("/stemmer.gz`. The following findings compare only deterministic `PRIMARY_OUTPUT` rows over identical included groups; candidate policies are reported separately as capability analyses.\n\n");
for (String mode : MODES) {
appendFinding(output, rows, mode);
}
for (String mode : MODES) {
final List<ResultRow> selected = rows.stream().filter(row -> row.mode().equals(mode)).sorted(resultOrder()).toList();
final long stemmers = selected.stream().map(ResultRow::stemmer).distinct().count();
final long policies = selected.stream().map(ResultRow::policy).distinct().count();
output.append("### `").append(mode).append("`\n\n")
.append("This mode contains **").append(selected.size()).append(" result rows**, **").append(stemmers)
.append(" evaluated stemmers**, and **").append(policies).append(" output policies**. Applied-row and form counts are shown per row because adapters share the language corpus but policy rows remain independently auditable. Rankings are separated by output policy and ordered by unrounded balanced accuracy, followed by MCC, F1, over-stemming rate, over-stemming count, under-stemming rate, and stemmer. Balanced accuracy is a navigation metric, not a universally authoritative quality score.\n\n");
for (String policy : List.of("PRIMARY_OUTPUT", "ANY_CANDIDATE", "ALL_CANDIDATES")) {
final List<ResultRow> policyRows = selected.stream().filter(row -> row.policy().equals(policy)).toList();
if (!policyRows.isEmpty()) {
output.append("#### `").append(policy).append("` ranking\n\n");
renderPrimaryTable(output, policyRows);
renderDetailedTables(output, policyRows);
}
}
renderCandidateAnalysis(output, selected);
}
appendMethodology(output);
output.append("### Provenance\n\n")
.append("- Authoritative source: `docs/benchmarks/data/stemming-quality.csv`\n")
.append("- Source SHA-256: `").append(checksum).append("`\n")
.append("- Evaluation command: `./gradlew stemmingQuality`\n")
.append("- Dictionary language: `").append(page.language()).append("`\n")
.append("- Processing modes: `ALL_WORDS`, `LOWERCASE_GROUPS_ONLY`\n")
.append("- Stemmer versions and transitive artifacts: resolved by the repository's JMH Gradle configuration and `gradle.lockfile`\n")
.append("- Radixor version, Git revision, generation date, JDK version, operating system, and dictionary revision: not recorded in the authoritative CSV\n\n")
.append(END).append('\n');
return output.toString();
}
/** Appends one deterministic primary-output winner and runner-up statement. */
private static void appendFinding(final StringBuilder output, final List<ResultRow> rows, final String mode) {
final List<ResultRow> primary = rows.stream().filter(row -> row.mode().equals(mode) && row.policy().equals("PRIMARY_OUTPUT"))
.sorted(resultOrder()).toList();
final ResultRow winner = primary.getFirst();
final ResultRow runnerUp = primary.size() > 1 ? primary.get(1) : null;
output.append("- **").append(mode).append(":** `").append(displayStemmer(winner.stemmer())).append("` ranks first by balanced accuracy at **")
.append(metric(winner, "Balanced accuracy")).append("** among ").append(primary.size()).append(" deterministic stemmers");
if (runnerUp == null) {
output.append("; no same-language competitor was available");
} else {
final double difference = winner.number("Balanced accuracy") - runnerUp.number("Balanced accuracy");
output.append(". The runner-up is `").append(displayStemmer(runnerUp.stemmer())).append("` at ")
.append(metric(runnerUp, "Balanced accuracy")).append(", a difference of ")
.append(String.format(Locale.ROOT, "%.6f", difference));
if (difference == 0.0) {
output.append(" (an exact tie before formatting)");
}
}
output.append(". This rank does not imply leadership in throughput or every secondary metric.\n");
}
/** Renders the compact primary ranking table in an accessible scroll region. */
private static void renderPrimaryTable(final StringBuilder output, final List<ResultRow> rows) {
output.append("<div class=\"quality-table quality-table--compact\" role=\"region\" aria-label=\"Compact stemming-quality ranking; scroll horizontally for additional columns\" tabindex=\"0\" markdown=\"1\">\n\n")
.append("| Rank | Stemmer | Output policy | Balanced accuracy | Over-stemming | Under-stemming | F0.5 | F1 | MCC |\n")
.append("|---:|---|---|---:|---:|---:|---:|---:|---:|\n");
for (int index = 0; index < rows.size(); index++) {
final ResultRow row = rows.get(index);
output.append('|').append(index + 1).append('|').append(displayStemmer(row.stemmer())).append('|').append(row.policy()).append('|')
.append(metric(row, "Balanced accuracy")).append('|')
.append(pair(row, "Over-stemming error pairs", "Over-stemming possible pairs", "Over-stemming percentage")).append('|')
.append(pair(row, "Under-stemming error pairs", "Under-stemming possible pairs", "Under-stemming percentage")).append('|')
.append(metric(row, "Pairwise F0.5")).append('|').append(metric(row, "Pairwise F1")).append('|')
.append(metric(row, "Matthews correlation coefficient")).append("|\n");
}
output.append("\n</div>\n\n");
}
/** Renders classification, relation, partition, and raw-count tables with repeated identities. */
private static void renderDetailedTables(final StringBuilder output, final List<ResultRow> rows) {
output.append("<details class=\"quality-details\" markdown=\"1\"><summary>Classification metrics</summary>\n\n")
.append("| Rank | Stemmer | Output policy | Precision | Recall | Specificity | Balanced accuracy | Pairwise accuracy | Error rate |\n")
.append("|---:|---|---|---:|---:|---:|---:|---:|---:|\n");
for (int index = 0; index < rows.size(); index++) {
final ResultRow row = rows.get(index);
output.append(identity(index, row)).append(metric(row, "Pairwise precision")).append('|').append(metric(row, "Pairwise recall")).append('|')
.append(metric(row, "Pairwise specificity")).append('|').append(metric(row, "Balanced accuracy")).append('|')
.append(metric(row, "Pairwise accuracy")).append('|').append(metric(row, "Pairwise error rate")).append("|\n");
}
output.append("\n</details>\n\n<details class=\"quality-details\" markdown=\"1\"><summary>Pair-relation metrics</summary>\n\n")
.append("| Rank | Stemmer | Output policy | F0.5 | F1 | F2 | Jaccard | FowlkesMallows | MCC |\n")
.append("|---:|---|---|---:|---:|---:|---:|---:|---:|\n");
for (int index = 0; index < rows.size(); index++) {
final ResultRow row = rows.get(index);
output.append(identity(index, row)).append(metric(row, "Pairwise F0.5")).append('|').append(metric(row, "Pairwise F1")).append('|')
.append(metric(row, "Pairwise F2")).append('|').append(metric(row, "Jaccard index")).append('|')
.append(metric(row, "Fowlkes-Mallows index")).append('|').append(metric(row, "Matthews correlation coefficient")).append("|\n");
}
output.append("\n</details>\n\n<details class=\"quality-details\" markdown=\"1\"><summary>Partition metrics (PRIMARY_OUTPUT only)</summary>\n\n")
.append("| Rank | Stemmer | Output policy | Adjusted Rand Index | Homogeneity | Completeness | V-measure | Normalized mutual information |\n")
.append("|---:|---|---|---:|---:|---:|---:|---:|\n");
for (int index = 0; index < rows.size(); index++) {
final ResultRow row = rows.get(index);
output.append(identity(index, row)).append(metric(row, "Adjusted Rand Index")).append('|').append(metric(row, "Homogeneity")).append('|')
.append(metric(row, "Completeness")).append('|').append(metric(row, "V-measure")).append('|')
.append(metric(row, "Normalized mutual information")).append("|\n");
}
output.append("\n</details>\n\n<details class=\"quality-details\" markdown=\"1\"><summary>Raw pair counts</summary>\n\n")
.append("| Rank | Stemmer | Output policy | TP | FP | FN | TN | Over error / possible | Under error / possible |\n")
.append("|---:|---|---|---:|---:|---:|---:|---:|---:|\n");
for (int index = 0; index < rows.size(); index++) {
final ResultRow row = rows.get(index);
output.append(identity(index, row)).append(row.value("True-positive pairs")).append('|').append(row.value("False-positive pairs"))
.append('|').append(row.value("False-negative pairs")).append('|').append(row.value("True-negative pairs")).append('|')
.append(row.value("Over-stemming error pairs")).append(" / ").append(row.value("Over-stemming possible pairs")).append('|')
.append(row.value("Under-stemming error pairs")).append(" / ").append(row.value("Under-stemming possible pairs")).append("|\n");
}
output.append("\n</details>\n\n");
}
/** Renders the candidate-policy trade-off for every genuinely multi-output adapter. */
private static void renderCandidateAnalysis(final StringBuilder output, final List<ResultRow> rows) {
final Map<String, Map<String, ResultRow>> byStemmer = new LinkedHashMap<>();
rows.forEach(row -> byStemmer.computeIfAbsent(row.stemmer(), ignored -> new HashMap<>()).put(row.policy(), row));
final List<Map.Entry<String, Map<String, ResultRow>>> multi = byStemmer.entrySet().stream()
.filter(entry -> entry.getValue().containsKey("ANY_CANDIDATE")).sorted(Map.Entry.comparingByKey()).toList();
if (multi.isEmpty()) {
return;
}
output.append("#### Multi-output analysis\n\nAlternative candidates are capability analyses, not replacements for the deterministic comparison.\n\n")
.append("| Stemmer | Under pairs repaired | Best-case over pairs avoided | All-candidate collisions added | Multi-candidate forms | Multi-candidate share | Maximum candidates | Total candidate assignments |\n")
.append("|---|---:|---:|---:|---:|---:|---:|---:|\n");
for (Map.Entry<String, Map<String, ResultRow>> entry : multi) {
final ResultRow primary = entry.getValue().get("PRIMARY_OUTPUT");
final ResultRow any = entry.getValue().get("ANY_CANDIDATE");
final ResultRow all = entry.getValue().get("ALL_CANDIDATES");
final long forms = any.longValue("Processed word forms");
final long multiple = any.longValue("Forms with multiple candidates");
output.append('|').append(displayStemmer(entry.getKey())).append('|').append(primary.fn() - any.fn()).append('|')
.append(primary.fp() - any.fp()).append('|').append(all.fp() - primary.fp()).append('|').append(multiple).append('|')
.append(String.format(Locale.ROOT, "%.6f%%", 100.0 * multiple / forms)).append('|')
.append(any.value("Maximum candidates for one form")).append('|').append(any.value("Total candidate assignments")).append("|\n");
}
output.append('\n');
}
/** Returns the repeated rank, stemmer, and policy prefix for a detailed table row. */
private static String identity(final int index, final ResultRow row) {
return "|" + (index + 1) + "|" + displayStemmer(row.stemmer()) + "|" + row.policy() + "|";
}
/** Converts authoritative adapter identifiers into a stable readable label without merging competitors. */
private static String displayStemmer(final String identifier) {
return identifier.endsWith("_RADIXOR") ? "Radixor" : identifier.replace('_', ' ');
}
/** Appends the self-contained policy, confusion-matrix, and metric definitions. */
private static void appendMethodology(final StringBuilder output) {
output.append("### Output Policies and Metric Definitions\n\n")
.append("`PRIMARY_OUTPUT` uses one deterministic stem per form and therefore defines a strict partition. `ANY_CANDIDATE` is an optimistic oracle-assisted pairwise upper bound: a same-group pair succeeds when candidates intersect, while a different-group pair succeeds when a non-colliding selection exists. Candidate choices may differ between pairs, so this is not deterministic runtime behaviour and need not represent one globally consistent assignment. `ALL_CANDIDATES` activates every returned candidate; forms are related when candidate sets intersect. Alternatives can reduce under-stemming but can introduce cross-group collisions, and the resulting relation can overlap and need not be a partition.\n\n")
.append("For each row, `TP = underPossiblePairs - underErrorPairs`, `FN = underErrorPairs`, `FP = overErrorPairs`, and `TN = overPossiblePairs - overErrorPairs`. TP and FN concern same-group pairs; FP and TN concern different-group pairs. Consequently, under-stemming and over-stemming use different denominators. Undefined values are rendered as `n/a`.\n\n")
.append("- Under-stemming rate: `FN / (TP + FN)`, the false-negative rate over same-group pairs.\n")
.append("- Over-stemming rate: `FP / (TN + FP)`, the false-positive rate over different-group pairs.\n")
.append("- Pairwise precision: `TP / (TP + FP)`, the fraction of predicted conflations that are gold-standard positive pairs.\n")
.append("- Pairwise recall: `TP / (TP + FN)`, the fraction of gold-standard positive pairs successfully connected.\n")
.append("- Pairwise specificity: `TN / (TN + FP)`, the fraction of different-group pairs correctly separated.\n")
.append("- Balanced accuracy: `(recall + specificity) / 2`. It gives equal weight to positive and negative pair classes and is less dominated by the large true-negative class than ordinary accuracy. It does not replace the raw errors or other metrics.\n")
.append("- Pairwise F-beta: `((1 + betaSquared) * TP) / (((1 + betaSquared) * TP) + (betaSquared * FN) + FP)`. F0.5 emphasizes precision and penalizes over-stemming more; F1 weights precision and recall equally; F2 emphasizes recall and penalizes under-stemming more.\n")
.append("- MCC: `(TP * TN - FP * FN) / sqrt((TP + FP) * (TP + FN) * (TN + FP) * (TN + FN))`. It uses all confusion counts and remains useful under class imbalance, except when its denominator is degenerate.\n")
.append("- Jaccard index: `TP / (TP + FP + FN)`.\n")
.append("- FowlkesMallows index: `sqrt(precision * recall)`.\n")
.append("- Pairwise accuracy: `(TP + TN) / (TP + TN + FP + FN)`. It can be dominated by true-negative cross-group pairs.\n")
.append("- Pairwise error rate: `(FP + FN) / (TP + TN + FP + FN)`.\n\n")
.append("Adjusted Rand Index uses the gold/predicted contingency table and chance correction. Homogeneity is `1 - H(gold | predicted) / H(gold)`; completeness is `1 - H(predicted | gold) / H(predicted)`; V-measure is their harmonic mean; normalized mutual information uses the arithmetic-mean entropy normalization `MI / ((H(gold) + H(predicted)) / 2)`. These partition-only metrics apply to `PRIMARY_OUTPUT`; candidate-relation rows show `n/a`.\n\n");
}
/** Renders the generated executive findings, winner matrix, and Radixor aggregates. */
private static String renderOverview(final Map<String, Page> pages, final List<ResultRow> rows, final String checksum) {
final StringBuilder output = new StringBuilder(16384);
output.append(OVERVIEW_START).append("\n\n## Pairwise Quality Findings\n\n")
.append("The validated snapshot is a broad multilingual comparison covering the complete 20-language Radixor dictionary universe; 19 languages have existing benchmark pages. The direct ranking below uses only deterministic `PRIMARY_OUTPUT` rows over identical per-language inputs. Candidate-aware rows are intentionally excluded from this claim.\n\n");
int radixorWins = 0;
int comparisons = 0;
for (String mode : MODES) {
for (String language : pages.keySet()) {
final List<ResultRow> ranked = primaryRows(rows, language, mode);
comparisons++;
if (ranked.getFirst().stemmer().endsWith("_RADIXOR")) {
radixorWins++;
}
}
}
if (radixorWins == comparisons) {
output.append("!!! success \"Evidence-based primary-output result\"\n Radixor achieved the highest balanced accuracy among the evaluated deterministic stemmers for every documented language in both `ALL_WORDS` and `LOWERCASE_GROUPS_ONLY`: **")
.append(radixorWins).append(" wins in ").append(comparisons).append(" language-mode comparisons, with no exact first-place ties**. This statement is limited to the evaluated implementations, versions, dictionaries, adapters, and balanced-accuracy metric; it is not a universal claim about every stemming use case.\n\n");
} else {
output.append("Radixor ranks first in **").append(radixorWins).append(" of ").append(comparisons)
.append("** documented primary-output language-mode comparisons.\n\n");
}
output.append("### Per-language winner matrix\n\n| Language | Dictionary mode | Winner | Balanced accuracy | Runner-up | Difference | Exact tie | Deterministic stemmers |\n")
.append("|---|---|---|---:|---|---:|---|---:|\n");
for (Page page : pages.values()) {
for (String mode : MODES) {
final List<ResultRow> ranked = primaryRows(rows, page.language(), mode);
final ResultRow winner = ranked.getFirst();
final ResultRow runner = ranked.size() > 1 ? ranked.get(1) : null;
final double difference = runner == null ? Double.NaN : winner.number("Balanced accuracy") - runner.number("Balanced accuracy");
output.append('|').append(page.displayName()).append(" (`").append(page.language()).append("`)|").append(mode).append('|')
.append(displayStemmer(winner.stemmer())).append('|').append(metric(winner, "Balanced accuracy")).append('|')
.append(runner == null ? "n/a" : displayStemmer(runner.stemmer())).append('|')
.append(runner == null ? "n/a" : String.format(Locale.ROOT, "%.9f", difference)).append('|')
.append(runner != null && difference == 0.0 ? "yes" : "no").append('|').append(ranked.size()).append("|\n");
}
}
renderSecondaryLeaders(output, pages, rows);
output.append("\n### Win, tie, and placement summary\n\nCounts use `PRIMARY_OUTPUT` only and retain each adapter configuration as a separate stemmer except that language-specific Radixor identifiers are combined as Radixor. Coverage is displayed explicitly; unsupported languages are absent, not losses.\n\n");
for (String mode : MODES) {
renderPlacementSummary(output, pages, rows, mode);
}
output.append("\n### Radixor full-coverage aggregates\n\nThese aggregates cover all 19 documented languages. Macro balanced accuracy gives each language equal weight. Micro metrics first sum raw pair counts across languages. Unsupported third-party languages are never inserted as zero results, so this full-coverage table is not presented as a cross-stemmer common-language ranking.\n\n")
.append("| Dictionary mode | Languages | Macro balanced accuracy | Micro balanced accuracy | Micro precision | Micro recall | Micro F1 |\n")
.append("|---|---:|---:|---:|---:|---:|---:|\n");
for (String mode : MODES) {
final List<ResultRow> radixor = rows.stream().filter(row -> pages.containsKey(row.language()) && row.mode().equals(mode)
&& row.policy().equals("PRIMARY_OUTPUT") && row.stemmer().endsWith("_RADIXOR")).toList();
final double macroBalanced = radixor.stream().mapToDouble(row -> row.number("Balanced accuracy")).average().orElseThrow();
long tp = 0;
long fp = 0;
long fn = 0;
long tn = 0;
for (ResultRow row : radixor) {
tp = Math.addExact(tp, row.longValue("True-positive pairs"));
fp = Math.addExact(fp, row.fp());
fn = Math.addExact(fn, row.fn());
tn = Math.addExact(tn, row.longValue("True-negative pairs"));
}
final double precision = (double) tp / Math.addExact(tp, fp);
final double recall = (double) tp / Math.addExact(tp, fn);
final double specificity = (double) tn / Math.addExact(tn, fp);
final double f1 = 2.0 * tp / (2.0 * tp + fp + fn);
output.append('|').append(mode).append('|').append(radixor.size()).append('|').append(format(macroBalanced)).append('|')
.append(format((recall + specificity) / 2.0)).append('|').append(format(precision)).append('|')
.append(format(recall)).append('|').append(format(f1)).append("|\n");
}
output.append("\n### Reproducible data\n\n- [Machine-readable quality snapshot](data/stemming-quality.csv)\n")
.append("- SHA-256: `").append(checksum).append("`\n")
.append("- [Linguistic quality methodology](reference/linguistic-quality.md)\n")
.append("- [Tested stemmer inventory](reference/tested-stemmers.md)\n")
.append("- [Reproducibility and raw data](reference/reproducibility.md)\n")
.append("- Pearson and Spearman correlation files are generated under `build/reports/stemming-quality/`; they are separated by dictionary mode and output policy. Correlation does not establish metric equivalence.\n\n")
.append(OVERVIEW_END).append('\n');
return output.toString();
}
/** Publishes every deterministic secondary-metric case led by a non-Radixor adapter. */
private static void renderSecondaryLeaders(final StringBuilder output, final Map<String, Page> pages,
final List<ResultRow> rows) {
final Map<String, Boolean> metrics = new LinkedHashMap<>();
metrics.put("Pairwise precision", true);
metrics.put("Pairwise recall", true);
metrics.put("Pairwise F0.5", true);
metrics.put("Pairwise F1", true);
metrics.put("Pairwise F2", true);
metrics.put("Matthews correlation coefficient", true);
metrics.put("Over-stemming percentage", false);
metrics.put("Under-stemming percentage", false);
final StringBuilder cases = new StringBuilder();
int count = 0;
for (Page page : pages.values()) {
for (String mode : MODES) {
final List<ResultRow> primary = primaryRows(rows, page.language(), mode);
for (Map.Entry<String, Boolean> metric : metrics.entrySet()) {
final Comparator<ResultRow> comparator = Comparator.comparingDouble(row -> row.number(metric.getKey()));
final ResultRow leader = metric.getValue() ? primary.stream().max(comparator).orElseThrow()
: primary.stream().min(comparator).orElseThrow();
if (!leader.stemmer().endsWith("_RADIXOR")) {
count++;
cases.append('|').append(page.displayName()).append('|').append(mode).append('|').append(metric.getKey()).append('|')
.append(displayStemmer(leader.stemmer())).append('|').append(metric(leader, metric.getKey())).append("|\n");
}
}
}
}
output.append("\n### Secondary-metric trade-offs\n\nBalanced-accuracy leadership does not imply leadership on every error trade-off. The table below lists all **")
.append(count).append("** deterministic primary-output language-mode-metric cases where a non-Radixor adapter has the best displayed value. Equal values are resolved by the authoritative row ordering and should be read as ties when the unrounded values are equal. Throughput leadership remains in the separate performance tables.\n\n")
.append("<details class=\"quality-details\" markdown=\"1\"><summary>Non-Radixor secondary-metric leaders</summary>\n\n")
.append("| Language | Dictionary mode | Metric | Leader | Value |\n|---|---|---|---|---:|\n")
.append(cases).append("\n</details>\n");
}
/** Renders coverage-aware placement statistics for one dictionary mode. */
private static void renderPlacementSummary(final StringBuilder output, final Map<String, Page> pages,
final List<ResultRow> rows, final String mode) {
final Map<String, List<Integer>> ranks = new HashMap<>();
final Map<String, Integer> wins = new HashMap<>();
final Map<String, Integer> ties = new HashMap<>();
final Map<String, Integer> topThree = new HashMap<>();
for (String language : pages.keySet()) {
final List<ResultRow> ranked = primaryRows(rows, language, mode);
final double leading = ranked.getFirst().number("Balanced accuracy");
final long leaders = ranked.stream().filter(row -> row.number("Balanced accuracy") == leading).count();
for (int index = 0; index < ranked.size(); index++) {
final ResultRow row = ranked.get(index);
final String name = displayStemmer(row.stemmer());
ranks.computeIfAbsent(name, ignored -> new ArrayList<>()).add(index + 1);
if (row.number("Balanced accuracy") == leading) {
wins.merge(name, 1, Integer::sum);
if (leaders > 1) {
ties.merge(name, 1, Integer::sum);
}
}
if (index < 3) {
topThree.merge(name, 1, Integer::sum);
}
}
}
output.append("<details class=\"quality-details\" markdown=\"1\"><summary>").append(mode).append(" placements</summary>\n\n")
.append("| Stemmer | Evaluated languages | Wins | Exact first-place ties | Top-three placements | Average rank | Median rank |\n")
.append("|---|---:|---:|---:|---:|---:|---:|\n");
final List<String> names = ranks.keySet().stream().sorted(Comparator
.comparingInt((String name) -> wins.getOrDefault(name, 0)).reversed()
.thenComparing(Comparator.comparingInt((String name) -> ranks.get(name).size()).reversed())
.thenComparing(name -> name)).toList();
for (String name : names) {
final List<Integer> placements = ranks.get(name).stream().sorted().toList();
final double average = placements.stream().mapToInt(Integer::intValue).average().orElseThrow();
final int middle = placements.size() / 2;
final double median = placements.size() % 2 == 0
? (placements.get(middle - 1) + placements.get(middle)) / 2.0 : placements.get(middle);
output.append('|').append(name).append('|').append(placements.size()).append('|').append(wins.getOrDefault(name, 0)).append('|')
.append(ties.getOrDefault(name, 0)).append('|').append(topThree.getOrDefault(name, 0)).append('|')
.append(String.format(Locale.ROOT, "%.3f", average)).append('|').append(String.format(Locale.ROOT, "%.3f", median)).append("|\n");
}
output.append("\n</details>\n\n");
}
/** Returns deterministically ranked primary-output rows for one language and mode. */
private static List<ResultRow> primaryRows(final List<ResultRow> rows, final String language, final String mode) {
return rows.stream().filter(row -> row.language().equals(language) && row.mode().equals(mode)
&& row.policy().equals("PRIMARY_OUTPUT")).sorted(resultOrder()).toList();
}
/** Formats an aggregate metric at the publication precision. */
private static String format(final double value) {
return String.format(Locale.ROOT, "%.6f", value);
}
/** Returns the deterministic publication order based on unrounded source values. */
private static Comparator<ResultRow> resultOrder() {
return Comparator.comparingDouble((ResultRow row) -> row.number("Balanced accuracy")).reversed()
.thenComparing(Comparator.comparingDouble((ResultRow row) -> row.number("Matthews correlation coefficient")).reversed())
.thenComparing(Comparator.comparingDouble((ResultRow row) -> row.number("Pairwise F1")).reversed())
.thenComparingDouble(row -> row.number("Over-stemming percentage"))
.thenComparingLong(row -> row.longValue("Over-stemming error pairs"))
.thenComparingDouble(row -> row.number("Under-stemming percentage"))
.thenComparing(ResultRow::stemmer).thenComparingInt(row -> POLICY_ORDER.get(row.policy()));
}
/** Formats a score to the publication-wide six-decimal precision. */
private static String metric(final ResultRow row, final String name) {
final String value = row.value(name);
return value.isEmpty() ? "n/a" : String.format(Locale.ROOT, "%.6f", Double.parseDouble(value));
}
/** Formats one raw error numerator, denominator, and percentage. */
private static String pair(final ResultRow row, final String error, final String possible, final String percentage) {
final String rate = row.value(percentage);
return row.value(error) + " / " + row.value(possible) + " (" + (rate.isEmpty() ? "n/a" : String.format(Locale.ROOT, "%.6f%%", Double.parseDouble(rate))) + ")";
}
/** Replaces an existing marked section or appends the first generated section. */
private static String replaceSection(final String original, final String section) {
return replaceMarkedSection(original, section, START, END);
}
/** Replaces or appends a section delimited by the supplied deterministic markers. */
private static String replaceMarkedSection(final String original, final String section, final String startMarker,
final String endMarker) {
final int start = original.indexOf(startMarker);
final int end = original.indexOf(endMarker);
if ((start < 0) != (end < 0) || (start >= 0 && end < start)) {
throw new IllegalStateException("Malformed stemming-quality generated-section markers.");
}
if (start < 0) {
return original.stripTrailing() + "\n\n" + section;
}
return original.substring(0, start) + section + original.substring(end + endMarker.length()).stripLeading();
}
/** Calculates a lowercase hexadecimal SHA-256 checksum. */
private static String sha256(final Path source) throws IOException {
try {
final byte[] digest = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(source));
final StringBuilder text = new StringBuilder(digest.length * 2);
for (byte value : digest) {
text.append(String.format(Locale.ROOT, "%02x", value & 0xff));
}
return text.toString();
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("The required SHA-256 algorithm is unavailable.", exception);
}
}
/** Immutable mapping from a language identifier to its existing page. */
private record Page(String language, String displayName, String file) { }
/** Immutable view of one authoritative CSV row. */
private record ResultRow(List<String> values, Map<String, Integer> indexes) {
/** Creates and validates an immutable row view. */
private ResultRow {
values = List.copyOf(values);
indexes = Map.copyOf(indexes);
}
/** Returns a field by its exact English header. */
private String value(final String name) { return this.values.get(this.indexes.get(name)); }
/** Returns the stemmer identifier. */
private String stemmer() { return value("Stemmer"); }
/** Returns the language identifier. */
private String language() { return value("Language"); }
/** Returns the dictionary-processing mode. */
private String mode() { return value("Dictionary mode"); }
/** Returns the output policy. */
private String policy() { return value("Output policy"); }
/** Returns a unique scenario key. */
private String key() { return stemmer() + "/" + language() + "/" + mode() + "/" + policy(); }
/** Parses a required long field. */
private long longValue(final String name) { return Long.parseLong(value(name)); }
/** Parses a numeric field, placing undefined values last during sorting. */
private double number(final String name) { return value(name).isEmpty() ? Double.NEGATIVE_INFINITY : Double.parseDouble(value(name)); }
/** Returns false-negative pairs. */
private long fn() { return longValue("False-negative pairs"); }
/** Returns false-positive pairs. */
private long fp() { return longValue("False-positive pairs"); }
/** Validates raw confusion counts and the published balanced accuracy. */
private void validate() {
final long tp = longValue("True-positive pairs");
final long fp = fp();
final long fn = fn();
final long tn = longValue("True-negative pairs");
if (fn != longValue("Under-stemming error pairs") || fp != longValue("Over-stemming error pairs")
|| Math.addExact(tp, fn) != longValue("Under-stemming possible pairs")
|| Math.addExact(tn, fp) != longValue("Over-stemming possible pairs")) {
throw new IllegalStateException("Raw pair-count invariants fail for " + key());
}
final double recall = ratio(tp, Math.addExact(tp, fn));
final double specificity = ratio(tn, Math.addExact(tn, fp));
final double expected = (recall + specificity) / 2.0;
if (Math.abs(expected - number("Balanced accuracy")) > 0.0000000000015) {
throw new IllegalStateException("Balanced accuracy is inconsistent with raw counts for " + key());
}
if (!policy().equals("PRIMARY_OUTPUT") && !value("Adjusted Rand Index").isEmpty()) {
throw new IllegalStateException("Partition-only metrics are present for a candidate relation: " + key());
}
}
/** Divides raw counts with explicit zero-denominator handling. */
private static double ratio(final long numerator, final long denominator) {
if (denominator == 0) {
throw new IllegalStateException("A balanced-accuracy component is undefined in a published result row.");
}
return (double) numerator / (double) denominator;
}
}
}