Move SSD learned guess selection to typed Kotlin

ober

c887b5d9a1721826f35a60677f41e7f182d7fcee

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index b9dc3e5..1c1860c 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -2107,6 +2107,68 @@
                   #t)
               #f))))
 
+    (typed-kotlin-file "com/sfb/ssdreview/LearnedGuessSelect.kt"
+      (kotlin-imports (org json JSONArray) (org json JSONObject))
+      (typed-library (com sfb ssdreview)
+        (export make-LearnedGuessMatch LearnedGuessMatch?
+                LearnedGuessMatch-example LearnedGuessMatch-score
+                bestLearnedGuessForGroup)
+        (type JSONArray)
+        (type JSONObject)
+        (type Float32)
+        (type Int32)
+        (record LearnedGuessMatch
+          ((example : JSONObject)
+           (score : Float32)))
+        (def (betterLearnedGuess (candidate : LearnedGuessMatch)
+                                 (current : (Nullable LearnedGuessMatch))) : (Nullable LearnedGuessMatch)
+          (if (nullable-null? current)
+            (nullable-some candidate)
+            (if (< (LearnedGuessMatch-score candidate)
+                   (LearnedGuessMatch-score (nullable-get current)))
+              (nullable-some candidate)
+              current)))
+        (def (learnedGuessCandidate (session : SsdSession)
+                                    (group : SsdGroup)
+                                    (features : JSONObject)
+                                    (example : JSONObject)) : (Nullable LearnedGuessMatch)
+          (let ((label (json-object-opt-string example "label")))
+            (if (or (equal? (json-object-opt-string example "source_key")
+                            (SsdSession-sourceKey session))
+                    (or (string-blank? label)
+                        (or (suppressedLearnedExample session example)
+                            (suppressedLearnedForGroup group example))))
+              (nullable-none LearnedGuessMatch)
+              (let ((candidateFeatures (json-object-opt-json-object example "features")))
+                (if (nullable-null? candidateFeatures)
+                  (nullable-none LearnedGuessMatch)
+                  (nullable-some
+                    (make-LearnedGuessMatch
+                      example
+                      (featureDistance features (nullable-get candidateFeatures)))))))))
+        (def (bestLearnedGuessForGroup (session : SsdSession)
+                                       (examples : JSONArray)
+                                       (group : SsdGroup)) : (Nullable LearnedGuessMatch)
+          (let ((features
+                  (groupFeaturesFromGroup
+                    group
+                    (SsdSession-imageWidth session)
+                    (SsdSession-imageHeight session))))
+            (for/fold ((best (nullable-none LearnedGuessMatch)))
+                      ((i (in-range (int32 0) (json-array-length examples))))
+              (let ((example (json-array-opt-json-object examples i)))
+                (if (nullable-null? example)
+                  best
+                  (let ((candidate
+                          (learnedGuessCandidate
+                            session
+                            group
+                            features
+                            (nullable-get example))))
+                    (if (nullable-null? candidate)
+                      best
+                      (betterLearnedGuess (nullable-get candidate) best))))))))))
+
     (typed-kotlin-file "com/sfb/ssdreview/OcrGeometry.kt"
       (typed-library (com sfb ssdreview)
         (export floatArrayGetOrZero
@@ -6406,25 +6468,12 @@
        "        var applied = 0"
        "        session.groups.forEach { group ->"
        "            if (group.label.isNotBlank() && !group.label.endsWith(\"?\")) return@forEach"
-       "            val features = groupFeaturesFromGroup(group, session.imageWidth, session.imageHeight)"
-       "            var best: JSONObject? = null"
-       "            var bestScore = Float.POSITIVE_INFINITY"
-       "            for (i in 0 until examples.length()) {"
-       "                val example = examples.getJSONObject(i)"
-       "                if (example.optString(\"source_key\") == session.sourceKey) continue"
-       "                val label = example.optString(\"label\")"
-       "                if (label.isBlank() || suppressedLearnedExample(session, example) || suppressedLearnedForGroup(group, example)) continue"
-       "                val score = featureDistance(features, example.optJSONObject(\"features\") ?: continue)"
-       "                if (score < bestScore) {"
-       "                    bestScore = score"
-       "                    best = example"
-       "                }"
-       "            }"
-       "            val match = best"
-       "            if (match != null && bestScore <= 0.22f) {"
-       "                group.label = \"${match.optString(\"label\")}?\""
-       "                group.boxTypeId = match.optString(\"box_type_id\", group.boxTypeId)"
-       "                group.notes = \"Learned guess from ${match.optString(\"truth_path\")} score %.3f\".format(bestScore)"
+       "            val match = bestLearnedGuessForGroup(session, examples, group)"
+       "            if (match != null && match.score <= 0.22f) {"
+       "                val example = match.example"
+       "                group.label = \"${example.optString(\"label\")}?\""
+       "                group.boxTypeId = example.optString(\"box_type_id\", group.boxTypeId)"
+       "                group.notes = \"Learned guess from ${example.optString(\"truth_path\")} score %.3f\".format(match.score)"
        "                applied += 1"
        "            }"
        "        }"