@@ -353,6 +353,16 @@ public static JunkDetector load(InputStream rawIs) throws IOException {
353353 f1TablesByScript .put (script , BigramTables .readFrom (dis ));
354354 }
355355
356+ requireUsableSigma ("scriptTransition" , scriptTransitionCalibration );
357+ requireUsableSigma ("block" , blockCalibration );
358+ requireUsableSigma ("control" , controlCalibration );
359+ requireUsableSigma ("z5" , z5Calibration );
360+ requireUsableSigma ("z6" , z6Calibration );
361+ requireUsableSigma ("z9" , z9Calibration );
362+ for (Map .Entry <String , float []> e : calibrations .entrySet ()) {
363+ requireUsableSigma ("z1[" + e .getKey () + "]" , e .getValue ());
364+ }
365+
356366 return new JunkDetector (calibrations ,
357367 blockTable , blockTableQuant , blockCalibration ,
358368 controlCalibration , combinerWeights ,
@@ -363,6 +373,22 @@ public static JunkDetector load(InputStream rawIs) throws IOException {
363373 }
364374 }
365375
376+ /**
377+ * Validates a calibration {@code {mu, sigma}} from the model file: sigma is the
378+ * divisor in every z-score, so it must be finite and > 0. Single enforcement
379+ * point for that invariant -- inference divides without re-checking.
380+ */
381+ static void requireUsableSigma (String name , float [] calibration ) throws IOException {
382+ boolean ok = calibration != null && calibration .length >= 2
383+ && Float .isFinite (calibration [1 ]) && calibration [1 ] > 0f ;
384+ if (!ok ) {
385+ String sigma = (calibration == null || calibration .length < 2 )
386+ ? "absent" : Float .toString (calibration [1 ]);
387+ throw new IOException ("Invalid model: " + name
388+ + " calibration sigma must be finite and > 0 but was " + sigma );
389+ }
390+ }
391+
366392 /** Read {@code size} big-endian int16 values as a short[]. */
367393 private static short [] readShortTable (DataInputStream dis , int size ) throws IOException {
368394 byte [] raw = dis .readNBytes (size * 2 );
@@ -642,7 +668,7 @@ public static final class FeatureComponents {
642668 */
643669 public float computeZ5LetterAdjacentToMarkRatio (String text ) {
644670 double raw = TextQualityFeatures .letterAdjacentToMarkRatio (text );
645- if (Double .isNaN (raw ) || z5Calibration == null || z5Calibration [ 1 ] <= 0 ) {
671+ if (Double .isNaN (raw ) || z5Calibration == null ) {
646672 return 0f ;
647673 }
648674 return ((float ) raw - z5Calibration [0 ]) / z5Calibration [1 ];
@@ -658,7 +684,7 @@ public float computeZ5LetterAdjacentToMarkRatio(String text) {
658684 */
659685 public float computeZ6ReplacementRatio (String text ) {
660686 double raw = TextQualityFeatures .replacementRatio (text );
661- if (Double .isNaN (raw ) || z6Calibration == null || z6Calibration [ 1 ] <= 0 ) {
687+ if (Double .isNaN (raw ) || z6Calibration == null ) {
662688 return 0f ;
663689 }
664690 // Flip sign: higher replacement = lower quality, so feature is
@@ -676,7 +702,7 @@ public float computeZ6ReplacementRatio(String text) {
676702 */
677703 public float computeZ9AlternationRatio (String text ) {
678704 double raw = TextQualityFeatures .scriptAlternationRatio (text );
679- if (Double .isNaN (raw ) || z9Calibration == null || z9Calibration [ 1 ] <= 0 ) {
705+ if (Double .isNaN (raw ) || z9Calibration == null ) {
680706 return 0f ;
681707 }
682708 // Higher alternation = junkier; (mu - raw) / sigma so clean text → positive z9.
0 commit comments