This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new b739f6f257 TIKA-5027: junk detector treats scripts without a usable
table as unmodeled, not junk (#3522)
b739f6f257 is described below
commit b739f6f25700f2a0736a737c02cb2672ec60c94b
Author: Tim Allison <[email protected]>
AuthorDate: Wed Nov 4 15:16:04 2026 -0500
TIKA-5027: junk detector treats scripts without a usable table as
unmodeled, not junk (#3522)
Scripts whose bigram table has fewer than 1000 bigrams add no z1 evidence;
all-unmodeled text takes the coherence fallback. TextQualityScore gains
getCoverage(); tika-eval reports languageness as unknown at coverage 0.
The fallback's z6 term is anchored so letter-free text scores -1, not +3.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
CHANGES.txt | 7 ++
.../org/apache/tika/quality/TextQualityScore.java | 29 +++++-
.../org/apache/tika/eval/app/ProfilerBase.java | 2 +-
.../eval/core/metadata/TikaEvalMetadataFilter.java | 2 +-
.../tika/ml/junkdetect/tools/TrainJunkModel.java | 7 +-
.../ml/junkdetect/JunkDetectorRoundTripTest.java | 6 +-
.../apache/tika/ml/junkdetect/BigramTables.java | 3 +-
.../apache/tika/ml/junkdetect/JunkDetector.java | 89 +++++++++++++----
.../tika/ml/junkdetect/UnmodeledScriptTest.java | 109 +++++++++++++++++++++
9 files changed, 221 insertions(+), 33 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 5167d60514..1294b4607e 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -102,6 +102,13 @@ Release 4.3.0 - ???
OTHER CHANGES
+ * The junk detector no longer scores text in scripts its model has no
usable table
+ for (Yi, Mongolian, Canadian syllabics, Cherokee, Adlam and 37 others) as
+ junk: it scored -3 to -19 before. TextQualityScore gains getCoverage(),
and
+ tika-eval reports languageness for such text as unknown. Text with no
letters
+ (whitespace, digits, symbols) now scores -1 instead of +3, below clean
prose
+ (TIKA-5027).
+
* The file-to-markdown-docker agent skill turns OCR off with an empty
text-recognizers
list and a server block (its tesseract-ocr-parser config failed to load
since 4.2.0),
and checks tk:parsed-by-full-set, not pdf:ocr-page-count, to confirm OCR
ran. A test
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
index 8eea89ddec..6dfbf55a50 100644
--- a/tika-core/src/main/java/org/apache/tika/quality/TextQualityScore.java
+++ b/tika-core/src/main/java/org/apache/tika/quality/TextQualityScore.java
@@ -29,6 +29,10 @@ package org.apache.tika.quality;
* {@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.
+ *
+ * <p>{@code coverage} is the fraction of the scored text the detector actually
+ * has a model for. At 0 the score says nothing about language; a consumer
+ * reporting text quality should treat it as unknown.
*/
public final class TextQualityScore {
@@ -40,15 +44,23 @@ public final class TextQualityScore {
private final float ciLow;
private final float ciHigh;
private final String dominantScript;
+ private final float coverage;
public TextQualityScore(float zScore, float pClean,
float ciLow, float ciHigh,
String dominantScript) {
+ this(zScore, pClean, ciLow, ciHigh, dominantScript, Float.NaN);
+ }
+
+ public TextQualityScore(float zScore, float pClean,
+ float ciLow, float ciHigh,
+ String dominantScript, float coverage) {
this.zScore = zScore;
this.pClean = pClean;
this.ciLow = ciLow;
this.ciHigh = ciHigh;
this.dominantScript = dominantScript;
+ this.coverage = coverage;
}
/** Z-score relative to clean text for the detected script. 0 = average
clean; negative = worse. */
@@ -76,6 +88,19 @@ public final class TextQualityScore {
return dominantScript;
}
+ /**
+ * Fraction in [0,1] of the text's scoreable units covered by the
detector's
+ * model; NaN if the text had none (e.g. digits only) or the detector does
not report it.
+ */
+ public float getCoverage() {
+ return coverage;
+ }
+
+ /** True if the text had scoreable units but none in a script the detector
models. */
+ public boolean isUnmodeled() {
+ return coverage == 0f;
+ }
+
/** True if scoring could not be performed (e.g. empty or
unsupported-script input). */
public boolean isUnknown() {
return Float.isNaN(zScore);
@@ -87,7 +112,7 @@ public final class TextQualityScore {
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);
+ "TextQualityScore[z=%.3f p=%.3f ci=(%.3f,%.3f) script=%s
coverage=%.3f]",
+ zScore, pClean, ciLow, ciHigh, dominantScript, coverage);
}
}
diff --git
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ProfilerBase.java
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ProfilerBase.java
index 748eabcc39..20a03becea 100644
---
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ProfilerBase.java
+++
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ProfilerBase.java
@@ -494,7 +494,7 @@ public abstract class ProfilerBase {
JunkDetector jd = TikaEvalMetadataFilter.getJunkDetector();
if (jd != null && !content.isEmpty()) {
TextQualityScore q = jd.score(content);
- if (!q.isUnknown()) {
+ if (!q.isUnknown() && !q.isUnmodeled()) {
results.put(LanguagenessMarker.class, q.getZScore());
}
}
diff --git
a/tika-eval/tika-eval-core/src/main/java/org/apache/tika/eval/core/metadata/TikaEvalMetadataFilter.java
b/tika-eval/tika-eval-core/src/main/java/org/apache/tika/eval/core/metadata/TikaEvalMetadataFilter.java
index 83b1bd0978..57e38fdfda 100644
---
a/tika-eval/tika-eval-core/src/main/java/org/apache/tika/eval/core/metadata/TikaEvalMetadataFilter.java
+++
b/tika-eval/tika-eval-core/src/main/java/org/apache/tika/eval/core/metadata/TikaEvalMetadataFilter.java
@@ -129,7 +129,7 @@ public class TikaEvalMetadataFilter extends
MetadataFilterBase {
// Replaces the GLM-based languageness removed in TIKA-4720.
if (JUNK_DETECTOR != null) {
TextQualityScore q = JUNK_DETECTOR.score(content);
- metadata.set(LANGUAGENESS, q.isUnknown() ? -99.0f : q.getZScore());
+ metadata.set(LANGUAGENESS, q.isUnknown() || q.isUnmodeled() ?
-99.0f : q.getZScore());
} else {
metadata.set(LANGUAGENESS, -99.0f);
}
diff --git
a/tika-ml/tika-ml-junkdetect-tools/src/main/java/org/apache/tika/ml/junkdetect/tools/TrainJunkModel.java
b/tika-ml/tika-ml-junkdetect-tools/src/main/java/org/apache/tika/ml/junkdetect/tools/TrainJunkModel.java
index 33629e97f2..dc7e5f053e 100644
---
a/tika-ml/tika-ml-junkdetect-tools/src/main/java/org/apache/tika/ml/junkdetect/tools/TrainJunkModel.java
+++
b/tika-ml/tika-ml-junkdetect-tools/src/main/java/org/apache/tika/ml/junkdetect/tools/TrainJunkModel.java
@@ -277,10 +277,9 @@ public class TrainJunkModel {
// file via forEachScriptBigram (the SAME tokenizer inference uses):
the
// text is split into maximal same-script LETTER runs (marks attach),
each
// run wrapped TOKEN_START..TOKEN_END, and the within-run bigrams
(incl. the
- // ^/$ edges) tallied to the run's script. Non-letters are boundaries
and
- // emit nothing, so the trained tables hold exactly the bigrams
inference
- // scores. The COMMON bucket is now effectively vestigial (only the
rare
- // COMMON-scripted letter lands there).
+ // ^/$ edges) tallied to the run's script. In-run
punctuation/symbols/digits
+ // attach as glue; whitespace and anomalies end a run. The trained
tables
+ // therefore hold exactly the bigrams inference scores.
//
-----------------------------------------------------------------------
System.out.println("\n--- Phase 1: bucket-by-script F1 tables ---");
Map<String, HashMap<Long, long[]>> pairsByScript = new HashMap<>();
diff --git
a/tika-ml/tika-ml-junkdetect-tools/src/test/java/org/apache/tika/ml/junkdetect/JunkDetectorRoundTripTest.java
b/tika-ml/tika-ml-junkdetect-tools/src/test/java/org/apache/tika/ml/junkdetect/JunkDetectorRoundTripTest.java
index 7433820efe..b2080b4b34 100644
---
a/tika-ml/tika-ml-junkdetect-tools/src/test/java/org/apache/tika/ml/junkdetect/JunkDetectorRoundTripTest.java
+++
b/tika-ml/tika-ml-junkdetect-tools/src/test/java/org/apache/tika/ml/junkdetect/JunkDetectorRoundTripTest.java
@@ -94,7 +94,7 @@ public class JunkDetectorRoundTripTest {
saveMinimalModel(tables, modelFile);
// Verify the file roundtrips through the loader.
- JunkDetector detector = JunkDetector.loadFromPath(modelFile);
+ JunkDetector detector = JunkDetector.loadFromPath(modelFile, 0);
assertEquals(JunkDetector.VERSION, detector.getModelVersion(), "Loaded
model should match current VERSION");
TextQualityScore score = detector.score("ABAB");
@@ -131,7 +131,7 @@ public class JunkDetectorRoundTripTest {
Path modelFile = tmp.resolve("junk-test-allseen.bin");
saveMinimalModel(tables, modelFile);
- JunkDetector detector = JunkDetector.loadFromPath(modelFile);
+ JunkDetector detector = JunkDetector.loadFromPath(modelFile, 0);
TextQualityScore score = detector.score("ABAB");
assertEquals(expectedRunZ(tables, "ABAB", -5.0f, 1.0f),
score.getZScore(), 0.05f,
@@ -221,7 +221,7 @@ public class JunkDetectorRoundTripTest {
combiner, scriptBuckets, scriptTransTable, scriptTransCal,
modelPath);
// --- 6. Load via JunkDetector and score ---
- JunkDetector detector = JunkDetector.loadFromPath(modelPath);
+ JunkDetector detector = JunkDetector.loadFromPath(modelPath, 0);
assertEquals(JunkDetector.VERSION, detector.getModelVersion(),
"Loaded model should match current VERSION");
assertTrue(detector.knownScripts().contains("LATIN"),
diff --git
a/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/BigramTables.java
b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/BigramTables.java
index 05974bd927..d26592dfee 100644
---
a/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/BigramTables.java
+++
b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/BigramTables.java
@@ -220,7 +220,8 @@ public final class BigramTables {
unigramQuantMin, unigramQuantMax);
}
- private int occupiedSlots() {
+ /** Number of distinct bigrams stored. */
+ public int occupiedSlots() {
int n = 0;
for (int k : bigramKeys) {
if (k != EMPTY_KEY) n++;
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
index b63f8787e8..e6a4a8fc46 100644
---
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
@@ -26,6 +26,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
@@ -102,6 +103,13 @@ public final class JunkDetector implements
TextQualityDetector {
// incompatible and rejected at load.
public static final int VERSION = 15;
+ /**
+ * A script table with fewer distinct bigrams than this is too thin to
score:
+ * real text in it would hit the unseen-pair floor on nearly every bigram
and
+ * read as junk. Such scripts are treated as unmodeled (no z1 evidence).
+ */
+ static final int MIN_MODELED_BIGRAMS = 1000;
+
// Feature 1 — per-script open-addressed codepoint-bigram tables.
// No global Bloom: empty-slot is the membership oracle.
private final Map<String, BigramTables> f1TablesByScript;
@@ -110,6 +118,9 @@ public final class JunkDetector implements
TextQualityDetector {
* z1 is the ONLY per-script feature. */
private final Map<String, float[]> calibrations; // script → float[2] {mu,
sigma}
+ /** Scripts whose z1 table is populated enough to score; see {@link
#MIN_MODELED_BIGRAMS}. */
+ private final Set<String> modeledScripts;
+
// Feature 2 — single GLOBAL block-transition table.
// int16-quantized; dequantize via {@code min + (s/65535) * (max - min)}.
private final short[] blockTable;
@@ -156,8 +167,19 @@ public final class JunkDetector implements
TextQualityDetector {
Map<String, BigramTables> f1TablesByScript,
float[] z5Calibration,
float[] z6Calibration,
- float[] z9Calibration) {
+ float[] z9Calibration,
+ int minModeledBigrams) {
this.calibrations = Collections.unmodifiableMap(calibrations);
+ Set<String> modeled = new HashSet<>();
+ for (Map.Entry<String, BigramTables> e : f1TablesByScript.entrySet()) {
+ // COMMON is the glue bucket for COMMON-scripted letters; always
scored.
+ if (calibrations.containsKey(e.getKey())
+ && (COMMON_SCRIPT.equals(e.getKey())
+ || e.getValue().occupiedSlots() >= minModeledBigrams))
{
+ modeled.add(e.getKey());
+ }
+ }
+ this.modeledScripts = Collections.unmodifiableSet(modeled);
this.blockTable = blockTable;
this.blockTableQuant = blockTableQuant;
this.blockCalibration = blockCalibration;
@@ -217,8 +239,13 @@ public final class JunkDetector implements
TextQualityDetector {
* Loads a model from the given file path. The file may be gzipped or raw.
*/
public static JunkDetector loadFromPath(Path path) throws IOException {
+ return loadFromPath(path, MIN_MODELED_BIGRAMS);
+ }
+
+ /** As {@link #loadFromPath(Path)}, with the modeled-script threshold
overridden (toy test models). */
+ static JunkDetector loadFromPath(Path path, int minModeledBigrams) throws
IOException {
try (InputStream is = Files.newInputStream(path)) {
- return load(is);
+ return load(is, minModeledBigrams);
}
}
@@ -273,6 +300,11 @@ public final class JunkDetector implements
TextQualityDetector {
* </pre>
*/
public static JunkDetector load(InputStream rawIs) throws IOException {
+ return load(rawIs, MIN_MODELED_BIGRAMS);
+ }
+
+ /** As {@link #load(InputStream)}, with the modeled-script threshold
overridden (toy test models). */
+ static JunkDetector load(InputStream rawIs, int minModeledBigrams) throws
IOException {
byte[] peek = rawIs.readNBytes(2);
InputStream rest = new java.io.SequenceInputStream(
new java.io.ByteArrayInputStream(peek), rawIs);
@@ -369,7 +401,7 @@ public final class JunkDetector implements
TextQualityDetector {
scriptTransitionTable, scriptTransitionTableQuant,
scriptTransitionCalibration,
scriptBucketIndex, numScriptBuckets, f1TablesByScript,
- z5Calibration, z6Calibration, z9Calibration);
+ z5Calibration, z6Calibration, z9Calibration,
minModeledBigrams);
}
}
@@ -413,12 +445,11 @@ public final class JunkDetector implements
TextQualityDetector {
/**
* {@inheritDoc}
*
- * <p>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.
+ * <p>Letter runs are scored against their script's bigram table and the
+ * per-script z1's are combined count-weighted. Punctuation, symbols and
+ * digits inside a run are scored with it; whitespace ends a run. Scripts
+ * outside {@link #knownScripts()} contribute no z1 evidence and lower
+ * {@link TextQualityScore#getCoverage()}.
*/
@Override
public TextQualityScore score(String text) {
@@ -457,9 +488,9 @@ public final class JunkDetector implements
TextQualityDetector {
return new TextQualityComparison(winner, delta, scoreA, scoreB,
labelA, labelB);
}
- /** Returns the set of script names this model knows about. */
+ /** Returns the scripts this model scores; text in any other script
carries no z1 evidence. */
public Set<String> knownScripts() {
- return calibrations.keySet();
+ return modeledScripts;
}
/** Returns the file-format version of the loaded model
@@ -476,7 +507,9 @@ public final class JunkDetector implements
TextQualityDetector {
private static final class Agg {
float z1, z2, z3, z4, z5, z6, z7, z8, z9, logit;
String dominantScript; // null => no scoreable script (NONE
fallback)
+ String dominantUnmodeledScript;
int totalBigrams;
+ float coverage = Float.NaN;
float[] dominantCal1;
}
@@ -486,7 +519,7 @@ public final class JunkDetector implements
TextQualityDetector {
* each script's mean log-prob is calibrated to {@code
z1_S=(mean-mu)/sigma},
* and the per-script z1's are aggregated count-weighted into a single doc
* {@code z1}. z2..z9 are document-level globals. A single global
combiner
- * produces the logit. No runs, no sentinels, no per-script z2/z3.
+ * produces the logit.
*/
private Agg aggregate(String text) {
// NFKC-normalize so inference matches the trainer's tally AND legacy
@@ -508,14 +541,13 @@ public final class JunkDetector implements
TextQualityDetector {
String[] lastScript = {null};
int[] lastB = {Integer.MIN_VALUE};
int[] lastBIdx = {-1};
+ Map<String, int[]> unmodeled = new HashMap<>();
forEachScriptBigram(cps, (script, a, b) -> {
- if (!calibrations.containsKey(script)) {
+ if (!modeledScripts.contains(script)) {
+ unmodeled.computeIfAbsent(script, k -> new int[1])[0]++;
return;
}
BigramTables t = f1TablesByScript.get(script);
- if (t == null) {
- return;
- }
int idxA = (a == lastB[0] && script.equals(lastScript[0]))
? lastBIdx[0] : codepointToIndex(t, a);
int idxB = codepointToIndex(t, b);
@@ -566,6 +598,18 @@ public final class JunkDetector implements
TextQualityDetector {
agg.z8 = Double.isNaN(rawFrag) ? 1f : 1f - (float) rawFrag;
agg.z9 = computeZ9AlternationRatio(text);
agg.totalBigrams = (int) z1Count;
+ long unmodeledCount = 0;
+ int maxUnmodeled = 0;
+ for (Map.Entry<String, int[]> e : unmodeled.entrySet()) {
+ unmodeledCount += e.getValue()[0];
+ if (e.getValue()[0] > maxUnmodeled) {
+ maxUnmodeled = e.getValue()[0];
+ agg.dominantUnmodeledScript = e.getKey();
+ }
+ }
+ if (z1Count + unmodeledCount > 0) {
+ agg.coverage = (float) z1Count / (z1Count + unmodeledCount);
+ }
if (z1Count == 0 || agg.dominantScript == null) {
// No scoreable LETTER at all (zero runs) — doc-level fallback:
@@ -573,10 +617,11 @@ public final class JunkDetector implements
TextQualityDetector {
// (unmodeled coherent script); density=1 coherence=0 -> very
negative.
// z6 is included so a letter-free but FFFD/anomaly-heavy doc
(which can
// no longer flood z1) is still penalized here — no path ignores
the
- // replacement ratio. For unmodeled-but-clean script z6~0, so
it's inert.
+ // replacement ratio. z6 is 1 for anomaly-free text, so (z6 - 1)
keeps
+ // it inert there: all-whitespace -1, coherent unmodeled script
~+2.4.
agg.dominantScript = null;
agg.z1 = Float.NaN;
- agg.logit = -7f + 4f * agg.z7 + 6f * agg.z8 + 4f * agg.z6;
+ agg.logit = -7f + 4f * agg.z7 + 6f * agg.z8 + 4f * (agg.z6 - 1f);
return agg;
}
agg.z1 = (float) (weightedZ1 / z1Count);
@@ -589,12 +634,13 @@ public final class JunkDetector implements
TextQualityDetector {
Agg a = aggregate(text);
float pClean = (float) (1.0 / (1.0 + Math.exp(-a.logit)));
if (a.dominantScript == null) {
- return new TextQualityScore(a.logit, pClean, a.logit, a.logit,
"NONE");
+ String script = a.dominantUnmodeledScript != null ?
a.dominantUnmodeledScript : "NONE";
+ return new TextQualityScore(a.logit, pClean, a.logit, a.logit,
script, a.coverage);
}
float uncertainty = (a.dominantCal1 != null && a.totalBigrams > 0)
? (float) (1.96 * a.dominantCal1[1] /
Math.sqrt(a.totalBigrams)) : 0f;
return new TextQualityScore(a.logit, pClean,
- a.logit - uncertainty, a.logit + uncertainty,
a.dominantScript);
+ a.logit - uncertainty, a.logit + uncertainty,
a.dominantScript, a.coverage);
}
/**
@@ -609,8 +655,9 @@ public final class JunkDetector implements
TextQualityDetector {
}
Agg a = aggregate(text);
if (a.dominantScript == null) {
+ String script = a.dominantUnmodeledScript != null ?
a.dominantUnmodeledScript : "NONE";
return new FeatureComponents(Float.NaN, Float.NaN, Float.NaN,
- a.z4, a.z5, a.z6, a.z7, a.z8, a.z9, a.logit, "NONE", null,
0);
+ a.z4, a.z5, a.z6, a.z7, a.z8, a.z9, a.logit, script, null,
0);
}
return new FeatureComponents(a.z1, a.z2, a.z3, a.z4, a.z5, a.z6, a.z7,
a.z8, a.z9, a.logit, a.dominantScript, combinerWeights,
diff --git
a/tika-ml/tika-ml-junkdetect/src/test/java/org/apache/tika/ml/junkdetect/UnmodeledScriptTest.java
b/tika-ml/tika-ml-junkdetect/src/test/java/org/apache/tika/ml/junkdetect/UnmodeledScriptTest.java
new file mode 100644
index 0000000000..42e960dd97
--- /dev/null
+++
b/tika-ml/tika-ml-junkdetect/src/test/java/org/apache/tika/ml/junkdetect/UnmodeledScriptTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Random;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import org.apache.tika.quality.TextQualityScore;
+
+/**
+ * Real text in a script the bundled model has no usable table for must read as
+ * unmodeled (coverage 0, not junk), and scripts it does model must be
unaffected.
+ */
+public class UnmodeledScriptTest {
+
+ private static JunkDetector detector;
+
+ @BeforeAll
+ static void loadModel() throws Exception {
+ detector = JunkDetector.loadFromClasspath();
+ }
+
+ @ParameterizedTest
+ @CsvSource(delimiter = '|', value = {
+ "YI|ꆈꌠꁱꂷ ꀉꂾꑭꇬ ꌋꆀꃅꑍ ꆈꌠꉙ ꀊꁨꌠ ꄉꈌꌠ ꑳꌠꆏ ꃅꐥꇬ ꌠꅇ",
+ "MONGOLIAN|ᠮᠣᠩᠭᠣᠯ ᠤᠯᠤᠰ ᠪᠣᠯ ᠳᠣᠷᠤᠨᠠᠳᠤ ᠠᠽᠢ ᠶᠢᠨ ᠬᠣᠢᠳᠤ ᠬᠡᠰᠡᠭ ᠲᠦ
ᠣᠷᠣᠰᠢᠳᠠᠭ",
+ "CANADIAN_ABORIGINAL|ᐃᓄᒃᑎᑐᑦ ᐅᖃᐅᓯᖅ ᐃᓄᐃᑦ ᐅᖃᐅᓯᖓᑦ ᑲᓇᑕᒥ ᓄᓇᕗᒻᒥ ᐊᒻᒪ
ᓄᓇᕕᒻᒥ",
+ "CHEROKEE|ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ ᎠᏂᏴᏫᏯ ᎤᏂᎦᏚ ᏚᏂᏃᎮᎴ ᏣᎳᎩ ᎠᏰᎵ ᏓᎵᏍᏓᏴᏗ",
+ "ADLAM|𞤀𞤣𞤤𞤢𞤥 𞤫𞤼𞤵 𞤦𞤭𞤲𞤣𞤭 𞤯𞤫𞤲𞤺𞤢𞤤 𞤆𞤵𞤤𞤢𞤪 𞤲𞤣𞤫𞤪 𞤀𞤬𞤪𞤭𞤳𞤢"
+ })
+ void unmodeledScriptIsNotJunk(String script, String text) {
+ assertFalse(detector.knownScripts().contains(script), script + "
should be unmodeled");
+ TextQualityScore s = detector.score(text);
+ assertTrue(s.isUnmodeled(), "coverage should be 0: " + s);
+ assertEquals(script, s.getDominantScript());
+ assertTrue(s.getZScore() > 0, "coherent unmodeled-script text scored
as junk: " + s);
+ }
+
+ @ParameterizedTest
+ @CsvSource(delimiter = '|', value = {
+ "GREEK|Η Ελλάδα είναι χώρα της νοτιοανατολικής Ευρώπης. Άρθρα για
την ιστορία της χώρας.",
+ "LATIN|The quick brown fox jumps over the lazy dog and keeps
running down the road.",
+ "CYRILLIC|Москва является столицей России и крупнейшим городом
страны."
+ })
+ void modeledScriptFullyCovered(String script, String text) {
+ assertTrue(detector.knownScripts().contains(script));
+ TextQualityScore s = detector.score(text);
+ assertEquals(1f, s.getCoverage(), 1e-6f, s.toString());
+ assertEquals(script, s.getDominantScript());
+ assertTrue(s.getZScore() > 0, s.toString());
+ }
+
+ @Test
+ void mixedTextIsPartiallyCovered() {
+ TextQualityScore s = detector.score(
+ "Mongolia ᠮᠣᠩᠭᠣᠯ ᠤᠯᠤᠰ ᠪᠣᠯ ᠳᠣᠷᠤᠨᠠᠳᠤ is a country in East
Asia.");
+ assertEquals("LATIN", s.getDominantScript());
+ assertTrue(s.getCoverage() > 0f && s.getCoverage() < 1f, s.toString());
+ }
+
+ @Test
+ void unmodeledScriptSaladIsJunk() {
+ int[] pool = "ꆈꌠꁱꂷᠮᠣᠩᠭᐃᓄᒃᑎᏣᎳᎩᎦ".codePoints().toArray();
+ Random r = new Random(20261103L);
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 60; i++) {
+ sb.appendCodePoint(pool[r.nextInt(pool.length)]);
+ if (r.nextInt(5) == 0) {
+ sb.append(' ');
+ }
+ }
+ TextQualityScore s = detector.score(sb.toString());
+ assertTrue(s.getZScore() < 0, "script salad should stay junk: " + s);
+ }
+
+ @Test
+ void letterFreeTextDoesNotOutscoreProse() {
+ float prose = detector.score("Москва является столицей России и
крупнейшим городом.")
+ .getZScore();
+ for (String letterFree : new String[]{" \n\n\t ", "╔══╗ ║░░║
╚══╝ ▒▓█ ┌─┐", "2026-11-03 3.14159"}) {
+ TextQualityScore s = detector.score(letterFree);
+ assertTrue(Float.isNaN(s.getCoverage()), s.toString());
+ assertTrue(s.getZScore() < 0 && s.getZScore() < prose,
+ "letter-free text must not look like clean prose: " + s);
+ }
+ }
+}