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 fe197f039a TIKA-4810 -- follow-on: single-pass Utf8Stats walker + 
loose-end cleanup (#2995)
fe197f039a is described below

commit fe197f039ab7a0c2bcc0806334b9cf9c35dcd3cc
Author: Tim Allison <[email protected]>
AuthorDate: Fri Aug 7 18:47:30 2026 -0400

    TIKA-4810 -- follow-on: single-pass Utf8Stats walker + loose-end cleanup 
(#2995)
---
 .skills/tika-eval-compare.md                       |   3 +
 .../tika/ml/chardetect/CjkDecodeValidator.java     |  28 +-
 .../ml/chardetect/MojibusterEncodingDetector.java  |  89 +------
 .../ml/chardetect/StructuralEncodingRules.java     | 284 +++++++--------------
 .../ToleratedUtf8StructuralRegressionTest.java     |  25 +-
 .../apache/tika/ml/chardetect/Utf8StatsTest.java   | 126 +++++++++
 .../ml/junkdetect/JunkFilterEncodingDetector.java  |  11 +-
 7 files changed, 251 insertions(+), 315 deletions(-)

diff --git a/.skills/tika-eval-compare.md b/.skills/tika-eval-compare.md
index cdb581e386..17058187c4 100644
--- a/.skills/tika-eval-compare.md
+++ b/.skills/tika-eval-compare.md
@@ -22,6 +22,9 @@ Ask the user for:
   zip archive containing `tika-app-*.jar`, `lib/`, and `plugins/`.
 - A corpus of input files (a directory tree).
 - tika-eval-app, built from `tika-eval/tika-eval-app` (use the zip).
+  The bare `target/tika-eval-app-*.jar` is thin (no bundled deps) and dies with
+  `NoClassDefFoundError: ...GzipCompressorOutputStream` (esp. with `-r`). 
Always
+  run the jar from the unzipped `target/*.zip` dir, which carries its `lib/`.
 - **Enable MD5 digesting** in both configs so tika-eval can match
   embedded documents by content hash (not just index position).
   Add to the config JSON:
diff --git 
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/CjkDecodeValidator.java
 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/CjkDecodeValidator.java
index 00f44dcee5..4dbe27977f 100644
--- 
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/CjkDecodeValidator.java
+++ 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/CjkDecodeValidator.java
@@ -94,7 +94,7 @@ public final class CjkDecodeValidator {
                 i++;
                 continue;
             }
-            int ulen = utf8SequenceLength(bytes, i);
+            int ulen = StructuralEncodingRules.utf8SequenceLength(bytes, i);
             if (ulen > 0) {
                 nUtf8Seqs++;
                 i += ulen; // embedded UTF-8 — not legacy content, skip
@@ -136,30 +136,4 @@ public final class CjkDecodeValidator {
                 || name.contains("shift") || name.contains("jis") || 
name.contains("949");
     }
 
-    /** Length (2/3/4) of a valid UTF-8 multi-byte sequence starting at {@code 
i},
-     *  or 0 if none.  Lead-byte ranges exclude overlong 2-byte (C0/C1) and
-     *  out-of-range (≥F5) leads; continuations must be 0x80–0xBF. */
-    static int utf8SequenceLength(byte[] b, int i) {
-        int x = b[i] & 0xFF;
-        int len;
-        if (x >= 0xC2 && x <= 0xDF) {
-            len = 2;
-        } else if (x >= 0xE0 && x <= 0xEF) {
-            len = 3;
-        } else if (x >= 0xF0 && x <= 0xF4) {
-            len = 4;
-        } else {
-            return 0;
-        }
-        if (i + len > b.length) {
-            return 0;
-        }
-        for (int k = 1; k < len; k++) {
-            int c = b[i + k] & 0xFF;
-            if (c < 0x80 || c > 0xBF) {
-                return 0;
-            }
-        }
-        return len;
-    }
 }
diff --git 
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java
 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java
index a7f1909d9b..d0424b4573 100644
--- 
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java
+++ 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java
@@ -140,7 +140,10 @@ public class MojibusterEncodingDetector implements 
EncodingDetector {
      *  genuine false-CJK ≥5.3%, so ~2.5% separates them (see 
CjkDecodeValidator). */
     private static final double CJK_FAILURE_VETO_THRESHOLD = 0.025;
 
-    /** Confidence for the windows-1252 fallback emitted on empty/ASCII 
probes. */
+    /** Confidence for the windows-1252 fallback emitted on empty/ASCII probes.
+     *  MUST equal JunkFilterEncodingDetector.NO_INFO_CONFIDENCE 
(tika-ml-junkdetect):
+     *  its strict {@code confidence > NO_INFO_CONFIDENCE} test treats exactly 
this
+     *  value as "statistical layer abstained" so a declaration can win. */
     private static final float FALLBACK_CONFIDENCE = 0.1f;
 
     /**
@@ -335,7 +338,8 @@ public class MojibusterEncodingDetector implements 
EncodingDetector {
         //     because STRUCTURAL confidence outranks STATISTICAL.
         //   • AMBIGUOUS (pure ASCII or only truncated lead): no
         //     emission; NB + fallbacks handle it.
-        StructuralEncodingRules.Utf8Result utf8 = 
StructuralEncodingRules.checkUtf8(probe);
+        StructuralEncodingRules.Utf8Stats utf8Stats = 
StructuralEncodingRules.utf8Stats(probe);
+        StructuralEncodingRules.Utf8Result utf8 = utf8Stats.toResult();
         // TACTICAL: tolerate small corruption.  If the grammar check returned
         // NOT_UTF8 but the malformed-byte fraction is tiny, treat as UTF-8 —
         // a single bad continuation byte in 2KB of CJK is nearly always
@@ -343,7 +347,7 @@ public class MojibusterEncodingDetector implements 
EncodingDetector {
         // replaced with a probabilistic decoder.
         boolean utf8Tolerated = false;
         if (utf8 == StructuralEncodingRules.Utf8Result.NOT_UTF8) {
-            int errors = StructuralEncodingRules.countUtf8Errors(probe);
+            int errors = utf8Stats.errors();
             // Length-aware: absolute floor for short probes, rate for long 
ones.
             int maxTolerated = Math.max(UTF8_MAX_TOLERATED_ERRORS,
                     (int) (probe.length * UTF8_MALFORMED_TOLERANCE));
@@ -371,7 +375,7 @@ public class MojibusterEncodingDetector implements 
EncodingDetector {
         // document its STRUCTURAL proof and JunkFilter has nothing to prefer
         // over the declared charset.
         boolean evidenceTolerated = utf8Tolerated
-                && StructuralEncodingRules.countUtf8Sequences(probe) >= 
MIN_TOLERATED_UTF8_SEQUENCES;
+                && utf8Stats.sequences() >= MIN_TOLERATED_UTF8_SEQUENCES;
         if (utf8 == StructuralEncodingRules.Utf8Result.LIKELY_UTF8 || 
evidenceTolerated) {
             pool.add(new EncodingResult(
                     java.nio.charset.StandardCharsets.UTF_8,
@@ -534,83 +538,6 @@ public class MojibusterEncodingDetector implements 
EncodingDetector {
         return true;
     }
 
-    /**
-     * Resolve UTF-16 to LE or BE once NB has called it "UTF-16".
-     *
-     * <p>Two deterministic tests:
-     * <ol>
-     *   <li>Null-density: count null bytes in even-offset positions
-     *       vs odd-offset positions.  For ASCII-in-UTF-16-LE the
-     *       high byte is 0x00 at odd positions; for BE it's at even
-     *       positions.  If one column is clearly null-dominant, that
-     *       column indicates the endianness.</li>
-     *   <li>Codepoint validity fallback: for ambiguous probes (pure
-     *       CJK UTF-16, no nulls in either column) count how many
-     *       16-bit codepoints under LE vs BE interpretation land in
-     *       assigned Unicode BMP ranges (non-PUA, non-unassigned).
-     *       Whichever interpretation yields more valid codepoints
-     *       wins.</li>
-     * </ol>
-     *
-     * <p>Also honors the {@code invalidUtf16Le}/{@code invalidUtf16Be}
-     * flags from {@link WideUnicodeDetector} — if either endianness
-     * is structurally invalid (surrogate-pair violation), the other
-     * wins by default.
-     *
-     * @return the resolved charset, or {@code null} if the probe is
-     *         structurally invalid under both interpretations
-     */
-    private static java.nio.charset.Charset disambiguateUtf16(byte[] probe,
-                                                              boolean 
invalidLe,
-                                                              boolean 
invalidBe) {
-        if (invalidLe && invalidBe) {
-            return null;
-        }
-        if (invalidLe) {
-            return java.nio.charset.Charset.forName("UTF-16BE");
-        }
-        if (invalidBe) {
-            return java.nio.charset.Charset.forName("UTF-16LE");
-        }
-        int nullEven = 0;
-        int nullOdd = 0;
-        for (int i = 0; i + 1 < probe.length; i += 2) {
-            if (probe[i] == 0) nullEven++;
-            if (probe[i + 1] == 0) nullOdd++;
-        }
-        // Clear null-density winner: one column is ≥ 3× more
-        // null-dominant than the other.
-        if (nullEven >= 3 * Math.max(1, nullOdd)) {
-            return java.nio.charset.Charset.forName("UTF-16BE");
-        }
-        if (nullOdd >= 3 * Math.max(1, nullEven)) {
-            return java.nio.charset.Charset.forName("UTF-16LE");
-        }
-        // Ambiguous on null-density (CJK content).  Count valid BMP
-        // codepoints under each interpretation.  A "valid" codepoint
-        // is any non-zero codepoint outside the surrogate range
-        // (0xD800-0xDFFF) — for CJK content most bytes map into
-        // assigned blocks, and random-byte-interpreted-as-UTF-16
-        // produces many surrogate-range halves.
-        int validLe = 0;
-        int validBe = 0;
-        for (int i = 0; i + 1 < probe.length; i += 2) {
-            int lo = probe[i] & 0xFF;
-            int hi = probe[i + 1] & 0xFF;
-            int leCp = (hi << 8) | lo;
-            int beCp = (lo << 8) | hi;
-            if (leCp != 0 && (leCp < 0xD800 || leCp > 0xDFFF)) {
-                validLe++;
-            }
-            if (beCp != 0 && (beCp < 0xD800 || beCp > 0xDFFF)) {
-                validBe++;
-            }
-        }
-        return validLe >= validBe
-                ? java.nio.charset.Charset.forName("UTF-16LE")
-                : java.nio.charset.Charset.forName("UTF-16BE");
-    }
-
     /**
      * Relabel the top result to windows-1252 when top is a non-1252
      * member of {@link CharsetConfusables#SBCS_LATIN_FAMILY} and
diff --git 
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/StructuralEncodingRules.java
 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/StructuralEncodingRules.java
index 67eb7c7503..bbd9a371d2 100644
--- 
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/StructuralEncodingRules.java
+++ 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/StructuralEncodingRules.java
@@ -656,97 +656,7 @@ public final class StructuralEncodingRules {
     }
 
     public static Utf8Result checkUtf8(byte[] bytes, int offset, int length) {
-        int highByteCount = 0;
-        // Count only multi-byte sequences whose continuations are all
-        // present in the probe.  A truncated lead at probe-end is
-        // valid-so-far but provides no structural evidence — a single
-        // 0xC3 byte alone should not be enough to claim UTF-8.
-        int completeHighSeqCount = 0;
-        int i = offset;
-        int end = offset + length;
-
-        while (i < end) {
-            int b = bytes[i] & 0xFF;
-
-            if (b < 0x80) {
-                i++;
-                continue;
-            }
-
-            highByteCount++;
-
-            // Determine expected continuation count from the lead byte
-            int seqLen;
-            if (b >= 0xF8) {
-                // 5-/6-byte sequences are not valid Unicode
-                return Utf8Result.NOT_UTF8;
-            } else if (b >= 0xF0) {
-                seqLen = 4;
-            } else if (b >= 0xE0) {
-                seqLen = 3;
-            } else if (b >= 0xC0) {
-                seqLen = 2;
-            } else {
-                // 0x80–0xBF is a continuation byte without a lead → invalid
-                return Utf8Result.NOT_UTF8;
-            }
-
-            // Overlong 2-byte sequence (C0 or C1 lead)
-            if (seqLen == 2 && b <= 0xC1) {
-                return Utf8Result.NOT_UTF8;
-            }
-
-            // Check that the right number of continuation bytes follow
-            boolean truncated = false;
-            for (int k = 1; k < seqLen; k++) {
-                if (i + k >= end) {
-                    truncated = true;
-                    break;
-                }
-                int cb = bytes[i + k] & 0xFF;
-                if (cb < 0x80 || cb > 0xBF) {
-                    return Utf8Result.NOT_UTF8;
-                }
-            }
-
-            // Validate scalar value ranges for 3- and 4-byte sequences,
-            // but only when the full sequence is present. Truncated sequences
-            // at the end of a probe are not evidence of invalid UTF-8.
-            if (!truncated) {
-                if (seqLen == 3) {
-                    int cp = ((b & 0x0F) << 12)
-                            | ((bytes[i + 1] & 0xFF) & 0x3F) << 6
-                            | ((bytes[i + 2] & 0xFF) & 0x3F);
-                    if (cp < 0x0800 || (cp >= 0xD800 && cp <= 0xDFFF)) {
-                        return Utf8Result.NOT_UTF8;
-                    }
-                } else if (seqLen == 4) {
-                    int cp = ((b & 0x07) << 18)
-                            | ((bytes[i + 1] & 0xFF) & 0x3F) << 12
-                            | ((bytes[i + 2] & 0xFF) & 0x3F) << 6
-                            | ((bytes[i + 3] & 0xFF) & 0x3F);
-                    if (cp < 0x10000 || cp > 0x10FFFF) {
-                        return Utf8Result.NOT_UTF8;
-                    }
-                }
-                completeHighSeqCount++;
-            }
-
-            i += seqLen;
-        }
-
-        // Grammar is valid.  Require at least one COMPLETE multi-byte
-        // sequence (not just a truncated lead at probe-end) to claim
-        // LIKELY.  A lone 0xC3 at the end of a 1-byte probe is
-        // valid-so-far but provides no structural evidence.
-        if (completeHighSeqCount > 0) {
-            return Utf8Result.LIKELY_UTF8;
-        }
-        // highByteCount > 0 but completeHighSeqCount == 0 means every
-        // high byte we saw was the start of a truncated sequence at
-        // probe-end.  That's not enough evidence — treat as ambiguous.
-        // Zero high bytes = pure ASCII; caller handles this separately.
-        return Utf8Result.AMBIGUOUS;
+        return utf8Stats(bytes, offset, length).toResult();
     }
 
     /**
@@ -787,30 +697,49 @@ public final class StructuralEncodingRules {
     }
 
     /**
-     * Counts the number of malformed UTF-8 <em>sequences</em> in the sample —
-     * one event per bad lead, orphaned continuation, overlong, surrogate, or
-     * out-of-range codepoint, regardless of how many bytes the bad sequence
-     * spans.  Unlike {@link #checkUtf8}, this does not early-exit on the
-     * first bad sequence; it scans the entire range, resyncing after each
-     * error.  Returns 0 for a clean UTF-8 stream.
+     * Single-pass tally of UTF-8 structure over the sample: malformed-sequence
+     * events and complete valid multi-byte sequences.  Same walk as
+     * {@link #checkUtf8} but without the early exit — scans the entire range,
+     * resyncing after each error.
      *
-     * <p>Useful for "tolerant" UTF-8 acceptance: a real-world UTF-8 file with
-     * a few corrupted sequences (copy-paste artefact, truncated upstream,
-     * MIME transport flip) should still be recognized as UTF-8 rather than
-     * rejected outright.  Caller decides what error count is tolerable
-     * (typically as a fraction of probe length).</p>
+     * <p>{@code errors} counts one event per bad lead, orphaned continuation,
+     * overlong, surrogate, or out-of-range codepoint regardless of how many
+     * bytes the bad sequence spans — matching Java's
+     * {@code new String(bytes, UTF_8)} U+FFFD-per-error semantics, the
+     * convention callers use for tolerance thresholds.  A truncated sequence
+     * at probe-end is neither an error nor a sequence.</p>
      *
-     * <p>The count matches Java's {@code new String(bytes, UTF_8)}'s
-     * U+FFFD-per-error semantics (one replacement per malformed sequence).</p>
+     * <p>{@code sequences} gauges how much genuine multi-byte UTF-8 evidence
+     * the probe carries independent of its error count.</p>
      *
-     * @return number of malformed UTF-8 sequence events
+     * <p>{@code truncatedTailInvalid}: the probe ends in a truncated sequence
+     * whose partial continuations are provably NOT UTF-8 (e.g. {@code E0 41}
+     * at probe-end).  Not an error <em>event</em> (no U+FFFD equivalent — the
+     * decoder would ask for more input), but it disqualifies the sample from
+     * {@link Utf8Result#LIKELY_UTF8}/{@link Utf8Result#AMBIGUOUS}.</p>
      */
-    public static int countUtf8Errors(byte[] bytes) {
-        return countUtf8Errors(bytes, 0, bytes.length);
+    public record Utf8Stats(int errors, int sequences, boolean 
truncatedTailInvalid) {
+
+        /** Collapse to the {@link #checkUtf8} tri-state: any invalidity →
+         *  NOT_UTF8; else ≥1 complete multi-byte sequence → LIKELY_UTF8
+         *  (a lone truncated lead is no structural evidence); else AMBIGUOUS
+         *  (pure ASCII, or truncated-lead-only). */
+        public Utf8Result toResult() {
+            if (errors > 0 || truncatedTailInvalid) {
+                return Utf8Result.NOT_UTF8;
+            }
+            return sequences > 0 ? Utf8Result.LIKELY_UTF8 : 
Utf8Result.AMBIGUOUS;
+        }
     }
 
-    public static int countUtf8Errors(byte[] bytes, int offset, int length) {
+    public static Utf8Stats utf8Stats(byte[] bytes) {
+        return utf8Stats(bytes, 0, bytes.length);
+    }
+
+    public static Utf8Stats utf8Stats(byte[] bytes, int offset, int length) {
         int errors = 0;
+        int sequences = 0;
+        boolean truncatedTailInvalid = false;
         int i = offset;
         int end = offset + length;
         while (i < end) {
@@ -843,10 +772,16 @@ public final class StructuralEncodingRules {
                 i++;
                 continue;
             }
-            int kEnd = Math.min(seqLen, end - i);
-            // Truncated at probe-end is not an error — just stop here.
-            if (kEnd < seqLen) {
-                i = end;
+            // Truncated at probe-end is not an error event, but a provably-bad
+            // continuation in the partial tail still disqualifies UTF-8.
+            if (end - i < seqLen) {
+                for (int k = 1; k < end - i; k++) {
+                    int cb = bytes[i + k] & 0xFF;
+                    if (cb < 0x80 || cb > 0xBF) {
+                        truncatedTailInvalid = true;
+                        break;
+                    }
+                }
                 break;
             }
             // Verify continuations are well-formed
@@ -860,11 +795,9 @@ public final class StructuralEncodingRules {
             }
             if (bad) {
                 errors++;
-                // Skip the whole intended sequence. Advancing byte-by-byte
-                // would re-count the orphaned continuations as additional
-                // errors and inflate the count above Java's UTF-8 decoder's
-                // U+FFFD-per-event semantics, which is the convention we
-                // match for caller threshold comparisons.
+                // Skip the whole intended sequence: byte-by-byte advancement
+                // would re-count orphaned continuations and inflate the count
+                // above the U+FFFD-per-event convention.
                 i += seqLen;
                 continue;
             }
@@ -889,84 +822,61 @@ public final class StructuralEncodingRules {
                     continue;
                 }
             }
+            sequences++;
             i += seqLen;
         }
-        return errors;
+        return new Utf8Stats(errors, sequences, truncatedTailInvalid);
+    }
+
+    /**
+     * Length (2/3/4) of a grammatically valid UTF-8 multi-byte sequence
+     * starting at {@code i}, or 0 if none.  Lead ranges exclude overlong
+     * 2-byte (C0/C1) and out-of-range (&ge;F5) leads; continuations must be
+     * 0x80–0xBF.  Deliberately grammar-only — NO codepoint-range/surrogate
+     * checks — so interleaved-decode callers (see
+     * {@link CjkDecodeValidator#strippedFailureRate}) classify a byte run as
+     * "UTF-8-shaped" cheaply without full validation.
+     */
+    public static int utf8SequenceLength(byte[] b, int i) {
+        int x = b[i] & 0xFF;
+        int len;
+        if (x >= 0xC2 && x <= 0xDF) {
+            len = 2;
+        } else if (x >= 0xE0 && x <= 0xEF) {
+            len = 3;
+        } else if (x >= 0xF0 && x <= 0xF4) {
+            len = 4;
+        } else {
+            return 0;
+        }
+        if (i + len > b.length) {
+            return 0;
+        }
+        for (int k = 1; k < len; k++) {
+            int c = b[i + k] & 0xFF;
+            if (c < 0x80 || c > 0xBF) {
+                return 0;
+            }
+        }
+        return len;
+    }
+
+    /** @see Utf8Stats#errors */
+    public static int countUtf8Errors(byte[] bytes) {
+        return utf8Stats(bytes).errors();
+    }
+
+    public static int countUtf8Errors(byte[] bytes, int offset, int length) {
+        return utf8Stats(bytes, offset, length).errors();
     }
 
-    /** Counts complete, valid multi-byte UTF-8 sequences — companion to
-     *  {@link #countUtf8Errors}, same walk, opposite tally. Gauges how much
-     *  genuine UTF-8 evidence a probe carries independent of its error count. 
*/
+    /** @see Utf8Stats#sequences */
     public static int countUtf8Sequences(byte[] bytes) {
-        return countUtf8Sequences(bytes, 0, bytes.length);
+        return utf8Stats(bytes).sequences();
     }
 
     public static int countUtf8Sequences(byte[] bytes, int offset, int length) 
{
-        int sequences = 0;
-        int i = offset;
-        int end = offset + length;
-        while (i < end) {
-            int b = bytes[i] & 0xFF;
-            if (b < 0x80) {
-                i++;
-                continue;
-            }
-            int seqLen;
-            if (b >= 0xF8) {
-                i++;
-                continue;
-            } else if (b >= 0xF0) {
-                seqLen = 4;
-            } else if (b >= 0xE0) {
-                seqLen = 3;
-            } else if (b >= 0xC0) {
-                seqLen = 2;
-            } else {
-                i++;
-                continue;
-            }
-            if (seqLen == 2 && b <= 0xC1) {
-                i++;
-                continue;
-            }
-            int kEnd = Math.min(seqLen, end - i);
-            if (kEnd < seqLen) {
-                break;
-            }
-            boolean bad = false;
-            for (int k = 1; k < seqLen; k++) {
-                int cb = bytes[i + k] & 0xFF;
-                if (cb < 0x80 || cb > 0xBF) {
-                    bad = true;
-                    break;
-                }
-            }
-            if (bad) {
-                i += seqLen;
-                continue;
-            }
-            if (seqLen == 3) {
-                int cp = ((b & 0x0F) << 12)
-                        | ((bytes[i + 1] & 0xFF) & 0x3F) << 6
-                        | ((bytes[i + 2] & 0xFF) & 0x3F);
-                if (cp < 0x0800 || (cp >= 0xD800 && cp <= 0xDFFF)) {
-                    i += seqLen;
-                    continue;
-                }
-            } else if (seqLen == 4) {
-                int cp = ((b & 0x07) << 18)
-                        | ((bytes[i + 1] & 0xFF) & 0x3F) << 12
-                        | ((bytes[i + 2] & 0xFF) & 0x3F) << 6
-                        | ((bytes[i + 3] & 0xFF) & 0x3F);
-                if (cp < 0x10000 || cp > 0x10FFFF) {
-                    i += seqLen;
-                    continue;
-                }
-            }
-            sequences++;
-            i += seqLen;
-        }
-        return sequences;
+        return utf8Stats(bytes, offset, length).sequences();
     }
 
     // -----------------------------------------------------------------------
diff --git 
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/ToleratedUtf8StructuralRegressionTest.java
 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/ToleratedUtf8StructuralRegressionTest.java
index 18ccefa994..f645b9bb46 100644
--- 
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/ToleratedUtf8StructuralRegressionTest.java
+++ 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/ToleratedUtf8StructuralRegressionTest.java
@@ -55,10 +55,7 @@ public class ToleratedUtf8StructuralRegressionTest {
     public void longDocumentWithOneStrayByteIsStillUtf8() throws IOException {
         byte[] probe = buildProbe(30);
         List<EncodingResult> results = newDetector().detect(probe);
-        boolean hasStructuralUtf8 = results.stream().anyMatch(r ->
-                "UTF-8".equals(r.getCharset().name())
-                        && r.getResultType() == 
EncodingResult.ResultType.STRUCTURAL);
-        assertTrue(hasStructuralUtf8,
+        assertTrue(hasStructuralUtf8(results),
                 "A long, overwhelmingly UTF-8 document with a single tolerated 
"
                         + "error byte must still yield a STRUCTURAL UTF-8 
candidate; "
                         + "results were: " + results);
@@ -73,10 +70,7 @@ public class ToleratedUtf8StructuralRegressionTest {
         byte[] probe = bo.toByteArray();
 
         List<EncodingResult> results = newDetector().detect(probe);
-        boolean hasStructuralUtf8 = results.stream().anyMatch(r ->
-                "UTF-8".equals(r.getCharset().name())
-                        && r.getResultType() == 
EncodingResult.ResultType.STRUCTURAL);
-        assertFalse(hasStructuralUtf8,
+        assertFalse(hasStructuralUtf8(results),
                 "A short probe shaped like a zip entry name must not be 
promoted "
                         + "to STRUCTURAL UTF-8 on a single tolerated error 
alone; "
                         + "results were: " + results);
@@ -87,10 +81,7 @@ public class ToleratedUtf8StructuralRegressionTest {
     public void chineseGbkFilenameIsNotPromotedToUtf8() {
         byte[] probe = "说明.txt".getBytes(Charset.forName("GBK"));
         List<EncodingResult> results = newDetector().detect(probe);
-        boolean hasStructuralUtf8 = results.stream().anyMatch(r ->
-                "UTF-8".equals(r.getCharset().name())
-                        && r.getResultType() == 
EncodingResult.ResultType.STRUCTURAL);
-        assertFalse(hasStructuralUtf8,
+        assertFalse(hasStructuralUtf8(results),
                 "A short GBK filename must not be promoted to STRUCTURAL UTF-8 
"
                         + "on a single tolerated error alone; results were: " 
+ results);
         assertTrue(results.stream().anyMatch(r -> 
r.getCharset().name().startsWith("GB")),
@@ -102,14 +93,16 @@ public class ToleratedUtf8StructuralRegressionTest {
     public void sauteFilenameIsNotPromotedToUtf8() {
         byte[] probe = "Sauté.txt".getBytes(Charset.forName("windows-1252"));
         List<EncodingResult> results = newDetector().detect(probe);
-        boolean hasStructuralUtf8 = results.stream().anyMatch(r ->
-                "UTF-8".equals(r.getCharset().name())
-                        && r.getResultType() == 
EncodingResult.ResultType.STRUCTURAL);
-        assertFalse(hasStructuralUtf8,
+        assertFalse(hasStructuralUtf8(results),
                 "A short windows-1252 filename must not be promoted to 
STRUCTURAL "
                         + "UTF-8 on a single tolerated error alone; results 
were: " + results);
     }
 
+    private static boolean hasStructuralUtf8(List<EncodingResult> results) {
+        return results.stream().anyMatch(r -> 
"UTF-8".equals(r.getCharset().name())
+                && r.getResultType() == EncodingResult.ResultType.STRUCTURAL);
+    }
+
     /** Declared-windows-1252 HTML page, genuinely UTF-8, one stray raw © 
byte. */
     private static byte[] buildProbe(int repeatCount) throws IOException {
         StringBuilder body = new StringBuilder();
diff --git 
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/Utf8StatsTest.java
 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/Utf8StatsTest.java
new file mode 100644
index 0000000000..348b7d7988
--- /dev/null
+++ 
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/Utf8StatsTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.chardetect;
+
+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.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.ml.chardetect.StructuralEncodingRules.Utf8Result;
+import org.apache.tika.ml.chardetect.StructuralEncodingRules.Utf8Stats;
+
+/**
+ * Pins the single-pass {@link StructuralEncodingRules#utf8Stats} contract —
+ * including the edges where the pre-consolidation {@code checkUtf8} and
+ * {@code countUtf8Errors} deliberately differed (a provably-bad truncated
+ * tail is NOT_UTF8 but not an error <em>event</em>).
+ */
+public class Utf8StatsTest {
+
+    private static Utf8Stats stats(int... unsignedBytes) {
+        byte[] b = new byte[unsignedBytes.length];
+        for (int i = 0; i < b.length; i++) {
+            b[i] = (byte) unsignedBytes[i];
+        }
+        return StructuralEncodingRules.utf8Stats(b);
+    }
+
+    @Test
+    public void pureAsciiIsAmbiguousWithNoCounts() {
+        Utf8Stats s = StructuralEncodingRules.utf8Stats(
+                "plain ascii only".getBytes(StandardCharsets.US_ASCII));
+        assertEquals(0, s.errors());
+        assertEquals(0, s.sequences());
+        assertEquals(Utf8Result.AMBIGUOUS, s.toResult());
+    }
+
+    @Test
+    public void completeSequencesAreLikelyAndCounted() {
+        Utf8Stats s = StructuralEncodingRules.utf8Stats(
+                "héllo wörld 中文 😀".getBytes(StandardCharsets.UTF_8));
+        assertEquals(0, s.errors());
+        assertEquals(5, s.sequences()); // é ö 中 文 😀
+        assertEquals(Utf8Result.LIKELY_UTF8, s.toResult());
+    }
+
+    @Test
+    public void truncatedCleanLeadAtEndIsAmbiguousNotError() {
+        // lone C3 at probe-end: valid-so-far, no structural evidence
+        Utf8Stats s = stats('a', 'b', 0xC3);
+        assertEquals(0, s.errors());
+        assertEquals(0, s.sequences());
+        assertFalse(s.truncatedTailInvalid());
+        assertEquals(Utf8Result.AMBIGUOUS, s.toResult());
+    }
+
+    @Test
+    public void provablyBadTruncatedTailIsNotUtf8ButNotAnErrorEvent() {
+        // E0 41 at probe-end: 3-byte lead + non-continuation — cannot be 
UTF-8,
+        // but per U+FFFD-event semantics it is not counted as an error.
+        Utf8Stats s = stats('a', 0xE0, 0x41);
+        assertEquals(0, s.errors());
+        assertTrue(s.truncatedTailInvalid());
+        assertEquals(Utf8Result.NOT_UTF8, s.toResult());
+    }
+
+    @Test
+    public void classicErrorEventsEachCountOnce() {
+        // F8 lead + orphan continuation + overlong C0 + bad-continuation seq
+        Utf8Stats s = stats(0xF8, 'a', 0x80, 'b', 0xC0, 'c', 0xE0, 0x41, 0x41, 
'd');
+        assertEquals(4, s.errors());
+        assertEquals(0, s.sequences());
+        assertEquals(Utf8Result.NOT_UTF8, s.toResult());
+    }
+
+    @Test
+    public void surrogateAndOverlongThreeByteAreErrors() {
+        // ED A0 80 = U+D800 surrogate; E0 80 80 = overlong (cp < 0x0800)
+        Utf8Stats s = stats(0xED, 0xA0, 0x80, 0xE0, 0x80, 0x80);
+        assertEquals(2, s.errors());
+        assertEquals(0, s.sequences());
+    }
+
+    @Test
+    public void mixedErrorsAndSequencesTallyIndependently() {
+        // one stray legacy byte before genuine multi-byte content (TIKA-4810 
shape)
+        byte[] bengali = "সেমি".getBytes(StandardCharsets.UTF_8);
+        byte[] probe = new byte[1 + bengali.length];
+        probe[0] = (byte) 0xA9;
+        System.arraycopy(bengali, 0, probe, 1, bengali.length);
+        Utf8Stats s = StructuralEncodingRules.utf8Stats(probe);
+        assertEquals(1, s.errors());
+        assertEquals(4, s.sequences());
+        assertEquals(Utf8Result.NOT_UTF8, s.toResult());
+    }
+
+    @Test
+    public void sequenceLengthIsGrammarOnly() {
+        // grammar-only by design: overlong E0 80 80 still reports length 3
+        byte[] overlong = {(byte) 0xE0, (byte) 0x80, (byte) 0x80};
+        assertEquals(3, StructuralEncodingRules.utf8SequenceLength(overlong, 
0));
+        byte[] twoByte = "é".getBytes(StandardCharsets.UTF_8);
+        assertEquals(2, StructuralEncodingRules.utf8SequenceLength(twoByte, 
0));
+        byte[] c0Lead = {(byte) 0xC0, (byte) 0x80};
+        assertEquals(0, StructuralEncodingRules.utf8SequenceLength(c0Lead, 0));
+        byte[] truncated = {(byte) 0xE0, (byte) 0xA6};
+        assertEquals(0, StructuralEncodingRules.utf8SequenceLength(truncated, 
0));
+    }
+}
diff --git 
a/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetector.java
 
b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetector.java
index 46f100e63d..0253502c32 100644
--- 
a/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetector.java
+++ 
b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetector.java
@@ -83,10 +83,13 @@ public class JunkFilterEncodingDetector implements 
MetaEncodingDetector {
     private static final int DEFAULT_READ_LIMIT = 16384;
 
     /** A STATISTICAL candidate at or below this confidence carries no real
-     *  signal — it's the "I don't know" level (matches Mojibuster's
-     *  windows-1252 fallback confidence).  When the statistical layer offers
-     *  nothing above this, the junk-filter defers to a DECLARATIVE/STRUCTURAL
-     *  anchor instead of arbitrating near-identical decodes by quality. */
+     *  signal — it's the "I don't know" level.  MUST equal
+     *  MojibusterEncodingDetector.FALLBACK_CONFIDENCE (tika-encoding-detector-
+     *  mojibuster); the strict {@code >} in hasConfidentNonDeclarative is
+     *  load-bearing: at exactly this value the fallback counts as abstention.
+     *  When the statistical layer offers nothing above this, the junk-filter
+     *  defers to a DECLARATIVE/STRUCTURAL anchor instead of arbitrating
+     *  near-identical decodes by quality. */
     private static final float NO_INFO_CONFIDENCE = 0.1f;
 
     // Adaptive candidate band (TIKA speed lever).  The tournament only needs

Reply via email to