+// e.g. [ARABIC, ARMENIAN, BENGALI, CYRILLIC, DEVANAGARI, GEORGIAN,
+// GREEK, GUJARATI, GURMUKHI, HAN, HANGUL, HEBREW, HIRAGANA,
+// KANNADA, KHMER, LAO, LATIN, MALAYALAM, MYANMAR, ORIYA,
+// SINHALA, TAMIL, TELUGU, THAANA, THAI, TIBETAN, ...]
+----
+
+If the dominant script of an input is not in this set, `score()` returns a
+`TextQualityScore` where `isUnknown()` is true and no z-score is available.
+
+== Thresholds and operating points
+
+There is no universally correct threshold. The right cutoff depends on your
+content and tolerance for false positives (flagging good text as junk).
+
+**Starting points:**
+
+* **Trigger re-OCR**: z < −2.0 (catches ~95% of severe corruption while flagging
+ ~2–5% of legitimate text on average, more for short strings).
+* **Charset tiebreaking**: prefer the candidate with the higher z-score when
+ `delta() > 1.0`; abstain if `delta() < 0.5`.
+* **Training data filtering**: z < −1.5 to remove mojibake and bot-generated
+ noise from NLP corpora.
+
+For short text (under ~50 UTF-8 bytes), use `getCiLow()` rather than `getZScore()`
+for threshold decisions, since the confidence interval widens substantially.
+
+== Limitations
+
+* **Script coverage**: only scripts with a trained model can be scored. Unknown
+ scripts return `isUnknown() = true`.
+* **Short text**: scoring is unreliable below ~15 UTF-8 bytes. The model needs
+ at least a few bigrams to produce a stable estimate.
+* **Closely related charsets in the same script pool**: the LATIN model is trained
+ across hundreds of languages, which dilutes the signal for closely related
+ Western European and Baltic encodings (e.g., cp1252 vs. cp1257 on Lithuanian
+ text). The winner is usually correct, but delta may be small (< 0.5).
+* **Deliberately obfuscated text**: content designed to look like natural language
+ (e.g. by adversarial padding) is not detected.
+
+== Further reading
+
+For training methodology, model format, evaluation harness, and guidance on
+improving the model, see
+xref:advanced/junk-detection-build.adoc[Building the Junk Detector].
diff --git a/tika-core/src/main/java/org/apache/tika/quality/TextQualityComparison.java b/tika-core/src/main/java/org/apache/tika/quality/TextQualityComparison.java
new file mode 100644
index 00000000000..8c054b0ef75
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/quality/TextQualityComparison.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.quality;
+
+/**
+ * Result of comparing two candidate strings for text quality via
+ * {@link TextQualityDetector#compare}.
+ *
+ * A typical use is charset-decoding arbitration: given raw bytes decoded
+ * two different ways (e.g. cp1251 vs cp1252), pass each decoded string with a
+ * label and let the detector pick the cleaner one.
+ *
+ *
The {@code delta} field is the absolute difference between the two z-scores.
+ * A delta near zero means the model is uncertain; larger values indicate
+ * confident discrimination. As a rough guide: delta > 1.0 is useful signal,
+ * delta > 3.0 is confident.
+ */
+public final class TextQualityComparison {
+
+ private final String winner;
+ private final float delta;
+ private final TextQualityScore scoreA;
+ private final TextQualityScore scoreB;
+ private final String labelA;
+ private final String labelB;
+
+ public TextQualityComparison(String winner, float delta,
+ TextQualityScore scoreA, TextQualityScore scoreB,
+ String labelA, String labelB) {
+ this.winner = winner;
+ this.delta = delta;
+ this.scoreA = scoreA;
+ this.scoreB = scoreB;
+ this.labelA = labelA;
+ this.labelB = labelB;
+ }
+
+ /**
+ * Returns {@code "A"} if candidate A is cleaner, {@code "B"} otherwise.
+ * Check {@link #delta()} to gauge confidence.
+ */
+ public String winner() {
+ return winner;
+ }
+
+ /**
+ * Absolute difference in z-scores between the two candidates.
+ * Small delta = uncertain; large delta = confident.
+ */
+ public float delta() {
+ return delta;
+ }
+
+ /** Quality score for candidate A. */
+ public TextQualityScore scoreA() {
+ return scoreA;
+ }
+
+ /** Quality score for candidate B. */
+ public TextQualityScore scoreB() {
+ return scoreB;
+ }
+
+ /** Label supplied for candidate A (e.g. a charset name or encoding description). */
+ public String labelA() {
+ return labelA;
+ }
+
+ /** Label supplied for candidate B. */
+ public String labelB() {
+ return labelB;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(java.util.Locale.ROOT,
+ "TextQualityComparison[winner=%s(%s) delta=%.3f A=%s B=%s]",
+ winner, winner.equals("A") ? labelA : labelB,
+ delta, scoreA, scoreB);
+ }
+}
diff --git a/tika-core/src/main/java/org/apache/tika/quality/TextQualityDetector.java b/tika-core/src/main/java/org/apache/tika/quality/TextQualityDetector.java
new file mode 100644
index 00000000000..d832b5a169d
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/quality/TextQualityDetector.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.quality;
+
+/**
+ * Scores a string for text quality and arbitrates between two candidate strings.
+ *
+ *
Implementations are expected to be immutable and thread-safe after construction.
+ *
+ *
Implementations are registered via the standard Java {@link java.util.ServiceLoader}
+ * mechanism: place the fully-qualified class name in
+ * {@code META-INF/services/org.apache.tika.quality.TextQualityDetector}.
+ *
+ *
Typical usage:
+ *
{@code
+ * TextQualityDetector detector = ServiceLoader.load(TextQualityDetector.class)
+ * .findFirst().orElseThrow();
+ *
+ * // Score a string
+ * TextQualityScore score = detector.score(text);
+ * if (score.getZScore() < -2.0) { ... flag or re-process ... }
+ *
+ * // Arbitrate between two charset decodings
+ * TextQualityComparison cmp = detector.compare("cp1252", decodedAsCp1252,
+ * "cp1251", decodedAsCp1251);
+ * String winner = cmp.winner(); // "A" or "B"
+ * }
+ */
+public interface TextQualityDetector {
+
+ /**
+ * Scores the given string for text quality.
+ *
+ * @param text the string to score; must not be null
+ * @return a {@link TextQualityScore}; check {@link TextQualityScore#isUnknown()}
+ * if the input is empty or the script is not covered by the model
+ */
+ TextQualityScore score(String text);
+
+ /**
+ * Compares two candidate strings and returns which is higher-quality (cleaner text).
+ *
+ * A common use case is charset-decoding arbitration: given raw bytes decoded
+ * via two different charsets, pass each decoded string here with a human-readable
+ * label (e.g. the charset name) and the detector will pick the one that looks
+ * more like natural language.
+ *
+ * @param labelA human-readable label for candidate A (e.g. {@code "cp1252"})
+ * @param candidateA first candidate string
+ * @param labelB human-readable label for candidate B (e.g. {@code "cp1251"})
+ * @param candidateB second candidate string
+ * @return a {@link TextQualityComparison} with the winning label and confidence delta
+ */
+ TextQualityComparison compare(String labelA, String candidateA,
+ String labelB, String candidateB);
+}
diff --git a/tika-core/src/main/java/org/apache/tika/quality/TextQualityScore.java b/tika-core/src/main/java/org/apache/tika/quality/TextQualityScore.java
new file mode 100644
index 00000000000..8eea89ddec4
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/quality/TextQualityScore.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.quality;
+
+/**
+ * Result of scoring a string for text quality via a {@link TextQualityDetector}.
+ *
+ *
{@code zScore} is the primary output: how many standard deviations below
+ * typical clean text this string scores on its dominant script's model.
+ * Negative means worse than average clean text; more negative means worse.
+ *
+ *
{@code pClean} is a probability estimate in [0,1] that this is clean text.
+ *
+ *
{@code ciLow} / {@code ciHigh} are the 95% confidence interval bounds on
+ * {@code zScore}. For short strings these bounds are wide; for long strings
+ * they narrow. Prefer {@code ciLow < threshold} over {@code zScore < threshold}
+ * when triggering actions, to reduce false positives on short strings.
+ */
+public final class TextQualityScore {
+
+ /** Sentinel z-score returned when scoring could not be run (e.g. null or empty input). */
+ public static final float UNKNOWN = Float.NaN;
+
+ private final float zScore;
+ private final float pClean;
+ private final float ciLow;
+ private final float ciHigh;
+ private final String dominantScript;
+
+ public TextQualityScore(float zScore, float pClean,
+ float ciLow, float ciHigh,
+ String dominantScript) {
+ this.zScore = zScore;
+ this.pClean = pClean;
+ this.ciLow = ciLow;
+ this.ciHigh = ciHigh;
+ this.dominantScript = dominantScript;
+ }
+
+ /** Z-score relative to clean text for the detected script. 0 = average clean; negative = worse. */
+ public float getZScore() {
+ return zScore;
+ }
+
+ /** Probability in [0,1] that this string is clean text. */
+ public float getPClean() {
+ return pClean;
+ }
+
+ /** Lower bound of the 95% confidence interval on zScore. */
+ public float getCiLow() {
+ return ciLow;
+ }
+
+ /** Upper bound of the 95% confidence interval on zScore. */
+ public float getCiHigh() {
+ return ciHigh;
+ }
+
+ /** Name of the dominant Unicode script detected, e.g. "LATIN", "CYRILLIC", "ARABIC". */
+ public String getDominantScript() {
+ return dominantScript;
+ }
+
+ /** True if scoring could not be performed (e.g. empty or unsupported-script input). */
+ public boolean isUnknown() {
+ return Float.isNaN(zScore);
+ }
+
+ @Override
+ public String toString() {
+ if (isUnknown()) {
+ return "TextQualityScore[UNKNOWN script=" + dominantScript + "]";
+ }
+ return String.format(java.util.Locale.ROOT,
+ "TextQualityScore[z=%.3f p=%.3f ci=(%.3f,%.3f) script=%s]",
+ zScore, pClean, ciLow, ciHigh, dominantScript);
+ }
+}
diff --git a/tika-ml/pom.xml b/tika-ml/pom.xml
index 3af2057aa81..5c9cf03af1b 100644
--- a/tika-ml/pom.xml
+++ b/tika-ml/pom.xml
@@ -34,6 +34,7 @@
tika-ml-core
tika-ml-chardetect
+ tika-ml-junkdetect
diff --git a/tika-ml/tika-ml-junkdetect/pom.xml b/tika-ml/tika-ml-junkdetect/pom.xml
new file mode 100644
index 00000000000..672e49195a0
--- /dev/null
+++ b/tika-ml/tika-ml-junkdetect/pom.xml
@@ -0,0 +1,153 @@
+
+
+
+
+ tika-ml
+ org.apache.tika
+ ${revision}
+
+ 4.0.0
+
+ tika-ml-junkdetect
+ Apache Tika ML junk detector — runtime and training tools
+
+ Language-agnostic text quality scorer that discriminates between clean UTF-8 text and
+ mojibake, reversed text, wrong-codec decodings, and other corruption forms.
+ Provides a standalone "languageyness" score suitable for re-OCR triggering and
+ charset-decoding arbitration.
+
+ Runtime classes (JunkDetector, ScriptDetector, feature extractors) and bundled model
+ resources live here. Training and evaluation CLI tools live in the tools subpackage.
+
+
+
+
+ org.apache.tika
+ tika-core
+ ${revision}
+
+
+ org.apache.tika
+ tika-ml-core
+ ${revision}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+
+ org.apache.tika.ml.junkdetect
+
+
+
+
+
+ org.apache.rat
+ apache-rat-plugin
+
+
+ **/*.bin
+ **/*.txt
+
+
+
+
+
+ de.thetaphi
+ forbiddenapis
+
+ true
+
+
+
+
+
+
+
+
+ train
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+
+
+ package
+ shade
+
+ true
+ tools
+
+
+ org.apache.tika.ml.junkdetect.tools.TrainJunkModel
+
+
+
+
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 3.0.0-rc1
+
+
diff --git a/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkDetector.java b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkDetector.java
new file mode 100644
index 00000000000..07aeb641648
--- /dev/null
+++ b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkDetector.java
@@ -0,0 +1,660 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.ml.junkdetect;
+
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.zip.GZIPInputStream;
+
+import org.apache.tika.quality.TextQualityComparison;
+import org.apache.tika.quality.TextQualityDetector;
+import org.apache.tika.quality.TextQualityScore;
+
+/**
+ * Language-agnostic text quality scorer. Discriminates clean UTF-8 text from
+ * mojibake, reversed text, wrong-codec decodings, and other corruption forms.
+ *
+ * Scoring combines up to three features, depending on the model version:
+ *
+ * - Byte-bigram log-probability — 256×256 table of log P(b|a) over
+ * consecutive byte pairs in the UTF-8 encoding.
+ * - Unicode named-block transition log-probability (version 2+) —
+ * N×N table of log P(block_b | block_a) where block IDs are the named
+ * {@link Character.UnicodeBlock} values (BASIC_LATIN, ARABIC,
+ * CJK_UNIFIED_IDEOGRAPHS, etc.).
+ * - Control-byte fraction (version 2+) — fraction of bytes in control
+ * ranges [0x01–0x08, 0x0B, 0x0C, 0x0E–0x1F, 0x7F].
+ *
+ *
+ * All features are calibrated (mu/sigma) on held-out dev text so their z-scores
+ * are on a common scale.
+ *
+ *
Features are combined by a per-script logistic regression classifier:
+ * {@code w1*z1 + w2*z2 + w3*z3 + w4*z4 + bias}, where weights are fit on
+ * clean vs. corrupted dev windows. The natural junk threshold is 0 (positive
+ * logit = clean); use a negative threshold for conservative detection
+ * (e.g., {@code score < -1}).
+ *
+ * Instances are immutable and thread-safe after construction.
+ *
+ *
Typical usage:
+ *
{@code
+ * JunkDetector detector = JunkDetector.loadFromClasspath();
+ * TextQualityScore score = detector.score("some text");
+ * if (score.getZScore() < 0) { ... flag as junk ... }
+ *
+ * // Arbitrate between two charset decodings
+ * TextQualityComparison result = detector.compare("cp1252", ascp1252, "cp1251", ascp1251);
+ * String winner = result.winner(); // "A" or "B"
+ * }
+ */
+public final class JunkDetector implements TextQualityDetector {
+
+ /** Classpath resource path for the bundled production model. */
+ public static final String DEFAULT_MODEL_RESOURCE =
+ "org/apache/tika/ml/junkdetect/junkdetect.bin";
+
+ static final String MAGIC = "JUNKDET1";
+
+ private final int modelVersion;
+
+ // Feature 1: byte bigrams (all versions)
+ private final Map tables; // script → float[65536] log-prob
+ private final Map calibrations; // script → float[2] {mu, sigma}
+
+ // Feature 2: named-block transitions (version 2+); null for v1 models
+ private final Map blockTables; // script → float[blockN*blockN]
+ private final Map blockCalibrations; // script → float[2] {mu, sigma}
+ private final int blockN; // block table dimension (0 for v1)
+
+ // Feature 3: control-byte fraction (version 2+); null for v1 models
+ private final Map controlCalibrations; // script → float[2] {mu, sigma}
+
+ // Feature combination: per-script linear classifier (version 3+); null for v1/v2 models
+ // float[numFeatures+1] = {w1, ..., wN, bias}; positive logit = clean
+ private final Map classifierWeights;
+
+ // Feature 4: global script-transition (version 4+); null for v1/v2/v3 models
+ // One global table: float[numScriptBuckets * numScriptBuckets] log P(script_b | script_a)
+ // Uses raw UnicodeScript names (not SCRIPT_MODEL_FALLBACK) to distinguish HIRAGANA/KATAKANA/HAN.
+ private final float[] scriptTransitionTable;
+ private final float[] scriptTransitionCalibration; // float[2] = {mu, sigma}
+ private final Map scriptBucketIndex; // raw UnicodeScript name → bucket ID
+ private final int numScriptBuckets; // 0 for v1/v2/v3
+
+ // Shared block index for v2+ models: UnicodeBlock → index [0, blockN-1)
+ // Index blockN-1 is the "unassigned" bucket (null UnicodeBlock).
+ private final Map blockIndex;
+
+ private JunkDetector(int modelVersion,
+ Map tables,
+ Map calibrations,
+ Map blockTables,
+ Map blockCalibrations,
+ int blockN,
+ Map controlCalibrations,
+ Map classifierWeights,
+ Map blockIndex,
+ float[] scriptTransitionTable,
+ float[] scriptTransitionCalibration,
+ Map scriptBucketIndex,
+ int numScriptBuckets) {
+ this.modelVersion = modelVersion;
+ this.tables = Collections.unmodifiableMap(tables);
+ this.calibrations = Collections.unmodifiableMap(calibrations);
+ this.blockTables = blockTables != null
+ ? Collections.unmodifiableMap(blockTables) : null;
+ this.blockCalibrations = blockCalibrations != null
+ ? Collections.unmodifiableMap(blockCalibrations) : null;
+ this.blockN = blockN;
+ this.controlCalibrations = controlCalibrations != null
+ ? Collections.unmodifiableMap(controlCalibrations) : null;
+ this.classifierWeights = classifierWeights != null
+ ? Collections.unmodifiableMap(classifierWeights) : null;
+ this.blockIndex = blockIndex;
+ this.scriptTransitionTable = scriptTransitionTable;
+ this.scriptTransitionCalibration = scriptTransitionCalibration;
+ this.scriptBucketIndex = scriptBucketIndex != null
+ ? Collections.unmodifiableMap(scriptBucketIndex) : null;
+ this.numScriptBuckets = numScriptBuckets;
+ }
+
+ // -----------------------------------------------------------------------
+ // Factory methods
+ // -----------------------------------------------------------------------
+
+ /**
+ * Loads the bundled model from the classpath.
+ *
+ * @throws IOException if the model resource is missing or malformed
+ */
+ public static JunkDetector loadFromClasspath() throws IOException {
+ InputStream is = JunkDetector.class.getClassLoader()
+ .getResourceAsStream(DEFAULT_MODEL_RESOURCE);
+ if (is == null) {
+ throw new IOException("Model resource not found on classpath: "
+ + DEFAULT_MODEL_RESOURCE);
+ }
+ try (InputStream wrapped = is) {
+ return load(wrapped);
+ }
+ }
+
+ /**
+ * Loads a model from the given file path. The file may be gzipped or raw.
+ */
+ public static JunkDetector loadFromPath(Path path) throws IOException {
+ try (InputStream is = Files.newInputStream(path)) {
+ return load(is);
+ }
+ }
+
+ /**
+ * Loads a model from an {@link InputStream}. Gzip-detection is automatic.
+ * Supports model versions 1 through 5.
+ */
+ public static JunkDetector load(InputStream rawIs) throws IOException {
+ byte[] peek = rawIs.readNBytes(2);
+ InputStream rest = new java.io.SequenceInputStream(
+ new java.io.ByteArrayInputStream(peek), rawIs);
+ InputStream in;
+ if (peek.length >= 2 && (peek[0] & 0xFF) == 0x1f && (peek[1] & 0xFF) == 0x8b) {
+ in = new GZIPInputStream(rest);
+ } else {
+ in = rest;
+ }
+
+ try (DataInputStream dis = new DataInputStream(in)) {
+ byte[] magic = dis.readNBytes(8);
+ if (!new String(magic, StandardCharsets.UTF_8).equals(MAGIC)) {
+ throw new IOException("Not a JunkDetector model file (bad magic)");
+ }
+ int version = dis.readUnsignedByte();
+ if (version != 5) {
+ throw new IOException("Unsupported model version: " + version
+ + ". Only version 5 is supported. Retrain the model with TrainJunkModel.");
+ }
+
+ int numScripts = dis.readInt();
+
+ // Block names (v5): stored in model for JVM-independence
+ int blockN = dis.readUnsignedShort();
+ String[] blockNames = new String[blockN - 1];
+ for (int i = 0; i < blockN - 1; i++) {
+ int nameLen = dis.readUnsignedShort();
+ blockNames[i] = new String(dis.readNBytes(nameLen), StandardCharsets.UTF_8);
+ }
+ Map blockIndex = buildBlockIndexFromNames(blockNames);
+
+ // Global script-transition section
+ int numScriptBuckets = dis.readUnsignedByte();
+ Map scriptBucketIndex = new LinkedHashMap<>(numScriptBuckets * 2);
+ for (int i = 0; i < numScriptBuckets; i++) {
+ int nameLen = dis.readUnsignedShort();
+ String bucketName = new String(dis.readNBytes(nameLen), StandardCharsets.UTF_8);
+ scriptBucketIndex.put(bucketName, i);
+ }
+ float[] scriptTransitionTable = readFloatTable(dis, numScriptBuckets * numScriptBuckets);
+ float[] scriptTransitionCalibration = new float[]{dis.readFloat(), dis.readFloat()};
+
+ Map tables = new HashMap<>(numScripts * 2);
+ Map calibrations = new HashMap<>(numScripts * 2);
+ Map blockTables = new HashMap<>(numScripts * 2);
+ Map blockCalibrations = new HashMap<>(numScripts * 2);
+ Map controlCalibrations = new HashMap<>(numScripts * 2);
+ Map classifierWeights = new HashMap<>(numScripts * 2);
+
+ for (int s = 0; s < numScripts; s++) {
+ int nameLen = dis.readUnsignedShort();
+ String script = new String(dis.readNBytes(nameLen), StandardCharsets.UTF_8);
+
+ // Feature 1: byte bigrams
+ calibrations.put(script, new float[]{dis.readFloat(), dis.readFloat()});
+ tables.put(script, readFloatTable(dis, 65536));
+
+ // Feature 2: named-block transitions
+ blockCalibrations.put(script, new float[]{dis.readFloat(), dis.readFloat()});
+ blockTables.put(script, readFloatTable(dis, blockN * blockN));
+
+ // Feature 3: control-byte fraction
+ controlCalibrations.put(script, new float[]{dis.readFloat(), dis.readFloat()});
+
+ // Classifier weights: num_features (1 byte) + num_features floats + 1 bias
+ int numFeatures = dis.readUnsignedByte();
+ float[] weights = new float[numFeatures + 1]; // last = bias
+ for (int j = 0; j <= numFeatures; j++) {
+ weights[j] = dis.readFloat();
+ }
+ classifierWeights.put(script, weights);
+ }
+
+ return new JunkDetector(version, tables, calibrations,
+ blockTables, blockCalibrations, blockN,
+ controlCalibrations, classifierWeights, blockIndex,
+ scriptTransitionTable, scriptTransitionCalibration,
+ scriptBucketIndex, numScriptBuckets);
+ }
+ }
+
+ private static float[] readFloatTable(DataInputStream dis, int size) throws IOException {
+ byte[] tableBytes = dis.readNBytes(size * 4);
+ float[] table = new float[size];
+ ByteBuffer buf = ByteBuffer.wrap(tableBytes).order(ByteOrder.BIG_ENDIAN);
+ buf.asFloatBuffer().get(table);
+ return table;
+ }
+
+ /**
+ * Builds the stable ordered mapping from {@link Character.UnicodeBlock} to index.
+ * This must produce the same ordering as {@link TrainJunkModel#buildBlockIndex()}.
+ * Used for v2/v3/v4 models only; v5+ models store block names in the file.
+ */
+ static Map buildBlockIndex() {
+ LinkedHashMap index = new LinkedHashMap<>();
+ for (int cp = 0; cp <= 0x10FFFF; cp++) {
+ Character.UnicodeBlock b = Character.UnicodeBlock.of(cp);
+ if (b != null) index.putIfAbsent(b, index.size());
+ }
+ return Collections.unmodifiableMap(index);
+ }
+
+ /**
+ * Builds a block index from an ordered array of block names stored in a v5+ model.
+ * Resolves each name via {@link Character.UnicodeBlock#forName(String)}.
+ * Throws {@link IOException} if any name is not recognised by the current JVM —
+ * this means the model was trained on a newer JVM; retrain on the minimum
+ * supported JVM (Java 17) to produce a compatible model.
+ *
+ * @param blockNames ordered array of block names (index = position in block table)
+ * @return unmodifiable map from UnicodeBlock to table index
+ */
+ static Map buildBlockIndexFromNames(String[] blockNames)
+ throws IOException {
+ Map index = new HashMap<>(blockNames.length * 2);
+ for (int i = 0; i < blockNames.length; i++) {
+ try {
+ Character.UnicodeBlock b = Character.UnicodeBlock.forName(blockNames[i]);
+ index.put(b, i);
+ } catch (IllegalArgumentException e) {
+ throw new IOException("Unicode block not known to this JVM: " + blockNames[i]
+ + ". Model was trained on a newer JVM; retrain on Java 17.", e);
+ }
+ }
+ return Collections.unmodifiableMap(index);
+ }
+
+ // -----------------------------------------------------------------------
+ // TextQualityDetector implementation
+ // -----------------------------------------------------------------------
+
+ /**
+ * {@inheritDoc}
+ *
+ * The text is split into contiguous runs of the same Unicode script.
+ * Each run is scored against its own script model. Logits are combined
+ * as a byte-count-weighted average, so mixed-script text (e.g. half
+ * LATIN, half HAN) is scored fairly without arbitrarily picking one script.
+ * COMMON, INHERITED, and UNKNOWN codepoints (spaces, punctuation, digits)
+ * are attached to the preceding script run.
+ */
+ @Override
+ public TextQualityScore score(String text) {
+ if (text == null || text.isEmpty()) {
+ return unknownScore("UNKNOWN");
+ }
+ return scoreText(text);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ *
Each candidate is scored independently via {@link #score(String)}.
+ * The candidate with the higher score wins.
+ *
+ *
An UNKNOWN score (script not in model) is treated as neutral (0) rather
+ * than {@code -∞}. This prevents a garbled-but-recognisable decoding from
+ * beating a correct decoding whose script happens to be unknown to the model —
+ * for example, a pure-katakana zip entry name decoded as Shift-JIS (UNKNOWN)
+ * vs. the same bytes decoded as UTF-8 (garbled LATIN, negative z-score).
+ */
+ @Override
+ public TextQualityComparison compare(String labelA, String candidateA,
+ String labelB, String candidateB) {
+ TextQualityScore scoreA = score(candidateA);
+ TextQualityScore scoreB = score(candidateB);
+
+ // UNKNOWN = "no evidence" = 0, not -∞. A text whose script is not in the
+ // model is assumed to be neutral, not junk.
+ float zA = scoreA.isUnknown() ? 0f : scoreA.getZScore();
+ float zB = scoreB.isUnknown() ? 0f : scoreB.getZScore();
+
+ String winner = zA >= zB ? "A" : "B";
+ float delta = Math.abs(zA - zB);
+
+ return new TextQualityComparison(winner, delta, scoreA, scoreB, labelA, labelB);
+ }
+
+ /** Returns the set of script names this model knows about. */
+ public Set knownScripts() {
+ return tables.keySet();
+ }
+
+ /** Returns the version of the loaded model (1, 2, or 3). */
+ public int getModelVersion() {
+ return modelVersion;
+ }
+
+ // -----------------------------------------------------------------------
+ // Internal scoring
+ // -----------------------------------------------------------------------
+
+ private TextQualityScore scoreText(String text) {
+ List runs = buildScriptRuns(text);
+
+ // Global z4: script-transition feature over the whole input string.
+ // Computed before chunking because it captures document-level script mixing.
+ float z4 = computeScriptTransitionZ(text);
+
+ // Score each run against its own model; aggregate weighted by byte count.
+ float totalBytes = 0;
+ float weightedLogit = 0;
+ String dominantScript = null;
+ int maxBytes = 0;
+ int totalBigramCount = 0;
+ float[] dominantCal1 = null;
+
+ for (ScriptRun run : runs) {
+ if (!tables.containsKey(run.script)) {
+ continue; // skip scripts not in model; treat as neutral, not junk
+ }
+ byte[] runUtf8 = run.text.getBytes(StandardCharsets.UTF_8);
+ if (runUtf8.length < 2) {
+ continue; // too short to score
+ }
+ float logit = scoreChunk(runUtf8, run.text, run.script, z4);
+ int n = runUtf8.length;
+ weightedLogit += logit * n;
+ totalBytes += n;
+ totalBigramCount += n - 1;
+ if (n > maxBytes) {
+ maxBytes = n;
+ dominantScript = run.script;
+ dominantCal1 = calibrations.get(run.script);
+ }
+ }
+
+ if (totalBytes == 0 || dominantScript == null) {
+ String label = runs.isEmpty() ? "LATIN" : runs.get(0).script;
+ return unknownScore(label);
+ }
+
+ float zScore = weightedLogit / totalBytes;
+
+ float uncertainty = (dominantCal1 != null && totalBigramCount > 0)
+ ? (float) (1.96 * dominantCal1[1] / Math.sqrt(totalBigramCount)) : 0f;
+ float ciLow = zScore - uncertainty;
+ float ciHigh = zScore + uncertainty;
+ float pClean = (float) (1.0 / (1.0 + Math.exp(-zScore)));
+
+ return new TextQualityScore(zScore, pClean, ciLow, ciHigh, dominantScript);
+ }
+
+ /**
+ * Scores a single script-homogeneous chunk and returns its logit.
+ * Positive = clean, negative = junk. Returns 0 (neutral) if the chunk
+ * has no model or is too short.
+ */
+ private float scoreChunk(byte[] utf8, String text, String script, float z4) {
+ float[] bigramTable = tables.get(script);
+ if (bigramTable == null || utf8.length < 2) {
+ return 0f;
+ }
+
+ // Feature 1: byte-bigram mean log-prob
+ double bigramSum = 0;
+ int bigramCount = 0;
+ for (int i = 0; i + 1 < utf8.length; i++) {
+ bigramSum += bigramTable[((utf8[i] & 0xFF) << 8) | (utf8[i + 1] & 0xFF)];
+ bigramCount++;
+ }
+ float meanBigramLogProb = (float) (bigramSum / bigramCount);
+ float[] cal1 = calibrations.get(script);
+ float z1 = (meanBigramLogProb - cal1[0]) / cal1[1];
+
+ // Feature 2: named-block transition mean log-prob
+ float z2 = 0f;
+ float[] blockTable = blockTables.get(script);
+ if (blockTable != null) {
+ int nullId = blockN - 1;
+ int prev = -1;
+ double blockSum = 0;
+ int blockCount = 0;
+ for (int i = 0; i < text.length(); ) {
+ int cp = text.codePointAt(i);
+ Character.UnicodeBlock b = Character.UnicodeBlock.of(cp);
+ int blockId = b != null ? blockIndex.getOrDefault(b, nullId) : nullId;
+ if (prev >= 0) {
+ blockSum += blockTable[prev * blockN + blockId];
+ blockCount++;
+ }
+ prev = blockId;
+ i += Character.charCount(cp);
+ }
+ if (blockCount > 0) {
+ float meanBlockLogProb = (float) (blockSum / blockCount);
+ float[] cal2 = blockCalibrations.get(script);
+ z2 = cal2 != null ? (meanBlockLogProb - cal2[0]) / cal2[1] : 0f;
+ }
+ }
+
+ // Feature 3: control-byte fraction (stored as −fraction, so higher = cleaner)
+ long controlCount = 0;
+ for (byte b : utf8) {
+ if (isControlByte(b & 0xFF)) controlCount++;
+ }
+ float controlScore = -(float) controlCount / utf8.length;
+ float[] cal3 = controlCalibrations.get(script);
+ float z3 = cal3 != null ? (controlScore - cal3[0]) / cal3[1] : 0f;
+
+ // Per-script linear classifier: w1*z1 + w2*z2 + w3*z3 + w4*z4 + bias
+ float[] cw = classifierWeights.get(script);
+ if (cw != null) {
+ int nFeat = cw.length - 1; // bias is last
+ float logit = cw[nFeat]; // bias
+ if (nFeat >= 1) logit += cw[0] * z1;
+ if (nFeat >= 2) logit += cw[1] * z2;
+ if (nFeat >= 3) logit += cw[2] * z3;
+ if (nFeat >= 4) logit += cw[3] * z4;
+ return logit;
+ }
+ return (z1 + z2 + z3 + z4) / 4.0f; // fallback: equal weight
+ }
+
+ /**
+ * Computes the global script-transition z-score for the whole input string.
+ * Uses raw {@link Character.UnicodeScript} values — NOT {@link #SCRIPT_MODEL_FALLBACK} —
+ * so that HIRAGANA, KATAKANA, and HAN remain distinct, preserving the
+ * characteristic script-mixing pattern of Japanese text.
+ *
+ * Returns 0 if the string has fewer than two non-neutral codepoints.
+ */
+ private float computeScriptTransitionZ(String text) {
+ if (scriptTransitionTable == null || scriptBucketIndex == null
+ || scriptTransitionCalibration == null || numScriptBuckets == 0) {
+ return 0f;
+ }
+ int otherBucket = numScriptBuckets - 1;
+ int prev = -1;
+ double sum = 0;
+ int count = 0;
+ for (int i = 0; i < text.length(); ) {
+ int cp = text.codePointAt(i);
+ i += Character.charCount(cp);
+ Character.UnicodeScript s = Character.UnicodeScript.of(cp);
+ if (s == Character.UnicodeScript.COMMON
+ || s == Character.UnicodeScript.INHERITED
+ || s == Character.UnicodeScript.UNKNOWN) {
+ continue;
+ }
+ int bucket = scriptBucketIndex.getOrDefault(s.name(), otherBucket);
+ if (prev >= 0) {
+ sum += scriptTransitionTable[prev * numScriptBuckets + bucket];
+ count++;
+ }
+ prev = bucket;
+ }
+ if (count == 0) {
+ return 0f;
+ }
+ float mean = (float) (sum / count);
+ return (mean - scriptTransitionCalibration[0]) / scriptTransitionCalibration[1];
+ }
+
+ /**
+ * Splits text into maximal runs of the same Unicode script.
+ * COMMON, INHERITED, and UNKNOWN codepoints (spaces, punctuation, digits)
+ * are attached to the preceding script run so that inter-word bigrams are
+ * preserved within each run. Any leading COMMON characters are prepended
+ * to the first non-COMMON run.
+ */
+ private List buildScriptRuns(String text) {
+ List runs = new ArrayList<>();
+ String currentScript = null;
+ StringBuilder currentText = new StringBuilder();
+ StringBuilder leadingCommon = new StringBuilder();
+
+ for (int i = 0; i < text.length(); ) {
+ int cp = text.codePointAt(i);
+ i += Character.charCount(cp);
+
+ Character.UnicodeScript s = Character.UnicodeScript.of(cp);
+ if (s == Character.UnicodeScript.COMMON
+ || s == Character.UnicodeScript.INHERITED
+ || s == Character.UnicodeScript.UNKNOWN) {
+ if (currentScript != null) {
+ currentText.appendCodePoint(cp);
+ } else {
+ leadingCommon.appendCodePoint(cp);
+ }
+ continue;
+ }
+
+ String scriptName = SCRIPT_MODEL_FALLBACK.getOrDefault(s.name(), s.name());
+
+ if (!scriptName.equals(currentScript)) {
+ if (currentScript != null && currentText.length() > 0) {
+ runs.add(new ScriptRun(currentScript, currentText.toString()));
+ }
+ currentScript = scriptName;
+ currentText = new StringBuilder();
+ if (leadingCommon.length() > 0) {
+ currentText.append(leadingCommon);
+ leadingCommon.setLength(0);
+ }
+ }
+ currentText.appendCodePoint(cp);
+ }
+
+ if (currentScript != null && currentText.length() > 0) {
+ runs.add(new ScriptRun(currentScript, currentText.toString()));
+ }
+ return runs;
+ }
+
+ private static final class ScriptRun {
+ final String script;
+ final String text;
+ ScriptRun(String script, String text) {
+ this.script = script;
+ this.text = text;
+ }
+ }
+
+ /**
+ * Returns true if the byte value is a control character that should not appear
+ * in natural-language UTF-8 text: {@code [0x01–0x08, 0x0B, 0x0C, 0x0E–0x1F, 0x7F]}.
+ *
+ * Excluded: 0x00 (null), 0x09 (tab), 0x0A (newline), 0x0D (carriage return)
+ * — all appear legitimately in text.
+ */
+ static boolean isControlByte(int b) {
+ return (b >= 0x01 && b <= 0x08)
+ || b == 0x0B || b == 0x0C
+ || (b >= 0x0E && b <= 0x1F)
+ || b == 0x7F;
+ }
+
+ private static TextQualityScore unknownScore(String script) {
+ return new TextQualityScore(TextQualityScore.UNKNOWN, Float.NaN,
+ Float.NaN, Float.NaN, script);
+ }
+
+ /**
+ * Maps Unicode scripts that share a trained model with a related script.
+ * Japanese kana (HIRAGANA, KATAKANA) map to HAN because the HAN model is
+ * trained on mixed Japanese text containing all three writing systems, so
+ * its byte-bigram and block-transition tables cover kana sequences.
+ */
+ private static final Map SCRIPT_MODEL_FALLBACK = Map.of(
+ "HIRAGANA", "HAN",
+ "KATAKANA", "HAN"
+ );
+
+ /**
+ * Detects the dominant Unicode script of the given text by histogramming
+ * {@link Character.UnicodeScript} over all codepoints, excluding COMMON,
+ * INHERITED, and UNKNOWN pseudo-scripts. Returns "LATIN" for ASCII-only text.
+ *
+ * Script names are mapped through {@link #SCRIPT_MODEL_FALLBACK} so that
+ * scripts without dedicated models fall back to a related trained model
+ * (e.g. KATAKANA and HIRAGANA both use the HAN model).
+ */
+ static String detectDominantScript(String text) {
+ Map counts = new HashMap<>();
+ for (int i = 0; i < text.length(); ) {
+ int cp = text.codePointAt(i);
+ Character.UnicodeScript s = Character.UnicodeScript.of(cp);
+ if (s != Character.UnicodeScript.COMMON
+ && s != Character.UnicodeScript.INHERITED
+ && s != Character.UnicodeScript.UNKNOWN) {
+ counts.merge(s, 1, Integer::sum);
+ }
+ i += Character.charCount(cp);
+ }
+ if (counts.isEmpty()) {
+ return "LATIN";
+ }
+ String name = counts.entrySet().stream()
+ .max(Map.Entry.comparingByValue())
+ .map(e -> e.getKey().name())
+ .orElse("LATIN");
+ return SCRIPT_MODEL_FALLBACK.getOrDefault(name, name);
+ }
+}
diff --git a/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/tools/BuildJunkTrainingData.java b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/tools/BuildJunkTrainingData.java
new file mode 100644
index 00000000000..27a5436d5e4
--- /dev/null
+++ b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/tools/BuildJunkTrainingData.java
@@ -0,0 +1,646 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.ml.junkdetect.tools;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.text.Normalizer;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.TreeMap;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * Builds per-script positive training data for the junk detector from MADLAD-400
+ * and Wikipedia sentence files.
+ *
+ * Script groups are derived entirely from the data: for each language directory
+ * the dominant Unicode script is detected by histogramming {@link Character.UnicodeScript}
+ * over a sample of sentences (COMMON, INHERITED, and UNKNOWN pseudo-scripts excluded).
+ * Languages that share the same dominant script are pooled. No script groups are
+ * hardcoded.
+ *
+ *
The total byte budget is distributed across script groups proportionally to
+ * each group's empirical byte-bigram entropy, measured from a small sample.
+ * Scripts with high entropy (e.g. CJK, which has thousands of distinct 3-byte
+ * codepoints) receive a proportionally larger allocation than low-entropy scripts
+ * (e.g. Arabic, whose UTF-8 high bytes cluster in a narrow 0xD8-0xDB range).
+ * This ensures every script's bigram table is estimated with comparable statistical
+ * quality regardless of character-set size.
+ *
+ *
Within each script group the byte budget is distributed evenly across its
+ * member languages, ensuring diversity (no single language dominates).
+ *
+ *
Input format ({@code sentences_madlad.txt} and {@code sentences_wikipedia.txt}):
+ * {@code lineNum TAB text}, UTF-8. MADLAD records contain literal {@code \n} escape
+ * sequences as sub-sentence separators (full scraped documents); Wikipedia records
+ * are individual sentences. Both are split/cleaned to sentence-level strings.
+ *
+ *
Output:
+ *
+ * output-dir/
+ * {script}.train.gz — 80% split, one NFC-normalised sentence per line
+ * {script}.dev.gz — 10% split, used for calibration (mu/sigma)
+ * {script}.test.gz — 10% split, held out for final evaluation only
+ * manifest.tsv — per-script stats: entropy, budget, bytes written, languages
+ *
+ *
+ * Usage:
+ *
+ * java BuildJunkTrainingData \
+ * --data-dir ~/datasets/madlad/data \
+ * --output-dir ~/datasets/madlad/junkdetect \
+ * [--total-budget-bytes 50000000]
+ *
+ */
+public class BuildJunkTrainingData {
+
+ // -----------------------------------------------------------------------
+ // Defaults
+ // -----------------------------------------------------------------------
+
+ /** Lines read per language to determine dominant script. */
+ private static final int DEFAULT_SCRIPT_SAMPLE_LINES = 2_000;
+
+ /**
+ * UTF-8 bytes loaded per script group for entropy estimation.
+ * Budget is spread evenly across languages in the group.
+ * 200KB is enough to observe the bigram distribution reliably.
+ */
+ private static final long ENTROPY_SAMPLE_BYTES = 200_000L;
+
+ /**
+ * Total UTF-8 byte budget across all script groups. Divided proportionally
+ * by bigram entropy after the sampling phase. 50MB gives ~1–3MB per script
+ * on average across 34 groups; scale up for production runs.
+ */
+ private static final long DEFAULT_TOTAL_BUDGET_BYTES = 50_000_000L;
+
+ /** Minimum UTF-8 byte length for a sentence to pass the quality filter. */
+ private static final int DEFAULT_MIN_BYTES = 50;
+
+ /** Maximum fraction of codepoints that may be ASCII punctuation/digits. */
+ private static final double DEFAULT_MAX_PUNC_FRAC = 0.30;
+
+ /** Fraction of sentences written to each split (train / dev / test = 80/10/10). */
+ private static final double TRAIN_FRAC = 0.80;
+ private static final double DEV_FRAC = 0.10;
+ // remaining (1 - TRAIN_FRAC - DEV_FRAC) goes to the test split
+
+ /**
+ * Minimum number of sentences that must land in the dev split for a script to be
+ * included in the model. Scripts below this floor have too few samples to reliably
+ * estimate calibration statistics (mu/sigma), which produces noisy z-scores and
+ * inflated false positive rates. With DEV_FRAC=0.10 the effective minimum total
+ * sentence count is minDevSentences / DEV_FRAC (default: 5,000 total sentences).
+ */
+ private static final int DEFAULT_MIN_DEV_SENTENCES = 500;
+
+ // -----------------------------------------------------------------------
+ // Entry point
+ // -----------------------------------------------------------------------
+
+ public static void main(String[] args) throws IOException {
+ Path dataDir = Paths.get(System.getProperty("user.home"), "datasets", "madlad", "data");
+ Path outputDir = Paths.get(System.getProperty("user.home"), "datasets", "madlad", "junkdetect");
+ int scriptSampleLines = DEFAULT_SCRIPT_SAMPLE_LINES;
+ long totalBudgetBytes = DEFAULT_TOTAL_BUDGET_BYTES;
+ int minBytes = DEFAULT_MIN_BYTES;
+ double maxPuncFrac = DEFAULT_MAX_PUNC_FRAC;
+ int seed = 42;
+ boolean dryRun = false;
+ int minDevSentences = DEFAULT_MIN_DEV_SENTENCES;
+
+ for (int i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case "--data-dir":
+ dataDir = Paths.get(args[++i]);
+ break;
+ case "--output-dir":
+ outputDir = Paths.get(args[++i]);
+ break;
+ case "--script-sample-lines":
+ scriptSampleLines = Integer.parseInt(args[++i]);
+ break;
+ case "--total-budget-bytes":
+ totalBudgetBytes = Long.parseLong(args[++i]);
+ break;
+ case "--min-bytes":
+ minBytes = Integer.parseInt(args[++i]);
+ break;
+ case "--max-punc-frac":
+ maxPuncFrac = Double.parseDouble(args[++i]);
+ break;
+ case "--seed":
+ seed = Integer.parseInt(args[++i]);
+ break;
+ case "--min-dev-sentences":
+ minDevSentences = Integer.parseInt(args[++i]);
+ break;
+ case "--dry-run":
+ dryRun = true;
+ break;
+ default:
+ System.err.println("Unknown argument: " + args[i]);
+ printUsage();
+ System.exit(1);
+ }
+ }
+
+ System.out.println("=== BuildJunkTrainingData ===");
+ System.out.println(" data-dir: " + dataDir);
+ System.out.println(" output-dir: " + outputDir);
+ System.out.printf( " total-budget-bytes: %,d (%.1f MB)%n",
+ totalBudgetBytes, totalBudgetBytes / 1_000_000.0);
+ System.out.printf( " min-bytes: %d%n", minBytes);
+ System.out.printf( " max-punc-frac: %.2f%n", maxPuncFrac);
+ System.out.printf( " min-dev-sentences: %d (min total ≈ %d)%n",
+ minDevSentences, (int)(minDevSentences / DEV_FRAC));
+ System.out.println(" dry-run: " + dryRun);
+
+ if (!Files.isDirectory(dataDir)) {
+ System.err.println("ERROR: data-dir not found: " + dataDir);
+ System.exit(1);
+ }
+
+ // -----------------------------------------------------------------------
+ // Phase 1: Detect dominant script per language, group languages
+ // -----------------------------------------------------------------------
+
+ System.out.println("\n--- Phase 1: Detecting dominant script per language ---");
+
+ Map> scriptGroups = new TreeMap<>();
+ Map langToScript = new LinkedHashMap<>();
+
+ try (var dirStream = Files.list(dataDir)) {
+ List langDirs = dirStream.filter(Files::isDirectory).sorted().toList();
+ for (Path langDir : langDirs) {
+ String lang = langDir.getFileName().toString();
+ String script = detectDominantScript(langDir, scriptSampleLines);
+ langToScript.put(lang, script);
+ scriptGroups.computeIfAbsent(script, k -> new ArrayList<>()).add(langDir);
+ System.out.printf(" %-12s → %s%n", lang, script);
+ }
+ }
+ System.out.printf("%n → %d languages, %d script groups%n",
+ langToScript.size(), scriptGroups.size());
+
+ // -----------------------------------------------------------------------
+ // Phase 2: Load small sample per script, compute byte-bigram entropy
+ // -----------------------------------------------------------------------
+
+ System.out.println("\n--- Phase 2: Estimating byte-bigram entropy per script ---");
+
+ Map scriptEntropy = new TreeMap<>();
+ for (Map.Entry> entry : scriptGroups.entrySet()) {
+ String script = entry.getKey();
+ List langDirs = entry.getValue();
+
+ long perLangSampleBytes = Math.max(ENTROPY_SAMPLE_BYTES / langDirs.size(), 2_000L);
+ List sample = new ArrayList<>();
+ for (Path langDir : langDirs) {
+ loadSentences(langDir, perLangSampleBytes, minBytes, maxPuncFrac, sample);
+ }
+
+ double entropy = computeBigramEntropy(sample);
+ scriptEntropy.put(script, entropy);
+ System.out.printf(" %-20s H=%.3f bits (%d sentences)%n",
+ script, entropy, sample.size());
+ }
+
+ // -----------------------------------------------------------------------
+ // Phase 3: Allocate byte budget proportional to entropy
+ // -----------------------------------------------------------------------
+
+ System.out.println("\n--- Phase 3: Allocating byte budget ---");
+
+ double totalEntropy = scriptEntropy.values().stream()
+ .mapToDouble(Double::doubleValue).sum();
+
+ Map scriptBudget = new TreeMap<>();
+ for (Map.Entry e : scriptEntropy.entrySet()) {
+ long budget = (long) (totalBudgetBytes * e.getValue() / totalEntropy);
+ scriptBudget.put(e.getKey(), budget);
+ System.out.printf(" %-20s H=%.3f → %,d bytes (%.1f MB)%n",
+ e.getKey(), e.getValue(), budget, budget / 1_000_000.0);
+ }
+
+ if (dryRun) {
+ System.out.println("\nDry-run: stopping before writing files.");
+ return;
+ }
+
+ // -----------------------------------------------------------------------
+ // Phase 4: Collect data, write train/dev splits
+ // -----------------------------------------------------------------------
+
+ Files.createDirectories(outputDir);
+ System.out.println("\n--- Phase 4a: Round 1 — collecting with initial budgets ---");
+
+ Random rng = new Random(seed);
+
+ // Collect sentences and actual byte counts for every script
+ Map> allSentences = new LinkedHashMap<>();
+ Map actualBytes = new LinkedHashMap<>();
+
+ for (Map.Entry budgetEntry : scriptBudget.entrySet()) {
+ String script = budgetEntry.getKey();
+ long budget = budgetEntry.getValue();
+ List langDirs = scriptGroups.get(script);
+
+ long perLangBytes = Math.max(budget / langDirs.size(), 1L);
+ List sentences = new ArrayList<>();
+ long totalBytesLoaded = 0;
+
+ for (Path langDir : langDirs) {
+ long remaining = budget - totalBytesLoaded;
+ if (remaining <= 0) break;
+ long langBytes = loadSentences(langDir,
+ Math.min(perLangBytes, remaining),
+ minBytes, maxPuncFrac, sentences);
+ totalBytesLoaded += langBytes;
+ if (langBytes > 0) {
+ System.out.printf(" %-12s %-20s +%,d bytes%n",
+ script, langDir.getFileName(), langBytes);
+ }
+ }
+ allSentences.put(script, sentences);
+ actualBytes.put(script, totalBytesLoaded);
+ }
+
+ // Compute surplus bytes from data-starved scripts (< 90% of budget used)
+ long surplus = 0;
+ for (Map.Entry e : scriptBudget.entrySet()) {
+ long budget = e.getValue();
+ long actual = actualBytes.getOrDefault(e.getKey(), 0L);
+ if (actual < budget * 0.9) {
+ surplus += (budget - actual);
+ }
+ }
+
+ // Round 2: redistribute surplus to saturated scripts proportional to entropy
+ if (surplus > 0) {
+ System.out.printf(
+ "\n--- Phase 4b: Redistributing %,d surplus bytes (%.1f MB) ---\n",
+ surplus, surplus / 1_000_000.0);
+
+ double saturatedEntropy = scriptBudget.entrySet().stream()
+ .filter(e -> actualBytes.getOrDefault(e.getKey(), 0L) >= e.getValue() * 0.9)
+ .mapToDouble(e -> scriptEntropy.getOrDefault(e.getKey(), 0.0))
+ .sum();
+
+ for (Map.Entry budgetEntry : scriptBudget.entrySet()) {
+ String script = budgetEntry.getKey();
+ long budget = budgetEntry.getValue();
+ long actual = actualBytes.getOrDefault(script, 0L);
+ if (actual < budget * 0.9) continue; // data-starved — skip
+
+ long extra = (long) (surplus
+ * scriptEntropy.getOrDefault(script, 0.0) / saturatedEntropy);
+ if (extra <= 0) continue;
+
+ long newBudget = budget + extra;
+ List langDirs = scriptGroups.get(script);
+ long perLangBytes = Math.max(newBudget / langDirs.size(), 1L);
+
+ List sentences = new ArrayList<>();
+ long totalBytesLoaded = 0;
+ for (Path langDir : langDirs) {
+ long remaining = newBudget - totalBytesLoaded;
+ if (remaining <= 0) break;
+ long langBytes = loadSentences(langDir,
+ Math.min(perLangBytes, remaining),
+ minBytes, maxPuncFrac, sentences);
+ totalBytesLoaded += langBytes;
+ }
+ if (!sentences.isEmpty()) {
+ allSentences.put(script, sentences);
+ actualBytes.put(script, totalBytesLoaded);
+ System.out.printf(" %-20s +%,d extra → %,d total bytes, %,d sentences%n",
+ script, extra, totalBytesLoaded, sentences.size());
+ }
+ }
+ }
+
+ // Write split files
+ System.out.println("\n--- Phase 4c: Writing train/dev/test splits ---");
+
+ // manifest columns: script, entropy, budget_bytes, written_bytes, sentences, train_bytes, languages
+ Map manifestStats = new TreeMap<>();
+
+ for (Map.Entry> e : allSentences.entrySet()) {
+ String script = e.getKey();
+ List sentences = e.getValue();
+
+ int expectedDevSize = (int) (sentences.size() * DEV_FRAC);
+ if (sentences.isEmpty() || expectedDevSize < minDevSentences) {
+ System.out.printf(
+ " SKIP %-20s — %,d sentences → dev=%d < min-dev-sentences=%d%n",
+ script, sentences.size(), expectedDevSize, minDevSentences);
+ manifestStats.put(script, new long[]{0, 0, 0, 0, 0});
+ continue;
+ }
+
+ Collections.shuffle(sentences, rng);
+
+ int nTrain = (int) (sentences.size() * TRAIN_FRAC);
+ int nDev = (int) (sentences.size() * DEV_FRAC);
+ List train = sentences.subList(0, nTrain);
+ List dev = sentences.subList(nTrain, nTrain + nDev);
+ List test = sentences.subList(nTrain + nDev, sentences.size());
+
+ String baseName = script.toLowerCase();
+ writeGzipped(outputDir.resolve(baseName + ".train.gz"), train);
+ writeGzipped(outputDir.resolve(baseName + ".dev.gz"), dev);
+ writeGzipped(outputDir.resolve(baseName + ".test.gz"), test);
+
+ long totalBytesLoaded = actualBytes.getOrDefault(script, 0L);
+ manifestStats.put(script,
+ new long[]{totalBytesLoaded, sentences.size(), nTrain, nDev, test.size()});
+ System.out.printf(
+ " WROTE %-12s — %,d bytes, %,d sentences (train=%,d dev=%,d test=%,d)%n",
+ script, totalBytesLoaded, sentences.size(),
+ nTrain, nDev, test.size());
+ }
+
+ // -----------------------------------------------------------------------
+ // Phase 5: Write manifest
+ // -----------------------------------------------------------------------
+
+ Path manifest = outputDir.resolve("manifest.tsv");
+ try (BufferedWriter w = Files.newBufferedWriter(manifest, StandardCharsets.UTF_8)) {
+ w.write("script\tentropy_bits\tbudget_bytes\twritten_bytes\tsentences"
+ + "\ttrain_sentences\tdev_sentences\ttest_sentences\tlanguages\n");
+ for (Map.Entry e : manifestStats.entrySet()) {
+ String script = e.getKey();
+ long[] stats = e.getValue();
+ double entropy = scriptEntropy.getOrDefault(script, 0.0);
+ long budget = scriptBudget.getOrDefault(script, 0L);
+ String langs = scriptGroups.get(script).stream()
+ .map(p -> p.getFileName().toString())
+ .reduce((a, b) -> a + "," + b).orElse("");
+ w.write(String.format("%s\t%.3f\t%d\t%d\t%d\t%d\t%d\t%d\t%s%n",
+ script, entropy, budget,
+ stats[0], stats[1], stats[2], stats[3], stats[4], langs));
+ }
+ }
+
+ System.out.println("\nWrote manifest: " + manifest);
+ System.out.println("Done.");
+ }
+
+ // -----------------------------------------------------------------------
+ // Script detection
+ // -----------------------------------------------------------------------
+
+ /**
+ * Detects the dominant Unicode script for a language by histogramming
+ * {@link Character.UnicodeScript} over a sample of its sentences.
+ * COMMON, INHERITED, and UNKNOWN pseudo-scripts are excluded from voting.
+ * Returns "COMMON" if no script reaches at least 1% of codepoints.
+ */
+ static String detectDominantScript(Path langDir, int sampleLines) {
+ Map counts = new HashMap<>();
+ long total = 0;
+
+ outer:
+ for (String filename : new String[]{"sentences_wikipedia.txt", "sentences_madlad.txt"}) {
+ Path file = langDir.resolve(filename);
+ if (!Files.exists(file)) {
+ continue;
+ }
+ try (BufferedReader r = new BufferedReader(
+ new InputStreamReader(Files.newInputStream(file), StandardCharsets.UTF_8))) {
+ String line;
+ int linesRead = 0;
+ while ((line = r.readLine()) != null && linesRead < sampleLines) {
+ String text = extractText(line);
+ for (int i = 0; i < text.length(); ) {
+ int cp = text.codePointAt(i);
+ Character.UnicodeScript s = Character.UnicodeScript.of(cp);
+ if (s != Character.UnicodeScript.COMMON
+ && s != Character.UnicodeScript.INHERITED
+ && s != Character.UnicodeScript.UNKNOWN) {
+ counts.merge(s, 1L, Long::sum);
+ total++;
+ }
+ i += Character.charCount(cp);
+ }
+ linesRead++;
+ }
+ } catch (IOException e) {
+ // Skip unreadable file; report COMMON if nothing else succeeds
+ }
+ if (total >= sampleLines * 10L) {
+ break outer; // sufficient signal
+ }
+ }
+
+ if (total == 0) {
+ return "COMMON";
+ }
+
+ // Plurality with a 1% floor to suppress spurious Latin wins on mixed text
+ Character.UnicodeScript best = Character.UnicodeScript.COMMON;
+ long bestCount = total / 100;
+ for (Map.Entry e : counts.entrySet()) {
+ if (e.getValue() > bestCount) {
+ bestCount = e.getValue();
+ best = e.getKey();
+ }
+ }
+ return best.name();
+ }
+
+ // -----------------------------------------------------------------------
+ // Entropy estimation
+ // -----------------------------------------------------------------------
+
+ /**
+ * Computes the empirical byte-bigram Shannon entropy (bits) of a list of
+ * UTF-8 sentences.
+ *
+ * All 256×256 = 65,536 consecutive byte pairs are counted; entropy is
+ * {@code -sum p(a,b) * log2(p(a,b))} over pairs with non-zero count.
+ * Maximum theoretical value is 16 bits (all pairs equally likely).
+ * Typical ranges: Latin ~8–11 bits, Arabic ~9–12, CJK ~13–15.
+ */
+ static double computeBigramEntropy(List sentences) {
+ long[] counts = new long[65536];
+ long total = 0;
+ for (String s : sentences) {
+ byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
+ for (int i = 0; i + 1 < bytes.length; i++) {
+ counts[((bytes[i] & 0xFF) << 8) | (bytes[i + 1] & 0xFF)]++;
+ total++;
+ }
+ }
+ if (total == 0) {
+ return 0.0;
+ }
+ double entropy = 0.0;
+ for (long c : counts) {
+ if (c > 0) {
+ double p = (double) c / total;
+ entropy -= p * (Math.log(p) / Math.log(2.0));
+ }
+ }
+ return entropy;
+ }
+
+ // -----------------------------------------------------------------------
+ // Sentence loading and filtering
+ // -----------------------------------------------------------------------
+
+ /**
+ * Loads filtered, NFC-normalised sentences from {@code langDir} until
+ * {@code maxBytes} UTF-8 bytes have been accumulated, and appends them
+ * to {@code result}.
+ *
+ * Reads {@code sentences_wikipedia.txt} before {@code sentences_madlad.txt}.
+ * MADLAD records contain literal {@code \n} escape sequences as sub-sentence
+ * separators (full scraped documents) and are split accordingly.
+ *
+ * @return total UTF-8 bytes of accepted sentences appended
+ */
+ static long loadSentences(Path langDir, long maxBytes, int minBytes,
+ double maxPuncFrac, List result) {
+ long bytesLoaded = 0;
+ for (String filename : new String[]{"sentences_wikipedia.txt", "sentences_madlad.txt"}) {
+ if (bytesLoaded >= maxBytes) {
+ break;
+ }
+ Path file = langDir.resolve(filename);
+ if (!Files.exists(file)) {
+ continue;
+ }
+ try (BufferedReader r = new BufferedReader(
+ new InputStreamReader(Files.newInputStream(file), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null && bytesLoaded < maxBytes) {
+ String raw = extractText(line);
+ for (String part : raw.split("\\\\n")) {
+ String text = part.replace("\\r", "")
+ .replace("\\t", " ")
+ .strip()
+ .replaceAll("\\s+", " ");
+ if (text.isEmpty()) {
+ continue;
+ }
+ String filtered = filterSentence(text, minBytes, maxPuncFrac);
+ if (filtered != null) {
+ int sentBytes = filtered.getBytes(StandardCharsets.UTF_8).length;
+ result.add(filtered);
+ bytesLoaded += sentBytes;
+ if (bytesLoaded >= maxBytes) {
+ break;
+ }
+ }
+ }
+ }
+ } catch (IOException e) {
+ System.err.println("WARNING: could not read " + file + ": " + e.getMessage());
+ }
+ }
+ return bytesLoaded;
+ }
+
+ /**
+ * Applies quality filters to a single sentence and NFC-normalises it.
+ *
+ * @return the normalised sentence, or {@code null} if it should be discarded
+ */
+ static String filterSentence(String text, int minBytes, double maxPuncFrac) {
+ if (text.indexOf('\uFFFD') >= 0) {
+ return null;
+ }
+ text = Normalizer.normalize(text, Normalizer.Form.NFC);
+ if (text.getBytes(StandardCharsets.UTF_8).length < minBytes) {
+ return null;
+ }
+ int cpCount = 0;
+ int puncCount = 0;
+ for (int i = 0; i < text.length(); ) {
+ int cp = text.codePointAt(i);
+ cpCount++;
+ if (cp >= 0x21 && cp <= 0x7E && !Character.isLetter(cp)) {
+ puncCount++;
+ }
+ i += Character.charCount(cp);
+ }
+ if (cpCount > 0 && (double) puncCount / cpCount > maxPuncFrac) {
+ return null;
+ }
+ return text;
+ }
+
+ // -----------------------------------------------------------------------
+ // I/O helpers
+ // -----------------------------------------------------------------------
+
+ private static String extractText(String line) {
+ int tab = line.indexOf('\t');
+ String text = (tab >= 0) ? line.substring(tab + 1) : line;
+ return text.replace("\uFEFF", "");
+ }
+
+ private static void writeGzipped(Path path, List lines) throws IOException {
+ try (BufferedWriter w = new BufferedWriter(
+ new OutputStreamWriter(
+ new GZIPOutputStream(Files.newOutputStream(path)),
+ StandardCharsets.UTF_8))) {
+ for (String line : lines) {
+ w.write(line);
+ w.newLine();
+ }
+ }
+ }
+
+ private static void printUsage() {
+ System.err.println("Usage: BuildJunkTrainingData [options]");
+ System.err.println(" --data-dir MADLAD data root"
+ + " (default: ~/datasets/madlad/data)");
+ System.err.println(" --output-dir Output directory"
+ + " (default: ~/datasets/madlad/junkdetect)");
+ System.err.println(" --script-sample-lines N Lines per language for script"
+ + " detection (default: 2000)");
+ System.err.println(" --total-budget-bytes N Total UTF-8 bytes across all"
+ + " scripts (default: 50000000)");
+ System.err.println(" --min-bytes N Min UTF-8 bytes per sentence"
+ + " (default: 50)");
+ System.err.println(" --max-punc-frac F Max ASCII punct fraction"
+ + " (default: 0.30)");
+ System.err.println(" --min-dev-sentences N Min sentences in dev split for a"
+ + " script to be included (default: 500). Scripts below this floor"
+ + " have unreliable calibration and inflated FPR.");
+ System.err.println(" --seed N Random seed (default: 42)");
+ System.err.println(" --dry-run Detect scripts + show budget,"
+ + " skip file writing");
+ }
+}
diff --git a/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/tools/EvalJunkDetector.java b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/tools/EvalJunkDetector.java
new file mode 100644
index 00000000000..6b6057fc34f
--- /dev/null
+++ b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/tools/EvalJunkDetector.java
@@ -0,0 +1,777 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.ml.junkdetect.tools;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.stream.Collectors;
+import java.util.zip.GZIPInputStream;
+
+import org.apache.tika.ml.junkdetect.JunkDetector;
+import org.apache.tika.quality.TextQualityComparison;
+import org.apache.tika.quality.TextQualityScore;
+
+/**
+ * Ablation evaluation for the junk detector.
+ *
+ * For each script's dev set, scores clean sentences alongside several corruption
+ * modes at various injection rates and string lengths. Computes per-cell Cohen's d
+ * (discrimination power) and TPR/FPR at a fixed z-score threshold.
+ *
+ *
Output files in {@code --output-dir}:
+ *
+ * - detail.tsv — one row per (script, distortion, rate, length):
+ * {@code script, distortion, param, length, n_clean, n_corrupt,
+ * mean_clean_z, mean_corrupt_z, cohens_d, fpr, tpr}
+ *
- summary.tsv — macro-averaged Cohen's d and FPR/TPR per
+ * (distortion, rate, length) across all scripts.
+ *
- compare.tsv — pairwise codec-comparison accuracy using the
+ * {@link JunkDetector#compare} API, stratified by string length.
+ * This is the primary metric for the charset-arbitration use case;
+ * larger mean delta = better discrimination at that length.
+ *
+ *
+ * Why char-remap is not in summary.tsv: The character-level wrong-codec
+ * substitution (e.g. CP1252→CP1255, replacing umlauts with Hebrew letters) is added
+ * to training at a 5% rate. At that rate it is too subtle to detect via the absolute
+ * {@link JunkDetector#score} API — z-score distributions barely separate (Cohen's d ≈ 0).
+ * The distortion trains the LR to distinguish subtly-wrong from correct decodings, which
+ * only manifests as larger pairwise deltas in {@link JunkDetector#compare}. Measuring it
+ * via summary.tsv would produce misleading d≈0 "failure" rows; see compare.tsv instead.
+ *
+ *
Cohen's d = (mean_clean_z − mean_corrupt_z) / pooled_std.
+ * Higher d = better discrimination. FPR = fraction of clean text falsely flagged;
+ * TPR = fraction of corrupted text correctly flagged. Both use threshold = −2.0.
+ *
+ *
To compare two model versions: run eval before and after, then diff the
+ * summary and compare TSVs. The "macro_d" column in summary.tsv and the
+ * "mean_delta" columns in compare.tsv are the headline metrics.
+ *
+ *
Usage:
+ *
+ * java EvalJunkDetector \
+ * --model /path/to/junkdetect.bin (default: classpath)
+ * --data-dir ~/datasets/madlad/junkdetect
+ * --output-dir /path/to/results (default: data-dir/eval)
+ * --split dev|test (default: dev)
+ * --samples 200
+ * --compare-n 200 (qualifying pairs per codec pair per length)
+ * --seed 42
+ * --lengths 5,9,15,30,50,100,200
+ * --compare-lengths 5,9,15,30,50
+ * --rates 0.01,0.05,0.10,0.25,0.50,0.90
+ * --threshold -2.0
+ *
+ */
+public class EvalJunkDetector {
+
+ public static void main(String[] args) throws Exception {
+ // Defaults
+ Path modelPath = null;
+ Path dataDir = Paths.get(System.getProperty("user.home"),
+ "datasets", "madlad", "junkdetect");
+ Path outputDir = null;
+ String split = "dev";
+ int samplesPerCell = 200;
+ int compareN = 200;
+ long seed = 42L;
+ int[] lengths = {5, 9, 15, 30, 50, 100, 200};
+ int[] compareLengths = {5, 9, 15, 30, 50};
+ double[] rates = {0.01, 0.05, 0.10, 0.25, 0.50, 0.90};
+ float threshold = -2.0f;
+
+ for (int i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case "--model":
+ modelPath = Paths.get(args[++i]);
+ break;
+ case "--data-dir":
+ dataDir = Paths.get(args[++i]);
+ break;
+ case "--output-dir":
+ outputDir = Paths.get(args[++i]);
+ break;
+ case "--split":
+ split = args[++i];
+ if (!split.equals("dev") && !split.equals("test")) {
+ System.err.println("--split must be 'dev' or 'test'");
+ System.exit(1);
+ }
+ break;
+ case "--samples":
+ samplesPerCell = Integer.parseInt(args[++i]);
+ break;
+ case "--compare-n":
+ compareN = Integer.parseInt(args[++i]);
+ break;
+ case "--seed":
+ seed = Long.parseLong(args[++i]);
+ break;
+ case "--lengths":
+ lengths = Arrays.stream(args[++i].split(","))
+ .mapToInt(Integer::parseInt).toArray();
+ break;
+ case "--compare-lengths":
+ compareLengths = Arrays.stream(args[++i].split(","))
+ .mapToInt(Integer::parseInt).toArray();
+ break;
+ case "--rates":
+ rates = Arrays.stream(args[++i].split(","))
+ .mapToDouble(Double::parseDouble).toArray();
+ break;
+ case "--threshold":
+ threshold = Float.parseFloat(args[++i]);
+ break;
+ default:
+ System.err.println("Unknown argument: " + args[i]);
+ System.exit(1);
+ }
+ }
+
+ if (outputDir == null) {
+ outputDir = dataDir.resolve("eval");
+ }
+ Files.createDirectories(outputDir);
+
+ JunkDetector detector = modelPath != null
+ ? JunkDetector.loadFromPath(modelPath)
+ : JunkDetector.loadFromClasspath();
+
+ System.err.println("=== EvalJunkDetector ===");
+ System.err.println(" data-dir: " + dataDir);
+ System.err.println(" output-dir: " + outputDir);
+ System.err.println(" split: " + split
+ + (split.equals("test") ? " [FINAL REPORTING MODE]" : ""));
+ System.err.println(" scripts in model: " + detector.knownScripts().size());
+ System.err.println(" threshold: " + threshold);
+
+ // Build wrong-codec remap tables for char-remap distortion
+ List