5656 * <p>All features are calibrated (mu/sigma) on held-out dev text so their z-scores
5757 * are on a common scale.
5858 *
59- * <ul>
60- * <li><b>Version 1</b>: bigrams only; z-score = z1.</li>
61- * <li><b>Version 2</b>: equal-weight average: {@code (z1 + z2 + z3) / 3}.</li>
62- * <li><b>Version 3</b>: per-script learned linear combination:
63- * {@code w1*z1 + w2*z2 + w3*z3 + bias}, where weights are fit by logistic
64- * regression on clean vs. corrupted dev windows. The natural junk threshold
65- * is 0 (positive logit = clean); use a negative threshold for conservative
66- * detection (e.g., {@code score < -1}).</li>
67- * </ul>
59+ * <p>Features are combined by a per-script logistic regression classifier:
60+ * {@code w1*z1 + w2*z2 + w3*z3 + w4*z4 + bias}, where weights are fit on
61+ * clean vs. corrupted dev windows. The natural junk threshold is 0 (positive
62+ * logit = clean); use a negative threshold for conservative detection
63+ * (e.g., {@code score < -1}).</p>
6864 *
6965 * <p>Instances are immutable and thread-safe after construction.
7066 *
@@ -182,7 +178,7 @@ public static JunkDetector loadFromPath(Path path) throws IOException {
182178
183179 /**
184180 * Loads a model from an {@link InputStream}. Gzip-detection is automatic.
185- * Supports model versions 1, 2, and 3 .
181+ * Supports model versions 1 through 5 .
186182 */
187183 public static JunkDetector load (InputStream rawIs ) throws IOException {
188184 byte [] peek = rawIs .readNBytes (2 );
@@ -201,86 +197,62 @@ public static JunkDetector load(InputStream rawIs) throws IOException {
201197 throw new IOException ("Not a JunkDetector model file (bad magic)" );
202198 }
203199 int version = dis .readUnsignedByte ();
204- if (version < 1 || version > 4 ) {
205- throw new IOException ("Unsupported model version: " + version );
200+ if (version != 5 ) {
201+ throw new IOException ("Unsupported model version: " + version
202+ + ". Only version 5 is supported. Retrain the model with TrainJunkModel." );
206203 }
207204
208205 int numScripts = dis .readInt ();
209206
210- // Version 2+: read global block table dimension
211- int blockN = 0 ;
212- Map <Character .UnicodeBlock , Integer > blockIndex = null ;
213- if (version >= 2 ) {
214- blockN = dis .readUnsignedShort ();
215- blockIndex = buildBlockIndex ();
216- int expectedN = blockIndex .size () + 1 ;
217- if (blockN != expectedN ) {
218- throw new IOException (String .format (
219- "Block table dimension mismatch: model has %d but JVM gives %d. "
220- + "Model was trained with a different Java version." , blockN , expectedN ));
221- }
207+ // Block names (v5): stored in model for JVM-independence
208+ int blockN = dis .readUnsignedShort ();
209+ String [] blockNames = new String [blockN - 1 ];
210+ for (int i = 0 ; i < blockN - 1 ; i ++) {
211+ int nameLen = dis .readUnsignedShort ();
212+ blockNames [i ] = new String (dis .readNBytes (nameLen ), StandardCharsets .UTF_8 );
222213 }
214+ Map <Character .UnicodeBlock , Integer > blockIndex = buildBlockIndexFromNames (blockNames );
223215
224- Map <String , float []> tables = new HashMap <>(numScripts * 2 );
225- Map <String , float []> calibrations = new HashMap <>(numScripts * 2 );
226-
227- Map <String , float []> blockTables = version >= 2 ? new HashMap <>(numScripts * 2 ) : null ;
228- Map <String , float []> blockCalibrations = version >= 2 ? new HashMap <>(numScripts * 2 ) : null ;
229- Map <String , float []> controlCalibrations = version >= 2 ? new HashMap <>(numScripts * 2 ) : null ;
230- Map <String , float []> classifierWeights = version >= 3 ? new HashMap <>(numScripts * 2 ) : null ;
231-
232- // Version 4+: global script-transition section
233- float [] scriptTransitionTable = null ;
234- float [] scriptTransitionCalibration = null ;
235- Map <String , Integer > scriptBucketIndex = null ;
236- int numScriptBuckets = 0 ;
237-
238- if (version >= 4 ) {
239- numScriptBuckets = dis .readUnsignedByte ();
240- scriptBucketIndex = new LinkedHashMap <>(numScriptBuckets * 2 );
241- for (int i = 0 ; i < numScriptBuckets ; i ++) {
242- int nameLen = dis .readUnsignedShort ();
243- String bucketName = new String (dis .readNBytes (nameLen ), StandardCharsets .UTF_8 );
244- scriptBucketIndex .put (bucketName , i );
245- }
246- scriptTransitionTable = readFloatTable (dis , numScriptBuckets * numScriptBuckets );
247- float mu4 = dis .readFloat ();
248- float sigma4 = dis .readFloat ();
249- scriptTransitionCalibration = new float []{mu4 , sigma4 };
216+ // Global script-transition section
217+ int numScriptBuckets = dis .readUnsignedByte ();
218+ Map <String , Integer > scriptBucketIndex = new LinkedHashMap <>(numScriptBuckets * 2 );
219+ for (int i = 0 ; i < numScriptBuckets ; i ++) {
220+ int nameLen = dis .readUnsignedShort ();
221+ String bucketName = new String (dis .readNBytes (nameLen ), StandardCharsets .UTF_8 );
222+ scriptBucketIndex .put (bucketName , i );
250223 }
224+ float [] scriptTransitionTable = readFloatTable (dis , numScriptBuckets * numScriptBuckets );
225+ float [] scriptTransitionCalibration = new float []{dis .readFloat (), dis .readFloat ()};
226+
227+ Map <String , float []> tables = new HashMap <>(numScripts * 2 );
228+ Map <String , float []> calibrations = new HashMap <>(numScripts * 2 );
229+ Map <String , float []> blockTables = new HashMap <>(numScripts * 2 );
230+ Map <String , float []> blockCalibrations = new HashMap <>(numScripts * 2 );
231+ Map <String , float []> controlCalibrations = new HashMap <>(numScripts * 2 );
232+ Map <String , float []> classifierWeights = new HashMap <>(numScripts * 2 );
251233
252234 for (int s = 0 ; s < numScripts ; s ++) {
253235 int nameLen = dis .readUnsignedShort ();
254236 String script = new String (dis .readNBytes (nameLen ), StandardCharsets .UTF_8 );
255237
256238 // Feature 1: byte bigrams
257- float mu1 = dis .readFloat ();
258- float sigma1 = dis .readFloat ();
259- calibrations .put (script , new float []{mu1 , sigma1 });
239+ calibrations .put (script , new float []{dis .readFloat (), dis .readFloat ()});
260240 tables .put (script , readFloatTable (dis , 65536 ));
261241
262- if (version >= 2 ) {
263- // Feature 2: named-block transitions
264- float mu2 = dis .readFloat ();
265- float sigma2 = dis .readFloat ();
266- blockCalibrations .put (script , new float []{mu2 , sigma2 });
267- blockTables .put (script , readFloatTable (dis , blockN * blockN ));
268-
269- // Feature 3: control-byte fraction
270- float mu3 = dis .readFloat ();
271- float sigma3 = dis .readFloat ();
272- controlCalibrations .put (script , new float []{mu3 , sigma3 });
273-
274- if (version >= 3 ) {
275- // Classifier weights: num_features (1 byte) + num_features floats + 1 bias
276- int numFeatures = dis .readUnsignedByte ();
277- float [] weights = new float [numFeatures + 1 ]; // last = bias
278- for (int j = 0 ; j <= numFeatures ; j ++) {
279- weights [j ] = dis .readFloat ();
280- }
281- classifierWeights .put (script , weights );
282- }
242+ // Feature 2: named-block transitions
243+ blockCalibrations .put (script , new float []{dis .readFloat (), dis .readFloat ()});
244+ blockTables .put (script , readFloatTable (dis , blockN * blockN ));
245+
246+ // Feature 3: control-byte fraction
247+ controlCalibrations .put (script , new float []{dis .readFloat (), dis .readFloat ()});
248+
249+ // Classifier weights: num_features (1 byte) + num_features floats + 1 bias
250+ int numFeatures = dis .readUnsignedByte ();
251+ float [] weights = new float [numFeatures + 1 ]; // last = bias
252+ for (int j = 0 ; j <= numFeatures ; j ++) {
253+ weights [j ] = dis .readFloat ();
283254 }
255+ classifierWeights .put (script , weights );
284256 }
285257
286258 return new JunkDetector (version , tables , calibrations ,
@@ -302,6 +274,7 @@ private static float[] readFloatTable(DataInputStream dis, int size) throws IOEx
302274 /**
303275 * Builds the stable ordered mapping from {@link Character.UnicodeBlock} to index.
304276 * This must produce the same ordering as {@link TrainJunkModel#buildBlockIndex()}.
277+ * Used for v2/v3/v4 models only; v5+ models store block names in the file.
305278 */
306279 static Map <Character .UnicodeBlock , Integer > buildBlockIndex () {
307280 LinkedHashMap <Character .UnicodeBlock , Integer > index = new LinkedHashMap <>();
@@ -312,6 +285,31 @@ static Map<Character.UnicodeBlock, Integer> buildBlockIndex() {
312285 return Collections .unmodifiableMap (index );
313286 }
314287
288+ /**
289+ * Builds a block index from an ordered array of block names stored in a v5+ model.
290+ * Resolves each name via {@link Character.UnicodeBlock#forName(String)}.
291+ * Throws {@link IOException} if any name is not recognised by the current JVM —
292+ * this means the model was trained on a newer JVM; retrain on the minimum
293+ * supported JVM (Java 17) to produce a compatible model.
294+ *
295+ * @param blockNames ordered array of block names (index = position in block table)
296+ * @return unmodifiable map from UnicodeBlock to table index
297+ */
298+ static Map <Character .UnicodeBlock , Integer > buildBlockIndexFromNames (String [] blockNames )
299+ throws IOException {
300+ Map <Character .UnicodeBlock , Integer > index = new HashMap <>(blockNames .length * 2 );
301+ for (int i = 0 ; i < blockNames .length ; i ++) {
302+ try {
303+ Character .UnicodeBlock b = Character .UnicodeBlock .forName (blockNames [i ]);
304+ index .put (b , i );
305+ } catch (IllegalArgumentException e ) {
306+ throw new IOException ("Unicode block not known to this JVM: " + blockNames [i ]
307+ + ". Model was trained on a newer JVM; retrain on Java 17." , e );
308+ }
309+ }
310+ return Collections .unmodifiableMap (index );
311+ }
312+
315313 // -----------------------------------------------------------------------
316314 // TextQualityDetector implementation
317315 // -----------------------------------------------------------------------
@@ -450,60 +448,53 @@ private float scoreChunk(byte[] utf8, String text, String script, float z4) {
450448 float [] cal1 = calibrations .get (script );
451449 float z1 = (meanBigramLogProb - cal1 [0 ]) / cal1 [1 ];
452450
453- float z2 = 0f , z3 = 0f ;
454- if (modelVersion >= 2 && blockTables != null ) {
455- // Feature 2: named-block transition mean log-prob
456- float [] blockTable = blockTables .get (script );
457- if (blockTable != null ) {
458- int nullId = blockN - 1 ;
459- int prev = -1 ;
460- double blockSum = 0 ;
461- int blockCount = 0 ;
462- for (int i = 0 ; i < text .length (); ) {
463- int cp = text .codePointAt (i );
464- Character .UnicodeBlock b = Character .UnicodeBlock .of (cp );
465- int blockId = b != null ? blockIndex .getOrDefault (b , nullId ) : nullId ;
466- if (prev >= 0 ) {
467- blockSum += blockTable [prev * blockN + blockId ];
468- blockCount ++;
469- }
470- prev = blockId ;
471- i += Character .charCount (cp );
472- }
473- if (blockCount > 0 ) {
474- float meanBlockLogProb = (float ) (blockSum / blockCount );
475- float [] cal2 = blockCalibrations .get (script );
476- z2 = cal2 != null ? (meanBlockLogProb - cal2 [0 ]) / cal2 [1 ] : 0f ;
451+ // Feature 2: named-block transition mean log-prob
452+ float z2 = 0f ;
453+ float [] blockTable = blockTables .get (script );
454+ if (blockTable != null ) {
455+ int nullId = blockN - 1 ;
456+ int prev = -1 ;
457+ double blockSum = 0 ;
458+ int blockCount = 0 ;
459+ for (int i = 0 ; i < text .length (); ) {
460+ int cp = text .codePointAt (i );
461+ Character .UnicodeBlock b = Character .UnicodeBlock .of (cp );
462+ int blockId = b != null ? blockIndex .getOrDefault (b , nullId ) : nullId ;
463+ if (prev >= 0 ) {
464+ blockSum += blockTable [prev * blockN + blockId ];
465+ blockCount ++;
477466 }
467+ prev = blockId ;
468+ i += Character .charCount (cp );
478469 }
479-
480- // Feature 3: control-byte fraction (stored as −fraction, so higher = cleaner)
481- long controlCount = 0 ;
482- for (byte b : utf8 ) {
483- if (isControlByte (b & 0xFF )) controlCount ++;
470+ if (blockCount > 0 ) {
471+ float meanBlockLogProb = (float ) (blockSum / blockCount );
472+ float [] cal2 = blockCalibrations .get (script );
473+ z2 = cal2 != null ? (meanBlockLogProb - cal2 [0 ]) / cal2 [1 ] : 0f ;
484474 }
485- float controlScore = -(float ) controlCount / utf8 .length ;
486- float [] cal3 = controlCalibrations .get (script );
487- z3 = cal3 != null ? (controlScore - cal3 [0 ]) / cal3 [1 ] : 0f ;
488475 }
489476
490- if (modelVersion >= 3 && classifierWeights != null ) {
491- float [] cw = classifierWeights .get (script );
492- if (cw != null ) {
493- int nFeat = cw .length - 1 ; // bias is last
494- float logit = cw [nFeat ]; // bias
495- if (nFeat >= 1 ) logit += cw [0 ] * z1 ;
496- if (nFeat >= 2 ) logit += cw [1 ] * z2 ;
497- if (nFeat >= 3 ) logit += cw [2 ] * z3 ;
498- if (nFeat >= 4 ) logit += cw [3 ] * z4 ;
499- return logit ;
500- }
501- return (z1 + z2 + z3 ) / 4.0f ; // fallback: equal weight including z4
502- } else if (modelVersion >= 2 && blockTables != null ) {
503- return (z1 + z2 + z3 ) / 3.0f ;
504- } else {
505- return z1 ;
477+ // Feature 3: control-byte fraction (stored as −fraction, so higher = cleaner)
478+ long controlCount = 0 ;
479+ for (byte b : utf8 ) {
480+ if (isControlByte (b & 0xFF )) controlCount ++;
481+ }
482+ float controlScore = -(float ) controlCount / utf8 .length ;
483+ float [] cal3 = controlCalibrations .get (script );
484+ float z3 = cal3 != null ? (controlScore - cal3 [0 ]) / cal3 [1 ] : 0f ;
485+
486+ // Per-script linear classifier: w1*z1 + w2*z2 + w3*z3 + w4*z4 + bias
487+ float [] cw = classifierWeights .get (script );
488+ if (cw != null ) {
489+ int nFeat = cw .length - 1 ; // bias is last
490+ float logit = cw [nFeat ]; // bias
491+ if (nFeat >= 1 ) logit += cw [0 ] * z1 ;
492+ if (nFeat >= 2 ) logit += cw [1 ] * z2 ;
493+ if (nFeat >= 3 ) logit += cw [2 ] * z3 ;
494+ if (nFeat >= 4 ) logit += cw [3 ] * z4 ;
495+ return logit ;
506496 }
497+ return (z1 + z2 + z3 + z4 ) / 4.0f ; // fallback: equal weight
507498 }
508499
509500 /**
@@ -512,8 +503,7 @@ private float scoreChunk(byte[] utf8, String text, String script, float z4) {
512503 * so that HIRAGANA, KATAKANA, and HAN remain distinct, preserving the
513504 * characteristic script-mixing pattern of Japanese text.
514505 *
515- * <p>Returns 0 if no v4 model is loaded or the string has fewer than two
516- * non-neutral codepoints.
506+ * <p>Returns 0 if the string has fewer than two non-neutral codepoints.
517507 */
518508 private float computeScriptTransitionZ (String text ) {
519509 if (scriptTransitionTable == null || scriptBucketIndex == null
0 commit comments