Files
Radixor/build.gradle
Leo Galambos 05f3855b99 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
2026-07-20 23:20:17 +02:00

639 lines
21 KiB
Groovy

plugins {
id 'java'
id 'eclipse'
id 'application'
id 'maven-publish'
id 'signing'
id 'pmd'
id 'jacoco'
id 'info.solidsoft.pitest' version '1.19.0'
id 'me.champeau.jmh' version '0.7.2'
id 'org.owasp.dependencycheck' version '12.2.1'
id 'org.cyclonedx.bom' version '3.2.4'
id 'com.palantir.git-version' version '4.0.0'
}
group = 'org.egothor'
version = gitVersion(prefix:'release@')
def benchmarkReportsDirectory = layout.buildDirectory.dir('reports/jmh')
def sbomReportsDirectory = layout.buildDirectory.dir('reports/sbom')
def jmhIncludesProperty = providers.gradleProperty('jmh.includes')
.orElse(providers.systemProperty('jmh.includes'))
def nvdApiKey = providers.gradleProperty('nvdApiKey')
.orElse(providers.environmentVariable('NVD_API_KEY'))
.orNull
def dependencyCheckSuppressionFile = rootProject.file('dependency-suppression.xml')
apply from: 'gradle/maven-pom.gradle'
configurations {
mockitoAgent
stemmingQualityJmhRuntime {
canBeConsumed = false
canBeResolved = true
extendsFrom(jmhImplementation, jmhRuntimeOnly)
}
}
java {
withSourcesJar()
withJavadocJar()
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
tasks.withType(AbstractArchiveTask).configureEach {
preserveFileTimestamps = false
reproducibleFileOrder = true
}
jacoco {
toolVersion = '0.8.14'
}
pmd {
consoleOutput = true
toolVersion = '7.20.0'
sourceSets = [sourceSets.main]
ruleSetFiles = files(rootProject.file(".ruleset"))
}
dependencyLocking {
lockAllConfigurations()
lockMode = LockMode.STRICT
}
dependencies {
jmhImplementation sourceSets.main.output
testImplementation platform(libs.junit.bom)
testImplementation libs.junit.jupiter
testRuntimeOnly libs.junit.platform.launcher
testImplementation libs.mockito.core
testImplementation libs.mockito.junit.jupiter
testImplementation libs.jqwik
mockitoAgent(libs.mockito.core) {
transitive = false
}
}
sourceSets.jmh.compileClasspath = sourceSets.jmh.compileClasspath - sourceSets.test.output
sourceSets.jmh.runtimeClasspath = sourceSets.jmh.runtimeClasspath - sourceSets.test.output
sourceSets.test.compileClasspath += sourceSets.jmh.output + configurations.jmhCompileClasspath
sourceSets.test.runtimeClasspath += sourceSets.jmh.output + configurations.jmhCompileClasspath
tasks.named('compileJmhJava', JavaCompile) {
classpath = classpath - sourceSets.test.output
setDependsOn([tasks.named('classes')])
}
dependencyCheck {
failBuildOnCVSS = 7.0
failOnError = true
autoUpdate = true
formats = ['HTML', 'JSON']
outputDirectory = layout.buildDirectory.dir('reports/dependency-check').get().asFile.absolutePath
/*
* Keep the scan focused on actual Java dependency inputs used by this project.
* testRuntimeClasspath is included intentionally because the current external
* dependency surface is primarily test-scoped.
*/
scanConfigurations = ['runtimeClasspath', 'testRuntimeClasspath', 'mockitoAgent']
skipTestGroups = false
analyzers {
experimentalEnabled = false
centralEnabled = true
}
nvd {
apiKey = nvdApiKey
delay = nvdApiKey != null ? 3500 : 8000
validForHours = 4
}
if (dependencyCheckSuppressionFile.exists()) {
suppressionFile = dependencyCheckSuppressionFile.absolutePath
failBuildOnUnusedSuppressionRule = true
}
}
def cliIncludeTags = project.findProperty('includeTags')?.toString() ?: System.getProperty('includeTags')
def cliExcludeTags = project.findProperty('excludeTags')?.toString() ?: System.getProperty('excludeTags')
def splitTagExpression = { String tagsExpr ->
if (tagsExpr == null || tagsExpr.isBlank()) {
return []
}
return tagsExpr.split(',')
.collect { it.trim() }
.findAll { it != null && !it.isBlank() }
}
tasks.withType(Test).configureEach {
doFirst {
jvmArgs "-javaagent:${configurations.mockitoAgent.singleFile}"
}
/*
* Bundled dictionary integration tests compile and reload large real-world
* stemming dictionaries, including large language resources such as es_es.
* The default Gradle test executor heap is too small for this workload.
*/
minHeapSize = '1g'
maxHeapSize = '4g'
reports {
junitXml.required = true
html.required = true
}
}
def configureJUnitPlatformTags = { Test task, String includeTagsExpr, String excludeTagsExpr ->
task.useJUnitPlatform {
final def includes = splitTagExpression(includeTagsExpr)
final def excludes = splitTagExpression(excludeTagsExpr)
if (!includes.isEmpty()) {
includeTags(*includes.toArray(new String[0]))
}
if (!excludes.isEmpty()) {
excludeTags(*excludes.toArray(new String[0]))
}
}
}
tasks.named('test', Test) {
final def requestedIncludes = splitTagExpression(cliIncludeTags)
final boolean slowExplicitlyIncluded = requestedIncludes.contains('slow')
final String defaultExcludeTags = cliExcludeTags ?: (slowExplicitlyIncluded ? null : 'slow')
configureJUnitPlatformTags(it, cliIncludeTags, defaultExcludeTags)
finalizedBy(tasks.named('jacocoTestReport'))
}
def configureTaggedTestProfile = { String taskName, String includeTagsExpr, String excludeTagsExpr = null,
String taskDescription = null, String testNameExcludePatterns = null ->
tasks.register(taskName, Test) {
group = 'verification'
description = taskDescription
configureJUnitPlatformTags(delegate as Test, includeTagsExpr, excludeTagsExpr)
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
dependsOn(tasks.named('compileTestJava'))
doFirst {
jvmArgs "-javaagent:${configurations.mockitoAgent.singleFile}"
}
if (testNameExcludePatterns != null && !testNameExcludePatterns.isBlank()) {
filter {
testNameExcludePatterns.split(',').each { String pattern ->
final def trimmedPattern = pattern.trim()
if (!trimmedPattern.isEmpty()) {
excludeTestsMatching(trimmedPattern)
}
}
}
}
minHeapSize = '1g'
maxHeapSize = '4g'
reports {
junitXml.required = true
html.required = true
}
}
}
configureTaggedTestProfile(
'ciSmoke',
'unit',
'slow',
'Fast feedback profile for unit tests with slow tests explicitly excluded.',
'org.egothor.stemmer.CompileIntegrationTest*'
)
configureTaggedTestProfile(
'ciCore',
'unit,trie,frequency-trie,property',
null,
'Focused profile for core trie behavior and trie-specific property checks.'
)
configureTaggedTestProfile(
'ciIntegration',
'integration',
'slow',
'Integration pipeline profile (loader/parser/CLI/IO end-to-end flows) excluding slow integration paths.'
)
configureTaggedTestProfile(
'ciSlow',
'slow',
null,
'Targeted profile for all slow tests (large dictionaries, long-running corpus validation, and heavy integration checks).'
)
configureTaggedTestProfile(
'ciCompat',
'compat,regression',
null,
'Compatibility profile guarding persisted artifact and compatibility regressions.'
)
configureTaggedTestProfile(
'ciRelease',
null,
'slow',
'Release-profile validation of all non-slow tests.',
'org.egothor.stemmer.CompileIntegrationTest*,org.egothor.stemmer.StemmerPatchTrieLoaderTest$BundledDictionaryTests*'
)
configureTaggedTestProfile(
'ciNightly',
'fuzz',
null,
'Nightly robustness profile with fuzz testing emphasis.'
)
tasks.register('ci') {
group = 'verification'
description = 'Runs the full enterprise CI profile set in sequence.'
dependsOn(tasks.named('ciSmoke'))
dependsOn(tasks.named('ciCore'))
dependsOn(tasks.named('ciIntegration'))
dependsOn(tasks.named('ciCompat'))
}
tasks.withType(Pmd).configureEach {
reports {
xml.required = true
html.required = true
}
}
tasks.named('jacocoTestReport', JacocoReport) {
dependsOn(tasks.named('test'))
classDirectories.setFrom(
files(sourceSets.main.output).asFileTree.matching {
exclude 'org/egothor/stemmer/StemmerKnowledgeExperiment*'
exclude 'org/egothor/stemmer/DiacriticStripper*'
}
)
reports {
xml.required = true
csv.required = false
html.required = true
}
}
def registerJacocoProfileReport = { String reportTaskName, String sourceTaskName ->
tasks.register(reportTaskName, JacocoReport) {
group = 'verification'
description = "Generates Jacoco report for ${sourceTaskName} execution."
dependsOn(tasks.named(sourceTaskName))
classDirectories.setFrom(
files(sourceSets.main.output).asFileTree.matching {
exclude 'org/egothor/stemmer/StemmerKnowledgeExperiment*'
exclude 'org/egothor/stemmer/DiacriticStripper*'
}
)
executionData.setFrom(
fileTree(layout.buildDirectory.dir('jacoco')) {
include "${sourceTaskName}.exec"
}
)
reports {
xml.required = true
csv.required = false
html.required = true
}
}
}
registerJacocoProfileReport('jacocoCiReleaseReport', 'ciRelease')
tasks.named('check') {
dependsOn(tasks.named('jacocoTestReport'))
// no-default, only on-demand: dependsOn(tasks.named('dependencyCheckAnalyze'))
}
allprojects {
tasks.matching { it.name == 'cyclonedxDirectBom' }.configureEach {
includeConfigs = ['runtimeClasspath', 'compileClasspath']
skipConfigs = ['testRuntimeClasspath', 'testCompileClasspath', 'jmh.*', 'mockitoAgent']
includeBomSerialNumber = true
includeLicenseText = false
includeMetadataResolution = true
includeBuildSystem = true
}
}
tasks.named('cyclonedxBom') {
includeBomSerialNumber = true
includeLicenseText = false
includeBuildSystem = true
jsonOutput.set(sbomReportsDirectory.map { it.file('radixor-sbom.json') })
xmlOutput.set(sbomReportsDirectory.map { it.file('radixor-sbom.xml') })
}
pitest {
pitestVersion = '1.22.1'
junit5PluginVersion = '1.2.3'
targetClasses = [
'org.egothor.stemmer.*',
'org.egothor.stemmer.trie.*'
]
targetTests = [
'org.egothor.stemmer.*Test',
'org.egothor.stemmer.trie.*Test'
]
excludedClasses = [
'org.egothor.stemmer.Compile*',
'org.egothor.stemmer.StemmerPatchTrieLoader*',
'org.egothor.stemmer.StemmerKnowledgeExperiment*',
'org.egothor.stemmer.StemmerKnowledgeExperimentCli*'
]
excludedTestClasses = [
'org.egothor.stemmer.CompileIntegrationTest',
'org.egothor.stemmer.StemmerPatchTrieLoaderTest',
'org.egothor.stemmer.StemmerKnowledgeExperimentTest'
]
outputFormats = ['XML', 'HTML']
timestampedReports = false
exportLineCoverage = true
failWhenNoMutations = true
threads = Math.max(1, Runtime.runtime.availableProcessors().intdiv(2))
}
application {
mainClass = 'org.egothor.stemmer.Compile'
applicationName = 'radixor'
executableDir = 'bin'
}
tasks.register('stemmerKnowledgeExperiment', JavaExec) {
group = 'application'
description = 'Runs the stemmer knowledge evaluation experiment.'
classpath = sourceSets.main.runtimeClasspath
mainClass = 'org.egothor.stemmer.StemmerKnowledgeExperimentCli'
}
distributions {
main {
distributionBaseName = 'radixor'
contents {
from('README.md') {
into ''
}
from('LICENSE') {
into ''
}
from('LICENSE-stemmer-data') {
into ''
}
from('docs') {
into 'docs'
include '**/*.md'
}
from(layout.buildDirectory.dir('generated/release-notes')) {
into ''
include 'CHANGELOG.md'
}
}
}
}
tasks.named('startScripts') {
applicationName = 'radixor'
}
tasks.named('distZip', Zip) {
archiveBaseName = 'radixor'
archiveClassifier = 'bin'
}
tasks.named('distTar') {
enabled = false
}
jmh {
jmhVersion = '1.37'
includeTests = false
warmupIterations = 3
iterations = 5
fork = 1
benchmarkMode = ['avgt']
timeUnit = 'ns'
resultFormat = 'CSV'
resultsFile = benchmarkReportsDirectory.map { it.file('jmh-results.csv').asFile }.get()
humanOutputFile = benchmarkReportsDirectory.map { it.file('jmh-results.txt').asFile }.get()
duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE
if (jmhIncludesProperty.isPresent()) {
includes = [jmhIncludesProperty.get()]
}
}
tasks.named('jmh') {
group = 'verification'
description = 'Runs JMH benchmarks for the Radixor algorithmic core and external stemmer comparison suites.'
}
apply from: 'gradle/lucene-benchmarks.gradle'
tasks.register('regressionArtifactGenerator', JavaExec) {
group = 'verification'
description = 'Generates deterministic compiled trie regression artifacts.'
classpath = sourceSets.test.runtimeClasspath
mainClass = 'org.egothor.stemmer.RegressionArtifactGenerator'
if (project.hasProperty('regressionInput')) {
args '--input', project.property('regressionInput').toString()
}
if (project.hasProperty('regressionOutput')) {
args '--output', project.property('regressionOutput').toString()
}
if (project.hasProperty('regressionStoreOriginal')) {
args '--store-original', project.property('regressionStoreOriginal').toString()
}
if (project.hasProperty('regressionReductionMode')) {
args '--reduction-mode', project.property('regressionReductionMode').toString()
}
}
tasks.register('stemmingQuality', JavaExec) {
group = 'verification'
description = 'Evaluates pairwise over-stemming and under-stemming against bundled dictionary groups.'
dependsOn(tasks.named('testClasses'))
dependsOn(tasks.named('jmhClasses'))
classpath = files(sourceSets.test.runtimeClasspath, configurations.stemmingQualityJmhRuntime)
mainClass = 'org.egothor.stemmer.benchmark.quality.StemmingQualityApplication'
args layout.buildDirectory.dir('reports/stemming-quality').get().asFile.absolutePath,
layout.projectDirectory.dir('src/main/resources').asFile.absolutePath,
providers.gradleProperty('stemmingQualityLanguage').getOrElse(''),
providers.gradleProperty('stemmingQualityStemmer').getOrElse(''),
providers.gradleProperty('stemmingQualityMode').getOrElse(''),
providers.gradleProperty('stemmingQualityOutputPolicy').getOrElse(''),
providers.gradleProperty('stemmingQualityRankMetric').getOrElse('PAIRWISE_F05'),
providers.gradleProperty('stemmingQualityAudit').getOrElse('false'),
providers.gradleProperty('stemmingQualityAuditLimit').getOrElse('25')
maxHeapSize = '6g'
}
tasks.register('publishStemmingQualityDocumentation', JavaExec) {
group = 'documentation'
description = 'Publishes validated complete stemming-quality results on the language benchmark pages.'
dependsOn(tasks.named('testClasses'))
classpath = sourceSets.test.runtimeClasspath
mainClass = 'org.egothor.stemmer.benchmark.quality.StemmingQualityDocumentationPublisher'
args layout.buildDirectory.file('reports/stemming-quality/stemming-quality.csv').get().asFile.absolutePath,
layout.projectDirectory.dir('docs').asFile.absolutePath,
'update'
doFirst {
if (!file("$buildDir/reports/stemming-quality/stemming-quality.csv").isFile()) {
throw new GradleException('A complete stemming-quality CSV is required. Run stemmingQuality only when no validated complete report is available.')
}
}
}
tasks.register('verifyStemmingQualityDocumentation', JavaExec) {
group = 'verification'
description = 'Verifies published language-page quality tables against the checked-in authoritative CSV.'
dependsOn(tasks.named('testClasses'))
classpath = sourceSets.test.runtimeClasspath
mainClass = 'org.egothor.stemmer.benchmark.quality.StemmingQualityDocumentationPublisher'
args layout.projectDirectory.file('docs/benchmarks/data/stemming-quality.csv').asFile.absolutePath,
layout.projectDirectory.dir('docs').asFile.absolutePath,
'verify'
}
tasks.named('check') {
dependsOn(tasks.named('verifyStemmingQualityDocumentation'))
}
tasks.register('verifyStemmingQualitySourceSets') {
group = 'verification'
description = 'Verifies the production, JMH, and standard-test ownership of stemming-quality infrastructure.'
doLast {
if (sourceSets.findByName('stemmingQualityTest') != null || file('src/stemmingQualityTest').exists()) {
throw new GradleException('The obsolete stemmingQualityTest source set or directory still exists.')
}
if (!file('src/jmh/java/org/egothor/stemmer/benchmark/QualityStemmerMatrix.java').isFile()) {
throw new GradleException('The authoritative JMH stemmer matrix is not in src/jmh.')
}
if (!file('src/test/java/org/egothor/stemmer/benchmark/quality/StemmingQualityApplication.java').isFile()) {
throw new GradleException('The stemming-quality evaluator is not in the standard test source set.')
}
}
}
tasks.register('verifyProductionJarExcludesStemmingQuality') {
group = 'verification'
description = 'Verifies that analytical stemming-quality classes are absent from the production JAR.'
dependsOn(tasks.named('jar'))
doLast {
final File archive = tasks.named('jar').get().archiveFile.get().asFile
final def forbidden = zipTree(archive).matching { include '**/benchmark/**' }.files
if (!forbidden.isEmpty()) {
throw new GradleException("Production JAR contains analytical stemming-quality classes: ${forbidden}")
}
}
}
tasks.register('printDependencyCheckNvdConfig') {
doLast {
System.out.println("NVD API key present: " + (nvdApiKey != null && !nvdApiKey.isBlank()))
}
}
tasks.named('dependencyCheckAnalyze') {
dependsOn(tasks.named('printDependencyCheckNvdConfig'))
}
javadoc {
failOnError = false
options.addStringOption('Xdoclint:all,-missing', '-quiet')
options.addBooleanOption('html5', true)
options.tags('apiNote:a:API Note:')
options.tags('implSpec:a:Implementation Requirements:')
options.tags('implNote:a:Implementation Note:')
options.tags('param')
options.tags('return')
options.tags('throws')
options.tags('since')
options.tags('version')
options.tags('serialData')
options.tags('factory')
options.tags('see')
options.use = true
options.author = true
options.version = true
options.windowTitle = 'Radixor - Egothor Stemmer'
options.docTitle = 'Radixor - Egothor Stemmer API'
options.overview = file('src/main/javadoc/overview.html')
options.bottom = """
<div class="legal-copy">
&copy; 2026 Egothor
<br/>
Licensed under <a href="https://github.com/leogalambos/Radixor/blob/main/LICENSE">BSD-3-Clause</a>
</div>
"""
options.links('https://docs.oracle.com/en/java/javase/21/docs/api/')
options.group('Core Stemming API', 'org.egothor.stemmer')
options.group('Trie Infrastructure', 'org.egothor.stemmer.trie')
source = sourceSets.main.allJava
}
apply from: 'gradle/snowball-benchmarks.gradle'
apply from: 'gradle/paicehusk-benchmarks.gradle'
apply from: 'gradle/opennlp-benchmarks.gradle'
apply from: 'gradle/hunspell-benchmarks.gradle'
apply from: 'gradle/cistem-benchmarks.gradle'
gradle.taskGraph.whenReady { taskGraph ->
def banner = """
\u001B[34m
8888888888 .d8888b. .d88888b. 88888888888 888 888 .d88888b. 8888888b.
888 d88P Y88b d88P" "Y88b 888 888 888 d88P" "Y88b 888 Y88b
888 888 888 888 888 888 888 888 888 888 888 888
8888888 888 888 888 888 8888888888 888 888 888 d88P
888 888 88888 888 888 888 888 888 888 888 8888888P"
888 888 888 888 888 888 888 888 888 888 888 T88b
888 Y88b d88P Y88b. .d88P 888 888 888 Y88b. .d88P 888 T88b
8888888888 "Y8888P88 "Y88888P" 888 888 888 "Y88888P" 888 T88b
\u001B[36m
Project : ${project.name}
Version : ${project.version}
\u001B[0m
"""
println banner
}