@@ -53,7 +53,7 @@ results are collected into an `EncodingDetectorContext` on the
5353| `MojibusterEncodingDetector`
5454| `tika-encoding-detector-mojibuster`
5555| Structural UTF-32 and UTF-16 detection, UTF-8 grammar gate, HTML
56- attribute-aware stripping, then a 33 -class byte-bigram NB
56+ attribute-aware stripping, then a 34 -class byte-bigram NB
5757 classifier. STRUCTURAL for structural hits; STATISTICAL for NB
5858 predictions. See <<nb-pipeline>>.
5959
@@ -135,6 +135,21 @@ sequences. Three outcomes:
135135* `AMBIGUOUS` — no complete multi-byte sequence (pure ASCII, or only
136136 a truncated lead at probe-end). No emission.
137137
138+ === ISO-2022-JP/KR/CN structural detection (pure-ASCII branch)
139+
140+ ISO-2022 encodings are 7-bit and escape-based (`ESC $ B`, `ESC $ ) C`, …),
141+ so they carry no high bytes and are invisible to the byte-bigram
142+ classifier; without a structural check a real ISO-2022-JP page would fall
143+ through to the windows-1252 default and decode to gibberish. On a
144+ pure-ASCII probe — the only place ISO-2022 can occur — the pipeline scans
145+ for the ISO-2022 designation escape and, if found, *verifies* by decoding:
146+ the result must contain real CJK at a near-zero replacement rate. The
147+ verify rejects a stray `ESC $` in ordinary ASCII (which yields no CJK).
148+ On success an ISO-2022-JP/KR/CN STRUCTURAL candidate is emitted. High-byte
149+ binary that happens to contain an escape sequence never reaches this
150+ check — it fails the pure-ASCII gate and takes the normal NB path, so it
151+ cannot trigger a false ISO-2022 detection.
152+
138153=== Layer 4 — HTML stripping (content-type aware)
139154
140155When the probe looks like HTML/XML (explicit content-type or unknown),
@@ -155,10 +170,10 @@ just content bytes for NB feature extraction. Optimizations:
155170
156171=== Layer 5 — Naive Bayes byte-bigram classifier
157172
158- 33 classes: CJK multibyte (Big5-HKSCS, EUC-JP, GB18030, Shift_JIS,
173+ 34 classes: CJK multibyte (Big5-HKSCS, EUC-JP, GB18030, Shift_JIS,
159174x-EUC-TW, x-windows-949), EBCDIC family (IBM420/424-ltr/rtl, IBM500,
160175IBM1047), DOS OEM (IBM850/852/855/866), Cyrillic (KOI8-R, KOI8-U),
161- Windows single-byte (1250-1258, 874), ISO-8859-3/16, Mac (x-MacRoman,
176+ Windows single-byte (1250-1258, 874), ISO-8859-2/ 3/16, Mac (x-MacRoman,
162177x-mac-cyrillic), and UTF-8.
163178
164179Features are **stride-1 byte bigrams** — for probe bytes `b[0..N]`,
@@ -212,9 +227,10 @@ every probe length we've measured.
212227
213228* **Empty / near-empty probes (< 2 bytes)** → windows-1252 @ 0.1
214229 confidence. WHATWG default; never returns empty result.
215- * **Pure ASCII probes** (no bytes ≥ 0x80, no nulls) → windows-1252.
216- Bigram NB cannot discriminate Latin code pages on pure-ASCII
217- content; return the HTML5-canonical answer directly.
230+ * **Pure ASCII probes** (no bytes ≥ 0x80, no nulls) → ISO-2022 structural
231+ detection first (see above); otherwise windows-1252. Bigram NB cannot
232+ discriminate Latin code pages on pure-ASCII content; return the
233+ HTML5-canonical answer directly.
218234* **Latin-sibling → windows-1252 rewrite** — on low-evidence probes
219235 (< 5 high bytes), if the top NB candidate is a non-1252 member of
220236 the Latin family and the probe decodes byte-identically under
@@ -223,6 +239,34 @@ every probe length we've measured.
223239 threshold are not emitted into the pool. Prevents JunkFilter from
224240 scoring weak coincidence picks against NB's confident top.
225241
242+ ==== CJK decode-failure veto (`CjkDecodeValidator`)
243+
244+ A legacy multi-byte CJK class (GB18030, Big5-HKSCS, Shift_JIS, EUC-JP,
245+ x-windows-949, x-EUC-TW) that NB picks on Latin/Cyrillic/garbage bytes is
246+ *false-CJK*: those bytes don't validate under the charset, so decoding
247+ produces many malformed/unmappable events, whereas real CJK decodes
248+ cleanly. After NB, each legacy-CJK candidate is decoded under its vendor
249+ superset (`CharsetSupersets`) and its failure rate measured as
250+ `failures / high-bytes`; above ~2.5% the candidate is dropped — and if it
251+ was NB's only pick, the pool empties and windows-1252 wins. Two
252+ corrections make the rate trustworthy:
253+
254+ * **Decode under the vendor superset, not the strict base** — real
255+ vendor-extension chars (NEC/IBM for Shift_JIS/EUC-JP, HKSCS for Big5)
256+ would otherwise count as failures and penalize genuine CJK.
257+ * **Discount embedded UTF-8** — mixed-encoding pages (legacy CJK body +
258+ UTF-8 widgets) would otherwise read as 2–9.5% failure. The validator
259+ walks the bytes and *skips* positions that begin a valid UTF-8 sequence
260+ (it does NOT physically strip them — that would misalign a pure
261+ legacy-CJK stream and manufacture failures), decoding the legacy charset
262+ in place elsewhere. Post-discount, real CJK (pure or mixed) is ≤1.6%
263+ while genuine false-CJK stays ≥5.3%, so ~2.5% separates them.
264+
265+ This veto catches *structurally-illegal* false-CJK only. The
266+ *legal-but-wrong* class — Latin/Cyrillic bytes that form a *valid* CJK
267+ decode at ~0 failure — is the typicality layer's job (<<junk-filter>>),
268+ not this veto's.
269+
226270[[junk-filter]]
227271== JunkFilterEncodingDetector — text-quality arbitration
228272
@@ -261,6 +305,36 @@ For plain first-match-wins, omit JunkFilter (see <<opting-out-of-arbitration>>).
261305. **Pairwise tournament** — first candidate seeds champion; each
262306 challenger compared via `JunkDetector.compare`; higher z-score wins.
263307
308+ === Post-tournament demote gates
309+
310+ Two demote-only refinements run after the champion is chosen. Each fires only
311+ to *demote* the champion across one boundary the whole-text z-score reads
312+ poorly under COMMON-dilution; neither can promote, so they cannot cost a
313+ confident detection.
314+
315+ * **CJK family gate** — the whole-text z coin-flips on the CJK/non-CJK boundary
316+ when markup and digits decode identically and swamp the few discriminating
317+ high bytes. A script-letter "diff" z — scored over only the `>= 0x80`
318+ letters/ideographs, where candidates actually differ — reads that boundary
319+ cleanly. If the champion is CJK and the best non-CJK diff-z beats the best
320+ CJK diff-z by `FAMILY_DIFF_MARGIN` (2.0), demote to the best non-CJK
321+ candidate. The reverse (promote to CJK) regressed at scale and is
322+ unnecessary — genuine CJK is `<meta>`-declared upstream.
323+
324+ * **Within-Latin letter gate** — among single-byte Latin siblings the z also
325+ coin-flips, occasionally promoting a DOS-OEM / Mac charset (IBM850,
326+ x-MacRoman) whose high bytes decode to box-drawing / symbols over the
327+ windows-1252 truth. Cased-letter count reads this where typicality cannot:
328+ if the champion is a Latin SBCS, a windows-1252 candidate is present, the
329+ probe is high-byte-dense, and windows-1252 decodes clearly more cased
330+ high-byte letters (by a margin), demote to windows-1252. Directional — a
331+ genuine Central-European / DOS document has *more* letters under its true
332+ charset, so the gate stays silent. Latin-scoped, so it never crosses the
333+ CJK boundary (the family gate's job) or touches a non-Latin SBCS, whose
334+ Cyrillic/Greek cased letters would pollute the count. Shares the
335+ `HighByteLetterStats` letter counter with Mojibuster's Western-Latin sibling
336+ fallback.
337+
264338=== JunkDetector scoring
265339
266340`JunkDetector` partitions decoded text into maximal Unicode-script runs
@@ -485,7 +559,7 @@ can't encode typographic characters).
485559 value, vocabulary size, and each trained bigram as
486560 `(uint16 bigram, int8 logP)` pairs.
487561
488- Files for the shipped 33 -class model are ~1 MB on disk. Loader
562+ Files for the shipped 34 -class model are ~1 MB on disk. Loader
489563materializes a dense `logP8[65 536 × numClasses]` array filled with
490564per-class unseen floors, overwritten by trained pairs. Working-set
491565memory: ~2 MB.
@@ -498,7 +572,7 @@ with feature hashing. The move to NB was driven by:
498572* **Speed**: direct bigram indexing removes the hash + bucket-lookup
499573 cost. Inner loop is `score[c] += logP[b × numClasses + c] × idf[b]`
500574 with no branching (zero-IDF bigrams are skipped before the class
501- loop). Measured ~15 µs on a full 1 KB probe for 33 classes.
575+ loop). Measured ~15 µs on a full 1 KB probe for 34 classes.
502576* **Memory layout**: bigram-major byte arrays fit in L3 cache for the
503577 full table. Sequential access through the hot loop is cache-line
504578 efficient.
0 commit comments